| author | |
| committer | |
| log | 3ab8402d3db4645647a187fd218bbe385cc6a123 |
| tree | 9cd26d29fa9424b2a43a57dc8e4a8ce7d495d6a3 |
| parent | 70f13f54049d9837fe63d489015fd50cd55b98cb |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover) |
Clover wants work PRs, queue status, and lifecycle decisions to share Mergebot's live data. Read repository scope and origins from private host configuration; keep workplace identifiers and credentials outside the patch.
Exchange the environment's GitHub credential for a Mergebot session and share one authenticated WebSocket. Accept verified snapshots, invalidate disconnected or superseded heads, distinguish check reruns, and release unused detail subscriptions. Configured work reads never fall back to GitHub or its cached PR data.
Stream queue badges to connected clients. Open Mergebot's own PR and queue interface inside T3's Browser on web, desktop, and mobile, preserving chat ownership, tab reuse, and normal close behavior. Standalone views do not create conversations. Native operations Mergebot does not expose direct the reader to its integrated interface.
Assisted-by: gpt-6.1-sol44 files changed, 4228 insertions(+), 295 deletions(-)
apps/mobile/src/native/useOpenPullRequest.test.ts created+126| ... | ... | @@ -0,0 +1,126 @@ |
| 1 | import { AsyncResult } from "effect/reactivity"; | |
| 2 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | const state = vi.hoisted(() => ({ | |
| 5 | navigate: vi.fn(), | |
| 6 | dispatch: vi.fn(), | |
| 7 | external: vi.fn(), | |
| 8 | alert: vi.fn(), | |
| 9 | open: vi.fn(), | |
| 10 | list: vi.fn(), | |
| 11 | serverBrowser: true, | |
| 12 | allowed: true, | |
| 13 | params: { environmentId: "environment", projectId: "project", threadId: "thread" } as Record< | |
| 14 | string, | |
| 15 | string | |
| 16 | >, | |
| 17 | })); | |
| 18 | vi.mock("react", () => ({ | |
| 19 | useCallback: (callback: unknown) => callback, | |
| 20 | useRef: (current: unknown) => ({ current }), | |
| 21 | })); | |
| 22 | vi.mock("@react-navigation/native", () => ({ | |
| 23 | useNavigation: () => ({ navigate: state.navigate, dispatch: state.dispatch }), | |
| 24 | useRoute: () => ({ params: state.params }), | |
| 25 | StackActions: {}, | |
| 26 | CommonActions: {}, | |
| 27 | })); | |
| 28 | vi.mock("react-native", () => ({ Platform: { OS: "ios" }, Alert: { alert: state.alert } })); | |
| 29 | vi.mock("@effect/atom-react", () => ({ | |
| 30 | useAtomValue: () => | |
| 31 | new Map([ | |
| 32 | [ | |
| 33 | "environment", | |
| 34 | { | |
| 35 | mergebot: { origin: "https://mergebot.example", repositories: ["Work/App"] }, | |
| 36 | environment: { capabilities: { serverBrowser: state.serverBrowser } }, | |
| 37 | }, | |
| 38 | ], | |
| 39 | ]), | |
| 40 | })); | |
| 41 | vi.mock("../state/entities", () => ({ useThreadShell: () => null })); | |
| 42 | vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {} })); | |
| 43 | vi.mock("../state/session", () => ({ readEnvironmentScope: () => state.allowed })); | |
| 44 | vi.mock("../state/preview", () => ({ previewEnvironment: { open: {}, list: {} } })); | |
| 45 | vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => state.open })); | |
| 46 | vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.list })); | |
| 47 | vi.mock("../lib/openExternalUrl", () => ({ tryOpenExternalUrl: state.external })); | |
| 48 | import { useOpenPullRequest } from "./useOpenPullRequest"; | |
| 49 | ||
| 50 | beforeEach(() => { | |
| 51 | state.params = { environmentId: "environment", projectId: "project", threadId: "thread" }; | |
| 52 | state.serverBrowser = true; | |
| 53 | state.allowed = true; | |
| 54 | state.navigate.mockReset(); | |
| 55 | state.dispatch.mockReset(); | |
| 56 | state.alert.mockReset(); | |
| 57 | state.external.mockReset(); | |
| 58 | state.open.mockReset().mockResolvedValue(AsyncResult.success({ tabId: "opened" })); | |
| 59 | state.list.mockReset().mockResolvedValue(AsyncResult.success({ sessions: [] })); | |
| 60 | }); | |
| 61 | describe("Mergebot PR navigation", () => { | |
| 62 | it.each(["https://github.com/work/app/pull/12", "https://mergebot.example/work/app/pull/12"])( | |
| 63 | "opens %s in the existing in-app browser with chat ownership", | |
| 64 | async (url) => { | |
| 65 | await expect(useOpenPullRequest()(url)).resolves.toBe(true); | |
| 66 | expect(state.open).toHaveBeenCalledWith( | |
| 67 | expect.objectContaining({ | |
| 68 | input: expect.objectContaining({ | |
| 69 | threadId: "thread", | |
| 70 | runtime: "server", | |
| 71 | url: "https://mergebot.example/Work/App/pull/12", | |
| 72 | }), | |
| 73 | }), | |
| 74 | ); | |
| 75 | expect(state.navigate).toHaveBeenCalledExactlyOnceWith("ThreadBrowserPreview", { | |
| 76 | environmentId: "environment", | |
| 77 | threadId: "thread", | |
| 78 | tabId: "opened", | |
| 79 | }); | |
| 80 | expect(state.external).not.toHaveBeenCalled(); | |
| 81 | }, | |
| 82 | ); | |
| 83 | it("reuses a matching server tab and supplies a stable owner without a chat", async () => { | |
| 84 | delete state.params.threadId; | |
| 85 | state.list.mockResolvedValue( | |
| 86 | AsyncResult.success({ | |
| 87 | sessions: [ | |
| 88 | { | |
| 89 | tabId: "existing", | |
| 90 | runtime: "server", | |
| 91 | navStatus: { _tag: "Success", url: "https://mergebot.example/Work/App/pull/12" }, | |
| 92 | }, | |
| 93 | ], | |
| 94 | }), | |
| 95 | ); | |
| 96 | await useOpenPullRequest()("https://github.com/work/app/pull/12"); | |
| 97 | expect(state.open).not.toHaveBeenCalled(); | |
| 98 | expect(state.navigate).toHaveBeenCalledWith("ThreadBrowserPreview", { | |
| 99 | environmentId: "environment", | |
| 100 | threadId: "mergebot-browser", | |
| 101 | tabId: "existing", | |
| 102 | }); | |
| 103 | }); | |
| 104 | it("shows unavailable browser access without silently opening native work details", async () => { | |
| 105 | state.serverBrowser = false; | |
| 106 | await expect(useOpenPullRequest()("https://github.com/work/app/pull/12")).resolves.toBe(true); | |
| 107 | expect(state.alert).toHaveBeenCalledOnce(); | |
| 108 | expect(state.navigate).not.toHaveBeenCalled(); | |
| 109 | expect(state.external).not.toHaveBeenCalled(); | |
| 110 | }); | |
| 111 | it("reports a recognized link failure once and marks it handled for callers", async () => { | |
| 112 | state.open.mockRejectedValueOnce(new Error("offline")); | |
| 113 | const handled = await useOpenPullRequest()("https://mergebot.example/work/app/pull/12"); | |
| 114 | if (!handled) state.alert("Generic PR failure"); | |
| 115 | expect(handled).toBe(true); | |
| 116 | expect(state.alert).toHaveBeenCalledExactlyOnceWith("Could not open Mergebot", "offline"); | |
| 117 | }); | |
| 118 | it("leaves unrelated repositories on the existing native PR path", async () => { | |
| 119 | await useOpenPullRequest()("https://github.com/other/repo/pull/12"); | |
| 120 | expect(state.open).not.toHaveBeenCalled(); | |
| 121 | expect(state.navigate).toHaveBeenCalledWith( | |
| 122 | "PullRequest", | |
| 123 | expect.objectContaining({ repository: "other/repo", host: "github.com", number: 12 }), | |
| 124 | ); | |
| 125 | }); | |
| 126 | }); |
apps/mobile/src/native/useOpenPullRequest.ts+107-4| ... | ... | @@ -1,11 +1,30 @@ |
| 1 | 1 | import { useNavigation, useRoute, StackActions } from "@react-navigation/native"; |
| 2 | 2 | import { nativePullRequestDestination } from "../lib/pullRequestNavigation"; |
| 3 | import { useCallback } from "react"; | |
| 4 | import { Platform } from "react-native"; | |
| 3 | import { useCallback, useRef } from "react"; | |
| 4 | import { useAtomValue } from "@effect/atom-react"; | |
| 5 | import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; | |
| 6 | import { | |
| 7 | isAtomCommandInterrupted, | |
| 8 | squashAtomCommandFailure, | |
| 9 | } from "@t3tools/client-runtime/state/runtime"; | |
| 10 | import { Alert, Platform } from "react-native"; | |
| 5 | 11 | import { CommonActions } from "@react-navigation/native"; |
| 6 | 12 | import { androidWorkspaceNavigationState } from "../lib/android-workspace-navigation"; |
| 7 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 13 | import { | |
| 14 | AuthPreviewOperateScope, | |
| 15 | EnvironmentId, | |
| 16 | FILL_PREVIEW_VIEWPORT, | |
| 17 | MERGEBOT_BROWSER_THREAD_ID, | |
| 18 | ThreadId, | |
| 19 | mergebotPullRequestUrl, | |
| 20 | parseMergebotPullRequestUrl, | |
| 21 | } from "@t3tools/contracts"; | |
| 8 | 22 | import { useThreadShell } from "../state/entities"; |
| 23 | import { previewEnvironment } from "../state/preview"; | |
| 24 | import { environmentServerConfigsAtom } from "../state/server"; | |
| 25 | import { readEnvironmentScope } from "../state/session"; | |
| 26 | import { useAtomCommand } from "../state/use-atom-command"; | |
| 27 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; | |
| 9 | 28 | import { tryOpenExternalUrl } from "../lib/openExternalUrl"; |
| 10 | 29 | |
| 11 | 30 | /** Use the thread's environment; the shared router resolves the repository's project there. */ |
| ... | ... | @@ -36,8 +55,83 @@ export function useOpenPullRequest(options?: { |
| 36 | 55 | const projectId = |
| 37 | 56 | options?.context?.projectId ?? first(routeContext?.projectId) ?? thread?.projectId; |
| 38 | 57 | const replace = options?.replace; |
| 58 | const configurations = useAtomValue(environmentServerConfigsAtom); | |
| 59 | const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); | |
| 60 | const listPreviews = useAtomQueryRunner(previewEnvironment.list, { | |
| 61 | refresh: true, | |
| 62 | reportFailure: false, | |
| 63 | }); | |
| 64 | const busy = useRef(false); | |
| 39 | 65 | return useCallback( |
| 40 | 66 | async (url: string) => { |
| 67 | const id = environmentId ? EnvironmentId.make(environmentId) : null; | |
| 68 | const configuration = id ? configurations.get(id) : undefined; | |
| 69 | const parsed: ChangeRequestLink | null = | |
| 70 | parseMergebotPullRequestUrl(configuration?.mergebot, url) ?? parseChangeRequestUrl(url); | |
| 71 | const mergebotUrl = parsed | |
| 72 | ? mergebotPullRequestUrl(configuration?.mergebot, { | |
| 73 | host: parsed.authority ?? parsed.host, | |
| 74 | repository: parsed.repository, | |
| 75 | number: parsed.number, | |
| 76 | }) | |
| 77 | : null; | |
| 78 | if (id && mergebotUrl) { | |
| 79 | if (busy.current) return true; | |
| 80 | if ( | |
| 81 | !configuration?.environment.capabilities.serverBrowser || | |
| 82 | !readEnvironmentScope(id, AuthPreviewOperateScope) | |
| 83 | ) { | |
| 84 | Alert.alert( | |
| 85 | "Mergebot browser unavailable", | |
| 86 | "Connect with browser access to an environment that can open Mergebot.", | |
| 87 | ); | |
| 88 | return true; | |
| 89 | } | |
| 90 | busy.current = true; | |
| 91 | try { | |
| 92 | const owner = ThreadId.make(threadId ?? MERGEBOT_BROWSER_THREAD_ID); | |
| 93 | const listed = await listPreviews({ environmentId: id, input: { threadId: owner } }); | |
| 94 | if (listed._tag === "Failure") { | |
| 95 | if (isAtomCommandInterrupted(listed)) return true; | |
| 96 | throw squashAtomCommandFailure(listed); | |
| 97 | } | |
| 98 | const matching = listed.value.sessions.find( | |
| 99 | (session) => | |
| 100 | session.runtime === "server" && | |
| 101 | session.navStatus._tag !== "Idle" && | |
| 102 | session.navStatus.url === mergebotUrl, | |
| 103 | ); | |
| 104 | const opened = matching | |
| 105 | ? null | |
| 106 | : await openPreview({ | |
| 107 | environmentId: id, | |
| 108 | input: { | |
| 109 | threadId: owner, | |
| 110 | url: mergebotUrl, | |
| 111 | runtime: "server", | |
| 112 | viewport: FILL_PREVIEW_VIEWPORT, | |
| 113 | }, | |
| 114 | }); | |
| 115 | if (opened?._tag === "Failure") { | |
| 116 | if (isAtomCommandInterrupted(opened)) return true; | |
| 117 | throw squashAtomCommandFailure(opened); | |
| 118 | } | |
| 119 | navigation.navigate("ThreadBrowserPreview", { | |
| 120 | environmentId: id, | |
| 121 | threadId: owner, | |
| 122 | tabId: matching?.tabId ?? (opened?._tag === "Success" ? opened.value.tabId : undefined), | |
| 123 | }); | |
| 124 | return true; | |
| 125 | } catch (error) { | |
| 126 | Alert.alert( | |
| 127 | "Could not open Mergebot", | |
| 128 | error instanceof Error ? error.message : "Try opening the pull request again.", | |
| 129 | ); | |
| 130 | return true; | |
| 131 | } finally { | |
| 132 | busy.current = false; | |
| 133 | } | |
| 134 | } | |
| 41 | 135 | const params = nativePullRequestDestination( |
| 42 | 136 | url, |
| 43 | 137 | environmentId && projectId |
| ... | ... | @@ -55,6 +149,15 @@ export function useOpenPullRequest(options?: { |
| 55 | 149 | else navigation.navigate("PullRequest", params); |
| 56 | 150 | return true; |
| 57 | 151 | }, |
| 58 | [environmentId, projectId, navigation, replace], | |
| 152 | [ | |
| 153 | configurations, | |
| 154 | environmentId, | |
| 155 | listPreviews, | |
| 156 | navigation, | |
| 157 | openPreview, | |
| 158 | projectId, | |
| 159 | replace, | |
| 160 | threadId, | |
| 161 | ], | |
| 59 | 162 | ); |
| 60 | 163 | } |
apps/server/src/auth/RpcAuthorization.test.ts+6| ... | ... | @@ -165,6 +165,12 @@ describe("RPC authorization scopes", () => { |
| 165 | 165 | ); |
| 166 | 166 | }); |
| 167 | 167 | |
| 168 | it("requires orchestration reads for Mergebot snapshots and live updates", () => { | |
| 169 | for (const method of [WS_METHODS.mergebotQueue, WS_METHODS.mergebotSubscribe]) { | |
| 170 | expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope); | |
| 171 | } | |
| 172 | }); | |
| 173 | ||
| 168 | 174 | it("separates preview control from observation", () => { |
| 169 | 175 | for (const method of [ |
| 170 | 176 | WS_METHODS.previewOpen, |
apps/server/src/auth/RpcAuthorization.ts+2| ... | ... | @@ -123,6 +123,8 @@ export const RPC_REQUIRED_SCOPES = { |
| 123 | 123 | [WS_METHODS.scheduledTasksGetWebhookDelivery]: AuthOrchestrationOperateScope, |
| 124 | 124 | [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, |
| 125 | 125 | [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, |
| 126 | [WS_METHODS.mergebotQueue]: AuthOrchestrationReadScope, | |
| 127 | [WS_METHODS.mergebotSubscribe]: AuthOrchestrationReadScope, | |
| 126 | 128 | [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, |
| 127 | 129 | [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, |
| 128 | 130 | [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope, |
apps/server/src/git/GitManager.test.ts+79| ... | ... | @@ -27,6 +27,7 @@ import { ChildProcessSpawner } from "effect/process"; |
| 27 | 27 | import { expect } from "vite-plus/test"; |
| 28 | 28 | import type { |
| 29 | 29 | ChangeRequest, |
| 30 | MergebotPullRequestDetails, | |
| 30 | 31 | GitActionProgressEvent, |
| 31 | 32 | GitManagerServiceError, |
| 32 | 33 | GitPreparePullRequestThreadInput, |
| ... | ... | @@ -73,6 +74,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 73 | 74 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; |
| 74 | 75 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 75 | 76 | import * as ServerSettings from "../serverSettings.ts"; |
| 77 | import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; | |
| 76 | 78 | import * as GitManager from "./GitManager.ts"; |
| 77 | 79 | |
| 78 | 80 | const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| ... | ... | @@ -723,6 +725,7 @@ function preparePullRequestThread( |
| 723 | 725 | } |
| 724 | 726 | |
| 725 | 727 | function makeManager(input?: { |
| 728 | mergebot?: MergebotQueue.MergebotQueue["Service"]; | |
| 726 | 729 | ghScenario?: FakeGhScenario; |
| 727 | 730 | sourceControlProvider?: SourceControlProvider["Service"]; |
| 728 | 731 | textGeneration?: Partial<FakeGitTextGeneration>; |
| ... | ... | @@ -837,6 +840,9 @@ function makeManager(input?: { |
| 837 | 840 | ); |
| 838 | 841 | |
| 839 | 842 | const layerManager = Layer.mergeAll( |
| 843 | input?.mergebot | |
| 844 | ? Layer.succeed(MergebotQueue.MergebotQueue, input.mergebot) | |
| 845 | : Layer.mock(MergebotQueue.MergebotQueue)({ configuration: null }), | |
| 840 | 846 | Layer.succeed(TextGeneration.TextGeneration, textGeneration), |
| 841 | 847 | Layer.mock(ProviderRegistry.ProviderRegistry)({ |
| 842 | 848 | getProviders: Effect.succeed([]), |
| ... | ... | @@ -880,6 +886,79 @@ const layerGitManagerTest = GitVcsDriver.layer.pipe( |
| 880 | 886 | ); |
| 881 | 887 | |
| 882 | 888 | it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 889 | it.effect( | |
| 890 | "work branch lookups and PR resolution use live Mergebot without GitHub cache fallback", | |
| 891 | () => | |
| 892 | Effect.gen(function* () { | |
| 893 | const repoDir = yield* makeTempDir("t3code-work-pr-"); | |
| 894 | yield* initRepo(repoDir); | |
| 895 | yield* runGit(repoDir, ["remote", "add", "origin", "https://github.com/work/app.git"]); | |
| 896 | yield* runGit(repoDir, ["checkout", "-b", "feature"]); | |
| 897 | yield* runGit(repoDir, ["update-ref", "refs/remotes/origin/feature", "HEAD"]); | |
| 898 | yield* runGit(repoDir, ["config", "branch.feature.remote", "origin"]); | |
| 899 | yield* runGit(repoDir, ["config", "branch.feature.merge", "refs/heads/feature"]); | |
| 900 | let value: MergebotPullRequestDetails | null = { | |
| 901 | number: 42, | |
| 902 | headSha: "head", | |
| 903 | lifecycle: "open", | |
| 904 | queueStatus: "waiting_ci", | |
| 905 | position: 1, | |
| 906 | mergeEtaAt: null, | |
| 907 | title: "Ship change", | |
| 908 | body: null, | |
| 909 | url: "https://mergebot.example/work/app/pull/42", | |
| 910 | createdAt: "2026-10-14T10:00:00Z", | |
| 911 | updatedAt: "2026-10-14T10:01:00Z", | |
| 912 | author: null, | |
| 913 | additions: 3, | |
| 914 | deletions: 1, | |
| 915 | changedFilesCount: 1, | |
| 916 | labels: [], | |
| 917 | comments: [], | |
| 918 | observedAt: 1791972060000, | |
| 919 | draft: false, | |
| 920 | baseBranch: "main", | |
| 921 | headBranch: "feature", | |
| 922 | ciStatus: "pending", | |
| 923 | hasConflicts: null, | |
| 924 | checks: [], | |
| 925 | }; | |
| 926 | const { manager, ghCalls } = yield* makeManager({ | |
| 927 | mergebot: { | |
| 928 | configuration: { origin: "https://mergebot.example", repositories: ["work/app"] }, | |
| 929 | read: () => Effect.succeed(null), | |
| 930 | watchQueue: () => Stream.empty, | |
| 931 | streamChanges: Stream.empty, | |
| 932 | readPullRequest: () => Effect.sync(() => value), | |
| 933 | findPullRequest: () => Effect.sync(() => value), | |
| 934 | }, | |
| 935 | }); | |
| 936 | expect(yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature" })).toMatchObject( | |
| 937 | { number: 42, title: "Ship change", repositoryKey: "github.com/work/app" }, | |
| 938 | ); | |
| 939 | value = { | |
| 940 | ...value!, | |
| 941 | title: "Live update", | |
| 942 | lifecycle: "merged", | |
| 943 | mergedAt: "2026-10-14T10:03:00Z", | |
| 944 | }; | |
| 945 | expect(yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature" })).toMatchObject( | |
| 946 | { title: "Live update", state: "merged" }, | |
| 947 | ); | |
| 948 | expect( | |
| 949 | (yield* manager.resolvePullRequest({ cwd: repoDir, reference: "42" })).pullRequest.title, | |
| 950 | ).toBe("Live update"); | |
| 951 | expect( | |
| 952 | (yield* manager | |
| 953 | .preparePullRequestThread({ cwd: repoDir, reference: "42", mode: "local" }) | |
| 954 | .pipe(Effect.flip)).message, | |
| 955 | ).toContain("Mergebot"); | |
| 956 | value = null; | |
| 957 | expect(yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature" })).toBeNull(); | |
| 958 | expect(ghCalls).toHaveLength(0); | |
| 959 | }), | |
| 960 | ); | |
| 961 | ||
| 883 | 962 | it.effect("passive worktree status streams do not start remote refreshes", () => |
| 884 | 963 | Effect.gen(function* () { |
| 885 | 964 | const repoDir = yield* makeTempDir("t3code-passive-vcs-"); |
apps/server/src/git/GitManager.ts+125-5| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; | |
| 1 | import { SourceControlProviderKind, type MergebotPullRequestDetails } from "@t3tools/contracts"; | |
| 2 | 2 | import * as Arr from "effect/Array"; |
| 3 | 3 | import * as Cache from "effect/Cache"; |
| 4 | 4 | import * as Clock from "effect/Clock"; |
| ... | ... | @@ -68,6 +68,7 @@ import { |
| 68 | 68 | } from "@t3tools/shared/threadPullRequests"; |
| 69 | 69 | |
| 70 | 70 | import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; |
| 71 | import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; | |
| 71 | 72 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 72 | 73 | import { |
| 73 | 74 | conventionalCommitsTextGenerationPolicy, |
| ... | ... | @@ -722,6 +723,7 @@ function toPullRequestHeadRemoteInfo(pr: { |
| 722 | 723 | |
| 723 | 724 | export const make = Effect.gen(function* () { |
| 724 | 725 | const gitCore = yield* GitVcsDriver.GitVcsDriver; |
| 726 | const mergebot = yield* MergebotQueue.MergebotQueue; | |
| 725 | 727 | const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; |
| 726 | 728 | const textGeneration = yield* TextGeneration.TextGeneration; |
| 727 | 729 | const providerRegistry = yield* ProviderRegistry.ProviderRegistry; |
| ... | ... | @@ -730,6 +732,28 @@ export const make = Effect.gen(function* () { |
| 730 | 732 | const fileSystem = yield* FileSystem.FileSystem; |
| 731 | 733 | const path = yield* Path.Path; |
| 732 | 734 | |
| 735 | const mergebotRepository = (target: string | null) => | |
| 736 | target === null | |
| 737 | ? undefined | |
| 738 | : mergebot.configuration?.repositories.find( | |
| 739 | (repository) => | |
| 740 | canonicalRepositoryKey(`github.com/${repository}`) === canonicalRepositoryKey(target), | |
| 741 | ); | |
| 742 | ||
| 743 | const mergebotPrInfo = (value: MergebotPullRequestDetails): PullRequestInfo => ({ | |
| 744 | number: value.number, | |
| 745 | title: value.title, | |
| 746 | url: value.url, | |
| 747 | state: value.lifecycle, | |
| 748 | baseRefName: value.baseBranch, | |
| 749 | headRefName: value.headBranch, | |
| 750 | headSha: value.headSha, | |
| 751 | isDraft: value.draft, | |
| 752 | updatedAt: DateTime.make(value.updatedAt), | |
| 753 | ...(value.mergedAt === undefined ? {} : { mergedAt: value.mergedAt }), | |
| 754 | ...(value.closedAt === undefined ? {} : { closedAt: value.closedAt }), | |
| 755 | }); | |
| 756 | ||
| 733 | 757 | const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); |
| 734 | 758 | const serverSettingsService = yield* ServerSettings.ServerSettingsService; |
| 735 | 759 | const threads = yield* ProjectionStore.ProjectionStoreV2; |
| ... | ... | @@ -1179,9 +1203,9 @@ export const make = Effect.gen(function* () { |
| 1179 | 1203 | // this lookup, so a merge it sees must reach the thread's link and settlement right away. |
| 1180 | 1204 | const pullRequestStateChanges = yield* PubSub.sliding<ThreadPullRequestKey>(64); |
| 1181 | 1205 | const lookupStates = new Map<string, PullRequestInfo["state"]>(); |
| 1182 | const noteLookupState = (pr: PullRequestInfo) => | |
| 1206 | const noteLookupState = (pr: PullRequestInfo, reference?: ThreadPullRequestKey) => | |
| 1183 | 1207 | Effect.suspend(() => { |
| 1184 | const parsed = parseChangeRequestUrl(pr.url); | |
| 1208 | const parsed = reference ?? parseChangeRequestUrl(pr.url); | |
| 1185 | 1209 | if (parsed === null || parsed.number !== pr.number) return Effect.void; |
| 1186 | 1210 | const key = normalizeThreadPullRequestKey(parsed); |
| 1187 | 1211 | const id = threadPullRequestKeyOf(key); |
| ... | ... | @@ -1244,7 +1268,27 @@ export const make = Effect.gen(function* () { |
| 1244 | 1268 | }, |
| 1245 | 1269 | }, |
| 1246 | 1270 | ); |
| 1247 | const getPrLookup = (key: string) => detachStackFrame(Cache.get(prLookupCache, key)); | |
| 1271 | const getPrLookup = Effect.fnUntraced(function* (key: string) { | |
| 1272 | if (mergebot.configuration !== null) { | |
| 1273 | const [cwd = "", branch = "", upstreamRef = "", defaultBranch = "", , remoteName = ""] = | |
| 1274 | key.split("\u0000"); | |
| 1275 | const { headContext, lookup } = yield* resolveLookupHeadContext(cwd, { | |
| 1276 | branch, | |
| 1277 | upstreamRef: upstreamRef || null, | |
| 1278 | defaultBranch: defaultBranch || null, | |
| 1279 | ...(remoteName ? { remoteName } : {}), | |
| 1280 | }); | |
| 1281 | const repository = mergebotRepository(headContext.targetRemoteUrlKey); | |
| 1282 | if (repository !== undefined) { | |
| 1283 | const latest = lookup ? yield* findLatestPrForHeadContext(cwd, headContext) : null; | |
| 1284 | if (latest !== null) | |
| 1285 | yield* noteLookupState(latest, { host: "github.com", repository, number: latest.number }); | |
| 1286 | return { latest, headContext }; | |
| 1287 | } | |
| 1288 | if (headContext.targetRemoteUrlKey === null) return { latest: null, headContext }; | |
| 1289 | } | |
| 1290 | return yield* detachStackFrame(Cache.get(prLookupCache, key)); | |
| 1291 | }); | |
| 1248 | 1292 | // A transient lookup failure (rate limit, network blip) must not clear an |
| 1249 | 1293 | // already-known PR badge, so the last successful answer per branch sticks |
| 1250 | 1294 | // around as the fallback. Keep the resolved head context with it so a |
| ... | ... | @@ -1827,6 +1871,18 @@ export const make = Effect.gen(function* () { |
| 1827 | 1871 | | "isCrossRepository" |
| 1828 | 1872 | >, |
| 1829 | 1873 | ) { |
| 1874 | if (mergebot.configuration !== null) { | |
| 1875 | const origin = yield* resolveRemoteRepositoryContext(cwd, "origin"); | |
| 1876 | const repository = mergebotRepository(origin.remoteUrlKey); | |
| 1877 | if (repository !== undefined) { | |
| 1878 | if (headContext.isCrossRepository) return null; | |
| 1879 | const value = yield* mergebot.findPullRequest({ | |
| 1880 | repository, | |
| 1881 | headBranch: headContext.headBranch, | |
| 1882 | }); | |
| 1883 | return value === null || value.lifecycle !== "open" ? null : mergebotPrInfo(value); | |
| 1884 | } | |
| 1885 | } | |
| 1830 | 1886 | const provider = yield* sourceControlProvider(cwd); |
| 1831 | 1887 | const probe = headBranchProbe(provider, headContext.headSelectors, "open"); |
| 1832 | 1888 | for (const headSelector of probe.headSelectors) { |
| ... | ... | @@ -1857,6 +1913,15 @@ export const make = Effect.gen(function* () { |
| 1857 | 1913 | cwd: string, |
| 1858 | 1914 | headContext: BranchHeadContext, |
| 1859 | 1915 | ) { |
| 1916 | const repository = mergebotRepository(headContext.targetRemoteUrlKey); | |
| 1917 | if (repository !== undefined) { | |
| 1918 | if (headContext.isCrossRepository) return null; | |
| 1919 | const value = yield* mergebot.findPullRequest({ | |
| 1920 | repository, | |
| 1921 | headBranch: headContext.headBranch, | |
| 1922 | }); | |
| 1923 | return value === null ? null : mergebotPrInfo(value); | |
| 1924 | } | |
| 1860 | 1925 | const parsedByNumber = new Map<number, PullRequestInfo>(); |
| 1861 | 1926 | |
| 1862 | 1927 | const provider = yield* sourceControlProvider(cwd); |
| ... | ... | @@ -2604,6 +2669,7 @@ export const make = Effect.gen(function* () { |
| 2604 | 2669 | } |
| 2605 | 2670 | } |
| 2606 | 2671 | const { latest } = cached; |
| 2672 | const workRepository = mergebotRepository(cached.headContext.targetRemoteUrlKey); | |
| 2607 | 2673 | if (latest === null) return null; |
| 2608 | 2674 | if ( |
| 2609 | 2675 | (branch === defaultBranch || |
| ... | ... | @@ -2619,7 +2685,10 @@ export const make = Effect.gen(function* () { |
| 2619 | 2685 | headSha: latest.headSha ?? null, |
| 2620 | 2686 | // Hosting CLIs can select an upstream repository instead of origin. |
| 2621 | 2687 | // The returned PR URL names the repository that actually owns it. |
| 2622 | repositoryKey: pullRequestRepositoryKey(latest.url), | |
| 2688 | repositoryKey: | |
| 2689 | workRepository === undefined | |
| 2690 | ? pullRequestRepositoryKey(latest.url) | |
| 2691 | : canonicalRepositoryKey(`github.com/${workRepository}`), | |
| 2623 | 2692 | }; |
| 2624 | 2693 | }); |
| 2625 | 2694 | const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( |
| ... | ... | @@ -2663,6 +2732,48 @@ export const make = Effect.gen(function* () { |
| 2663 | 2732 | const resolvePullRequest: GitManager["Service"]["resolvePullRequest"] = Effect.fn( |
| 2664 | 2733 | "resolvePullRequest", |
| 2665 | 2734 | )(function* (input) { |
| 2735 | if (mergebot.configuration !== null) { | |
| 2736 | const origin = yield* resolveRemoteRepositoryContext(input.cwd, "origin"); | |
| 2737 | const repository = mergebotRepository(origin.remoteUrlKey); | |
| 2738 | if (repository !== undefined) { | |
| 2739 | const normalized = normalizePullRequestReference(input.reference); | |
| 2740 | let number = /^\d+$/.test(normalized) ? Number(normalized) : null; | |
| 2741 | if (number === null) { | |
| 2742 | const parsed = parseChangeRequestUrl(normalized); | |
| 2743 | if ( | |
| 2744 | parsed?.host === "github.com" && | |
| 2745 | parsed.repository.toLowerCase() === repository.toLowerCase() | |
| 2746 | ) | |
| 2747 | number = parsed.number; | |
| 2748 | else { | |
| 2749 | const parsedUrl = URL.canParse(normalized) ? new URL(normalized) : null; | |
| 2750 | const parts = parsedUrl?.pathname.split("/").filter(Boolean); | |
| 2751 | if ( | |
| 2752 | parsedUrl?.origin === mergebot.configuration.origin && | |
| 2753 | parts?.length === 4 && | |
| 2754 | `${parts[0]}/${parts[1]}`.toLowerCase() === repository.toLowerCase() && | |
| 2755 | parts[2] === "pull" && | |
| 2756 | /^\d+$/.test(parts[3]!) | |
| 2757 | ) | |
| 2758 | number = Number(parts[3]); | |
| 2759 | } | |
| 2760 | } | |
| 2761 | if (number === null || !Number.isSafeInteger(number) || number < 1) | |
| 2762 | return yield* new GitManagerError({ | |
| 2763 | operation: "resolvePullRequest", | |
| 2764 | cwd: input.cwd, | |
| 2765 | detail: "Choose a pull request from this repository in Mergebot.", | |
| 2766 | }); | |
| 2767 | const value = yield* mergebot.readPullRequest({ repository, number }); | |
| 2768 | if (value === null) | |
| 2769 | return yield* new GitManagerError({ | |
| 2770 | operation: "resolvePullRequest", | |
| 2771 | cwd: input.cwd, | |
| 2772 | detail: "Mergebot is unavailable. Try again after it reconnects.", | |
| 2773 | }); | |
| 2774 | return { pullRequest: toResolvedPullRequest(mergebotPrInfo(value)) }; | |
| 2775 | } | |
| 2776 | } | |
| 2666 | 2777 | const pullRequest = yield* (yield* sourceControlProvider(input.cwd)) |
| 2667 | 2778 | .getChangeRequest({ |
| 2668 | 2779 | cwd: input.cwd, |
| ... | ... | @@ -2676,6 +2787,15 @@ export const make = Effect.gen(function* () { |
| 2676 | 2787 | const preparePullRequestThread: GitManager["Service"]["preparePullRequestThread"] = Effect.fn( |
| 2677 | 2788 | "preparePullRequestThread", |
| 2678 | 2789 | )(function* (input) { |
| 2790 | if (mergebot.configuration !== null) { | |
| 2791 | const origin = yield* resolveRemoteRepositoryContext(input.cwd, "origin"); | |
| 2792 | if (mergebotRepository(origin.remoteUrlKey) !== undefined) | |
| 2793 | return yield* new GitManagerError({ | |
| 2794 | operation: "preparePullRequestThread", | |
| 2795 | cwd: input.cwd, | |
| 2796 | detail: "Open this pull request in Mergebot to choose its workspace.", | |
| 2797 | }); | |
| 2798 | } | |
| 2679 | 2799 | const maybeRunSetupScript = (worktreePath: string) => { |
| 2680 | 2800 | if (!input.threadId) { |
| 2681 | 2801 | return Effect.void; |
apps/server/src/mergebot/MergebotQueue.test.ts created+667| ... | ... | @@ -0,0 +1,667 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as HostProcess from "@t3tools/shared/HostProcess"; | |
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Redacted from "effect/Redacted"; | |
| 9 | import * as Stream from "effect/Stream"; | |
| 10 | import * as Fiber from "effect/Fiber"; | |
| 11 | import * as Deferred from "effect/Deferred"; | |
| 12 | import * as TestClock from "effect/testing/TestClock"; | |
| 13 | import * as Socket from "effect/socket/Socket"; | |
| 14 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 15 | import * as MergebotQueue from "./MergebotQueue.ts"; | |
| 16 | ||
| 17 | const repository = "work/app"; | |
| 18 | const url = `https://mergebot.example/${repository}`; | |
| 19 | const configuration = { origin: "https://mergebot.example", repositories: [repository] }; | |
| 20 | const pr = { | |
| 21 | id: `${repository}#123`, | |
| 22 | repoId: repository, | |
| 23 | number: 123, | |
| 24 | authorId: null, | |
| 25 | createdAt: "2026-10-14T10:00:00Z", | |
| 26 | additions: 3, | |
| 27 | deletions: 1, | |
| 28 | changedFilesCount: 2, | |
| 29 | labels: [], | |
| 30 | title: "Ship change", | |
| 31 | updatedAt: "2026-10-14T10:02:00Z", | |
| 32 | body: null, | |
| 33 | draft: false, | |
| 34 | headBranch: "feature", | |
| 35 | baseBranch: "main", | |
| 36 | headSha: "current-head", | |
| 37 | lifecycle: "open", | |
| 38 | queueStatus: "waiting_ci", | |
| 39 | mergeEtaAt: null, | |
| 40 | ciStatus: "pending", | |
| 41 | hasConflicts: false, | |
| 42 | url: "https://github.com/work/app/pull/123", | |
| 43 | }; | |
| 44 | const check = { | |
| 45 | id: "check:1", | |
| 46 | name: "Tests", | |
| 47 | workflowName: "CI", | |
| 48 | status: "completed", | |
| 49 | conclusion: "failure", | |
| 50 | url: null, | |
| 51 | startedAt: "2026-10-14T10:00:00Z", | |
| 52 | completedAt: "2026-10-14T10:01:00Z", | |
| 53 | }; | |
| 54 | const repoChannel = { kind: "repo_overview", repoId: repository }; | |
| 55 | const detailChannel = { kind: "pull_request_detail", repoId: repository, prNumber: 123 }; | |
| 56 | const repoSnapshot = { | |
| 57 | observedAt: "2026-10-14T10:02:00Z", | |
| 58 | type: "snapshot", | |
| 59 | channel: repoChannel, | |
| 60 | channelKey: `repo_overview:${repository}`, | |
| 61 | payload: { | |
| 62 | entities: { repos: { [repository]: { processing: true } }, pullRequests: { [pr.id]: pr } }, | |
| 63 | view: { repoId: repository, queuePrIds: [pr.id] }, | |
| 64 | }, | |
| 65 | }; | |
| 66 | const detailSnapshot = { | |
| 67 | type: "snapshot", | |
| 68 | channel: detailChannel, | |
| 69 | channelKey: `pull_request_detail:${pr.id}`, | |
| 70 | observedAt: "2026-10-14T10:02:00Z", | |
| 71 | payload: { | |
| 72 | entities: { pullRequests: { [pr.id]: pr }, users: {} }, | |
| 73 | view: { | |
| 74 | repoId: repository, | |
| 75 | prId: pr.id, | |
| 76 | comments: [], | |
| 77 | checks: [check], | |
| 78 | isLoadingDetails: false, | |
| 79 | }, | |
| 80 | }, | |
| 81 | }; | |
| 82 | ||
| 83 | class TestSocket implements Socket.WebSocketLike { | |
| 84 | readyState = 1; | |
| 85 | readonly sent: unknown[] = []; | |
| 86 | readonly listeners = new Map<string, Set<(event: Socket.WebSocketEvent) => void>>(); | |
| 87 | readonly onSubscribe: (socket: TestSocket, channel: { kind: string }) => void; | |
| 88 | constructor(onSubscribe: (socket: TestSocket, channel: { kind: string }) => void) { | |
| 89 | this.onSubscribe = onSubscribe; | |
| 90 | } | |
| 91 | addEventListener(type: string, listener: (event: Socket.WebSocketEvent) => void) { | |
| 92 | const set = this.listeners.get(type) ?? new Set(); | |
| 93 | set.add(listener); | |
| 94 | this.listeners.set(type, set); | |
| 95 | } | |
| 96 | removeEventListener(type: string, listener: (event: Socket.WebSocketEvent) => void) { | |
| 97 | this.listeners.get(type)?.delete(listener); | |
| 98 | } | |
| 99 | emit(type: string, event: Socket.WebSocketEvent) { | |
| 100 | for (const listener of this.listeners.get(type) ?? []) listener(event); | |
| 101 | } | |
| 102 | close() { | |
| 103 | if (this.readyState === 3) return; | |
| 104 | this.readyState = 3; | |
| 105 | this.emit("close", { code: 1000, reason: "Test close" }); | |
| 106 | } | |
| 107 | send(data: string | Uint8Array<ArrayBuffer>) { | |
| 108 | const message = JSON.parse(String(data)); | |
| 109 | this.sent.push(message); | |
| 110 | if (message.type === "subscribe") this.onSubscribe(this, message.channel); | |
| 111 | } | |
| 112 | snapshot(value: unknown) { | |
| 113 | this.emit("message", { data: JSON.stringify(value) }); | |
| 114 | } | |
| 115 | } | |
| 116 | ||
| 117 | function harness(configurationText: string | null = JSON.stringify(configuration)) { | |
| 118 | let token: string | null = "test-github-token"; | |
| 119 | let onSubscribe = (socket: TestSocket, channel: { kind: string }) => { | |
| 120 | socket.snapshot(channel.kind === "repo_overview" ? repoSnapshot : detailSnapshot); | |
| 121 | }; | |
| 122 | const sockets: TestSocket[] = []; | |
| 123 | const exchanges: Array<{ url: string; authorization: string | undefined }> = []; | |
| 124 | let exchangeStatus = 200; | |
| 125 | const requests: Array<{ url: string; options?: Socket.WebSocketConstructorOptions }> = []; | |
| 126 | return { | |
| 127 | sockets, | |
| 128 | requests, | |
| 129 | exchanges, | |
| 130 | setExchangeStatus: (status: number) => { | |
| 131 | exchangeStatus = status; | |
| 132 | }, | |
| 133 | setToken: (value: string | null) => { | |
| 134 | token = value; | |
| 135 | }, | |
| 136 | subscribeWith: (handler: typeof onSubscribe) => { | |
| 137 | onSubscribe = handler; | |
| 138 | }, | |
| 139 | layer: MergebotQueue.layerWithSocketConstructor.pipe( | |
| 140 | Layer.provide( | |
| 141 | Layer.mergeAll( | |
| 142 | NodeServices.layer, | |
| 143 | Layer.succeed( | |
| 144 | HttpClient.HttpClient, | |
| 145 | HttpClient.make((request) => { | |
| 146 | exchanges.push({ url: request.url, authorization: request.headers.authorization }); | |
| 147 | return Effect.succeed( | |
| 148 | HttpClientResponse.fromWeb( | |
| 149 | request, | |
| 150 | Response.json({ token: "sealed-mergebot-session" }, { status: exchangeStatus }), | |
| 151 | ), | |
| 152 | ); | |
| 153 | }), | |
| 154 | ), | |
| 155 | Layer.succeed(HostProcess.HomeDirectory, "/test"), | |
| 156 | Layer.succeed( | |
| 157 | FileSystem.FileSystem, | |
| 158 | FileSystem.makeNoop( | |
| 159 | configurationText === null | |
| 160 | ? {} | |
| 161 | : { | |
| 162 | readFileString: () => Effect.succeed(configurationText), | |
| 163 | }, | |
| 164 | ), | |
| 165 | ), | |
| 166 | Layer.succeed(GitHubCredentials.GitHubCredentials, { | |
| 167 | get: (host: string) => | |
| 168 | token === null | |
| 169 | ? Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })) | |
| 170 | : Effect.succeed({ | |
| 171 | host, | |
| 172 | token: Redacted.make(token), | |
| 173 | fingerprint: token, | |
| 174 | source: "gh" as const, | |
| 175 | }), | |
| 176 | invalidate: () => Effect.void, | |
| 177 | }), | |
| 178 | Layer.succeed(Socket.WebSocketConstructor, (url, options) => { | |
| 179 | requests.push({ url, ...(options ? { options } : {}) }); | |
| 180 | const socket = new TestSocket((...args) => onSubscribe(...args)); | |
| 181 | sockets.push(socket); | |
| 182 | return socket; | |
| 183 | }), | |
| 184 | ), | |
| 185 | ), | |
| 186 | ), | |
| 187 | }; | |
| 188 | } | |
| 189 | ||
| 190 | const nextChange = (service: MergebotQueue.MergebotQueue["Service"]) => | |
| 191 | Stream.runHead(service.streamChanges).pipe(Effect.forkScoped({ startImmediately: true })); | |
| 192 | ||
| 193 | describe("MergebotQueue", () => { | |
| 194 | it.effect.each([ | |
| 195 | null, | |
| 196 | "not JSON", | |
| 197 | JSON.stringify({ ...configuration, origin: "http://mergebot.example" }), | |
| 198 | JSON.stringify({ ...configuration, origin: "https://user:password@mergebot.example" }), | |
| 199 | JSON.stringify({ ...configuration, repositories: ["work/app/extra"] }), | |
| 200 | ])("disables malformed private configuration: %s", (text) => { | |
| 201 | const h = harness(text); | |
| 202 | return Effect.gen(function* () { | |
| 203 | const service = yield* MergebotQueue.MergebotQueue; | |
| 204 | expect(service.configuration).toBeNull(); | |
| 205 | expect(yield* service.read({ repository })).toBeNull(); | |
| 206 | expect(h.requests).toHaveLength(0); | |
| 207 | }).pipe(Effect.provide(h.layer)); | |
| 208 | }); | |
| 209 | ||
| 210 | it.effect( | |
| 211 | "shares one authenticated stream, derives queue position and never exposes the credential", | |
| 212 | () => { | |
| 213 | const h = harness(); | |
| 214 | return Effect.gen(function* () { | |
| 215 | const service = yield* MergebotQueue.MergebotQueue; | |
| 216 | const [a, b] = yield* Effect.all( | |
| 217 | [service.read({ repository }), service.read({ repository: "WORK/APP" })], | |
| 218 | { concurrency: "unbounded" }, | |
| 219 | ); | |
| 220 | expect(a).toEqual(b); | |
| 221 | expect(a?.pullRequests?.[0]).toMatchObject({ | |
| 222 | number: 123, | |
| 223 | position: 1, | |
| 224 | url: `${url}/pull/123`, | |
| 225 | }); | |
| 226 | expect(h.requests).toEqual([ | |
| 227 | { | |
| 228 | url: "wss://mergebot.example/api/state/ws", | |
| 229 | options: { headers: { Authorization: "Bearer sealed-mergebot-session" } }, | |
| 230 | }, | |
| 231 | ]); | |
| 232 | expect(JSON.stringify(a)).not.toContain("test-github-token"); | |
| 233 | expect(h.sockets[0]?.sent).toHaveLength(1); | |
| 234 | expect(yield* service.readPullRequest({ repository, number: 123 })).toMatchObject({ | |
| 235 | headSha: "current-head", | |
| 236 | checks: [check], | |
| 237 | }); | |
| 238 | expect(h.requests).toHaveLength(1); | |
| 239 | expect(h.exchanges).toEqual([ | |
| 240 | { | |
| 241 | url: "https://mergebot.example/api/auth/github/token", | |
| 242 | authorization: "Bearer test-github-token", | |
| 243 | }, | |
| 244 | ]); | |
| 245 | }).pipe(Effect.provide(h.layer)); | |
| 246 | }, | |
| 247 | ); | |
| 248 | ||
| 249 | it.effect("missing credentials and unapproved repositories never open a socket", () => { | |
| 250 | const h = harness(); | |
| 251 | h.setToken(null); | |
| 252 | return Effect.gen(function* () { | |
| 253 | const service = yield* MergebotQueue.MergebotQueue; | |
| 254 | expect(yield* service.read({ repository: "other/app" })).toBeNull(); | |
| 255 | expect(yield* service.read({ repository })).toBeNull(); | |
| 256 | expect(yield* service.readPullRequest({ repository, number: 123 })).toBeNull(); | |
| 257 | expect(h.requests).toHaveLength(0); | |
| 258 | }).pipe(Effect.provide(h.layer)); | |
| 259 | }); | |
| 260 | ||
| 261 | it.effect("cached, loading and unverified detail snapshots cannot satisfy a fresh read", () => { | |
| 262 | const h = harness(); | |
| 263 | h.subscribeWith((socket, channel) => { | |
| 264 | if (channel.kind === "repo_overview") socket.snapshot(repoSnapshot); | |
| 265 | else { | |
| 266 | socket.snapshot({ ...detailSnapshot, observedAt: null }); | |
| 267 | socket.snapshot({ ...detailSnapshot, observedAt: null }); | |
| 268 | socket.snapshot({ | |
| 269 | ...detailSnapshot, | |
| 270 | payload: { | |
| 271 | ...detailSnapshot.payload, | |
| 272 | view: { ...detailSnapshot.payload.view, isLoadingDetails: true }, | |
| 273 | }, | |
| 274 | }); | |
| 275 | socket.snapshot({ | |
| 276 | ...detailSnapshot, | |
| 277 | payload: { | |
| 278 | ...detailSnapshot.payload, | |
| 279 | entities: { users: {}, pullRequests: { [pr.id]: { ...pr, headSha: "verified-head" } } }, | |
| 280 | }, | |
| 281 | }); | |
| 282 | } | |
| 283 | }); | |
| 284 | return Effect.gen(function* () { | |
| 285 | const service = yield* MergebotQueue.MergebotQueue; | |
| 286 | expect(yield* service.readPullRequest({ repository, number: 123 })).toMatchObject({ | |
| 287 | headSha: "verified-head", | |
| 288 | position: null, | |
| 289 | }); | |
| 290 | }).pipe(Effect.provide(h.layer)); | |
| 291 | }); | |
| 292 | ||
| 293 | it.effect("live head changes replace checks and reruns preserve a new failure identity", () => { | |
| 294 | const h = harness(); | |
| 295 | return Effect.gen(function* () { | |
| 296 | const service = yield* MergebotQueue.MergebotQueue; | |
| 297 | yield* service.readPullRequest({ repository, number: 123 }); | |
| 298 | const changed = yield* nextChange(service); | |
| 299 | const rerun = { ...check, id: "check:2", completedAt: "2026-10-14T10:05:00Z" }; | |
| 300 | h.sockets[0]!.snapshot({ | |
| 301 | ...detailSnapshot, | |
| 302 | payload: { | |
| 303 | ...detailSnapshot.payload, | |
| 304 | entities: { | |
| 305 | users: {}, | |
| 306 | pullRequests: { [pr.id]: { ...pr, headSha: "new-head", ciStatus: "failed" } }, | |
| 307 | }, | |
| 308 | view: { ...detailSnapshot.payload.view, checks: [rerun] }, | |
| 309 | }, | |
| 310 | }); | |
| 311 | yield* Fiber.join(changed); | |
| 312 | expect(yield* service.readPullRequest({ repository, number: 123 })).toMatchObject({ | |
| 313 | headSha: "new-head", | |
| 314 | ciStatus: "failed", | |
| 315 | checks: [rerun], | |
| 316 | }); | |
| 317 | }).pipe(Effect.provide(h.layer)); | |
| 318 | }); | |
| 319 | ||
| 320 | it.effect( | |
| 321 | "disconnect invalidates every snapshot and reconnect resubscribes before accepting fresh data", | |
| 322 | () => { | |
| 323 | const h = harness(); | |
| 324 | return Effect.gen(function* () { | |
| 325 | const service = yield* MergebotQueue.MergebotQueue; | |
| 326 | yield* service.read({ repository }); | |
| 327 | yield* service.readPullRequest({ repository, number: 123 }); | |
| 328 | const changed = yield* nextChange(service); | |
| 329 | h.sockets[0]!.close(); | |
| 330 | yield* Fiber.join(changed); | |
| 331 | expect(yield* service.read({ repository })).toBeNull(); | |
| 332 | expect(yield* service.readPullRequest({ repository, number: 123 })).toBeNull(); | |
| 333 | yield* TestClock.adjust("1 second"); | |
| 334 | expect(h.requests).toHaveLength(2); | |
| 335 | expect(h.sockets[1]?.sent).toHaveLength(2); | |
| 336 | expect(yield* service.readPullRequest({ repository, number: 123 })).toMatchObject({ | |
| 337 | checks: [check], | |
| 338 | }); | |
| 339 | }).pipe(Effect.provide(h.layer)); | |
| 340 | }, | |
| 341 | ); | |
| 342 | ||
| 343 | it.effect("failed initial reads publish unavailable once across authentication retries", () => { | |
| 344 | const h = harness(); | |
| 345 | h.setExchangeStatus(503); | |
| 346 | return Effect.gen(function* () { | |
| 347 | const service = yield* MergebotQueue.MergebotQueue; | |
| 348 | let changes = 0; | |
| 349 | yield* service.streamChanges.pipe( | |
| 350 | Stream.runForEach(() => Effect.sync(() => changes++)), | |
| 351 | Effect.forkScoped({ startImmediately: true }), | |
| 352 | ); | |
| 353 | expect(yield* service.read({ repository })).toBeNull(); | |
| 354 | yield* TestClock.adjust("3 seconds"); | |
| 355 | expect(h.exchanges).toHaveLength(4); | |
| 356 | expect(changes).toBe(1); | |
| 357 | h.setExchangeStatus(200); | |
| 358 | const recovered = yield* nextChange(service); | |
| 359 | yield* TestClock.adjust("1 second"); | |
| 360 | yield* Fiber.join(recovered); | |
| 361 | expect(yield* service.read({ repository })).toMatchObject({ status: "ready" }); | |
| 362 | expect(changes).toBe(2); | |
| 363 | }).pipe(Effect.provide(h.layer)); | |
| 364 | }); | |
| 365 | ||
| 366 | it.effect("an outage invalidates ready channels once and publishes fresh recovery", () => { | |
| 367 | const h = harness(); | |
| 368 | return Effect.gen(function* () { | |
| 369 | const service = yield* MergebotQueue.MergebotQueue; | |
| 370 | yield* service.readPullRequest({ repository, number: 123 }); | |
| 371 | const invalidated = yield* Deferred.make<void>(); | |
| 372 | let changes = 0; | |
| 373 | yield* service.streamChanges.pipe( | |
| 374 | Stream.runForEach(() => | |
| 375 | Effect.gen(function* () { | |
| 376 | changes++; | |
| 377 | if (changes === 2) yield* Deferred.succeed(invalidated, undefined); | |
| 378 | }), | |
| 379 | ), | |
| 380 | Effect.forkScoped({ startImmediately: true }), | |
| 381 | ); | |
| 382 | h.setExchangeStatus(503); | |
| 383 | h.sockets[0]!.close(); | |
| 384 | yield* Deferred.await(invalidated); | |
| 385 | expect(yield* service.read({ repository })).toBeNull(); | |
| 386 | expect(yield* service.readPullRequest({ repository, number: 123 })).toBeNull(); | |
| 387 | yield* TestClock.adjust("3 seconds"); | |
| 388 | expect(h.exchanges).toHaveLength(4); | |
| 389 | expect(changes).toBe(2); | |
| 390 | h.setExchangeStatus(200); | |
| 391 | const recovered = yield* nextChange(service); | |
| 392 | yield* TestClock.adjust("1 second"); | |
| 393 | yield* Fiber.join(recovered); | |
| 394 | expect(yield* service.readPullRequest({ repository, number: 123 })).toMatchObject({ | |
| 395 | headSha: "current-head", | |
| 396 | }); | |
| 397 | expect(changes).toBe(4); | |
| 398 | }).pipe(Effect.provide(h.layer)); | |
| 399 | }); | |
| 400 | ||
| 401 | it.effect("an account change immediately invalidates the old account's snapshot", () => { | |
| 402 | const h = harness(); | |
| 403 | return Effect.gen(function* () { | |
| 404 | const service = yield* MergebotQueue.MergebotQueue; | |
| 405 | yield* service.readPullRequest({ repository, number: 123 }); | |
| 406 | h.setToken("second-account-token"); | |
| 407 | expect(yield* service.readPullRequest({ repository, number: 123 })).toBeNull(); | |
| 408 | yield* TestClock.adjust("1 second"); | |
| 409 | expect(h.exchanges[1]?.authorization).toBe("Bearer second-account-token"); | |
| 410 | expect(h.requests[1]?.options).toEqual({ | |
| 411 | headers: { Authorization: "Bearer sealed-mergebot-session" }, | |
| 412 | }); | |
| 413 | }).pipe(Effect.provide(h.layer)); | |
| 414 | }); | |
| 415 | ||
| 416 | it.effect("channel errors and malformed frames invalidate previously fresh data", () => { | |
| 417 | const h = harness(); | |
| 418 | return Effect.gen(function* () { | |
| 419 | const service = yield* MergebotQueue.MergebotQueue; | |
| 420 | yield* service.readPullRequest({ repository, number: 123 }); | |
| 421 | let changed = yield* nextChange(service); | |
| 422 | h.sockets[0]!.snapshot({ | |
| 423 | type: "error", | |
| 424 | channelKey: detailSnapshot.channelKey, | |
| 425 | message: "Refresh failed", | |
| 426 | }); | |
| 427 | yield* Fiber.join(changed); | |
| 428 | expect(yield* service.readPullRequest({ repository, number: 123 })).toBeNull(); | |
| 429 | changed = yield* nextChange(service); | |
| 430 | h.sockets[0]!.snapshot({ malformed: true }); | |
| 431 | yield* Fiber.join(changed); | |
| 432 | expect(yield* service.read({ repository })).toBeNull(); | |
| 433 | }).pipe(Effect.provide(h.layer)); | |
| 434 | }); | |
| 435 | it.effect.each([401, 403, 302, 503])( | |
| 436 | "authentication status %s never opens a socket or reuses ready data", | |
| 437 | (status) => { | |
| 438 | const h = harness(); | |
| 439 | h.setExchangeStatus(status); | |
| 440 | return Effect.gen(function* () { | |
| 441 | const service = yield* MergebotQueue.MergebotQueue; | |
| 442 | expect(yield* service.read({ repository })).toBeNull(); | |
| 443 | expect(h.sockets).toHaveLength(0); | |
| 444 | expect(h.exchanges).toHaveLength(1); | |
| 445 | }).pipe(Effect.provide(h.layer)); | |
| 446 | }, | |
| 447 | ); | |
| 448 | ||
| 449 | it.effect("a missing verified snapshot has a bounded initial read", () => { | |
| 450 | const h = harness(); | |
| 451 | h.subscribeWith((socket, channel) => { | |
| 452 | if (channel.kind === "repo_overview") socket.snapshot(repoSnapshot); | |
| 453 | else socket.snapshot({ ...detailSnapshot, observedAt: null }); | |
| 454 | }); | |
| 455 | return Effect.gen(function* () { | |
| 456 | const service = yield* MergebotQueue.MergebotQueue; | |
| 457 | const read = yield* service | |
| 458 | .readPullRequest({ repository, number: 123 }) | |
| 459 | .pipe(Effect.forkScoped({ startImmediately: true })); | |
| 460 | yield* TestClock.adjust("10 seconds"); | |
| 461 | expect(yield* Fiber.join(read)).toBeNull(); | |
| 462 | }).pipe(Effect.provide(h.layer)); | |
| 463 | }); | |
| 464 | ||
| 465 | it.effect("branch discovery uses observed Mergebot metadata and verified detail only", () => { | |
| 466 | const h = harness(); | |
| 467 | return Effect.gen(function* () { | |
| 468 | const service = yield* MergebotQueue.MergebotQueue; | |
| 469 | expect(yield* service.findPullRequest({ repository, headBranch: "feature" })).toMatchObject({ | |
| 470 | number: 123, | |
| 471 | headSha: "current-head", | |
| 472 | }); | |
| 473 | expect(yield* service.findPullRequest({ repository, headBranch: "other" })).toBeNull(); | |
| 474 | expect(h.sockets[0]?.sent).toHaveLength(2); | |
| 475 | }).pipe(Effect.provide(h.layer)); | |
| 476 | }); | |
| 477 | ||
| 478 | it.effect( | |
| 479 | "a new overview head invalidates prior detail until its own checks are refreshed", | |
| 480 | () => { | |
| 481 | const h = harness(); | |
| 482 | return Effect.gen(function* () { | |
| 483 | const service = yield* MergebotQueue.MergebotQueue; | |
| 484 | yield* service.readPullRequest({ repository, number: 123 }); | |
| 485 | const changed = yield* nextChange(service); | |
| 486 | h.sockets[0]!.snapshot({ | |
| 487 | ...repoSnapshot, | |
| 488 | observedAt: "2026-10-14T10:03:00Z", | |
| 489 | payload: { | |
| 490 | ...repoSnapshot.payload, | |
| 491 | entities: { | |
| 492 | ...repoSnapshot.payload.entities, | |
| 493 | pullRequests: { [pr.id]: { ...pr, headSha: "next-head" } }, | |
| 494 | }, | |
| 495 | }, | |
| 496 | }); | |
| 497 | yield* Fiber.join(changed); | |
| 498 | const read = yield* service | |
| 499 | .readPullRequest({ repository, number: 123 }) | |
| 500 | .pipe(Effect.forkScoped({ startImmediately: true })); | |
| 501 | h.sockets[0]!.snapshot({ | |
| 502 | ...detailSnapshot, | |
| 503 | observedAt: "2026-10-14T10:03:00Z", | |
| 504 | payload: { | |
| 505 | ...detailSnapshot.payload, | |
| 506 | entities: { users: {}, pullRequests: { [pr.id]: { ...pr, headSha: "next-head" } } }, | |
| 507 | view: { ...detailSnapshot.payload.view, checks: [] }, | |
| 508 | }, | |
| 509 | }); | |
| 510 | expect(yield* Fiber.join(read)).toMatchObject({ headSha: "next-head", checks: [] }); | |
| 511 | }).pipe(Effect.provide(h.layer)); | |
| 512 | }, | |
| 513 | ); | |
| 514 | ||
| 515 | it.effect("an older observation cannot replace a newer head or rerun", () => { | |
| 516 | const h = harness(); | |
| 517 | return Effect.gen(function* () { | |
| 518 | const service = yield* MergebotQueue.MergebotQueue; | |
| 519 | yield* service.readPullRequest({ repository, number: 123 }); | |
| 520 | const changed = yield* nextChange(service); | |
| 521 | h.sockets[0]!.snapshot({ | |
| 522 | ...detailSnapshot, | |
| 523 | observedAt: "2026-10-14T10:03:00Z", | |
| 524 | payload: { | |
| 525 | ...detailSnapshot.payload, | |
| 526 | view: { ...detailSnapshot.payload.view, checks: [{ ...check, id: "check:2" }] }, | |
| 527 | }, | |
| 528 | }); | |
| 529 | yield* Fiber.join(changed); | |
| 530 | h.sockets[0]!.snapshot(detailSnapshot); | |
| 531 | expect(yield* service.readPullRequest({ repository, number: 123 })).toMatchObject({ | |
| 532 | checks: [{ id: "check:2" }], | |
| 533 | }); | |
| 534 | }).pipe(Effect.provide(h.layer)); | |
| 535 | }); | |
| 536 | it.effect( | |
| 537 | "queue subscribers receive initial state and live changes without a connection race", | |
| 538 | () => { | |
| 539 | const h = harness(); | |
| 540 | return Effect.gen(function* () { | |
| 541 | const service = yield* MergebotQueue.MergebotQueue; | |
| 542 | const initial = yield* Deferred.make<void>(); | |
| 543 | const values = yield* service.watchQueue({ repository }).pipe( | |
| 544 | Stream.tap(() => Deferred.succeed(initial, undefined)), | |
| 545 | Stream.take(2), | |
| 546 | Stream.runCollect, | |
| 547 | Effect.forkScoped({ startImmediately: true }), | |
| 548 | ); | |
| 549 | yield* Deferred.await(initial); | |
| 550 | h.sockets[0]!.snapshot({ | |
| 551 | ...repoSnapshot, | |
| 552 | payload: { | |
| 553 | ...repoSnapshot.payload, | |
| 554 | entities: { | |
| 555 | ...repoSnapshot.payload.entities, | |
| 556 | repos: { [repository]: { processing: false } }, | |
| 557 | }, | |
| 558 | }, | |
| 559 | }); | |
| 560 | expect((yield* Fiber.join(values)).map((queue) => queue?.processing)).toEqual([ | |
| 561 | true, | |
| 562 | false, | |
| 563 | ]); | |
| 564 | expect(h.requests).toHaveLength(1); | |
| 565 | }).pipe(Effect.provide(h.layer)); | |
| 566 | }, | |
| 567 | ); | |
| 568 | it.effect("later reads retry a failed channel without a reactive retry loop", () => { | |
| 569 | const h = harness(); | |
| 570 | return Effect.gen(function* () { | |
| 571 | const service = yield* MergebotQueue.MergebotQueue; | |
| 572 | yield* service.readPullRequest({ repository, number: 123 }); | |
| 573 | const changed = yield* nextChange(service); | |
| 574 | h.sockets[0]!.snapshot({ | |
| 575 | type: "error", | |
| 576 | channelKey: detailSnapshot.channelKey, | |
| 577 | message: "Upstream unavailable", | |
| 578 | }); | |
| 579 | yield* Fiber.join(changed); | |
| 580 | expect(yield* service.readPullRequest({ repository, number: 123 })).toBeNull(); | |
| 581 | expect(h.sockets[0]?.sent).toHaveLength(2); | |
| 582 | yield* TestClock.adjust("1 second"); | |
| 583 | expect(yield* service.readPullRequest({ repository, number: 123 })).toMatchObject({ | |
| 584 | headSha: "current-head", | |
| 585 | }); | |
| 586 | expect(h.sockets[0]?.sent).toHaveLength(3); | |
| 587 | expect(h.requests).toHaveLength(1); | |
| 588 | }).pipe(Effect.provide(h.layer)); | |
| 589 | }); | |
| 590 | it.effect( | |
| 591 | "an account change during an initial read cannot return the previous account's result", | |
| 592 | () => { | |
| 593 | const h = harness(); | |
| 594 | h.subscribeWith((socket, channel) => { | |
| 595 | if (channel.kind === "repo_overview") socket.snapshot(repoSnapshot); | |
| 596 | }); | |
| 597 | return Effect.gen(function* () { | |
| 598 | const service = yield* MergebotQueue.MergebotQueue; | |
| 599 | yield* service.read({ repository }); | |
| 600 | const read = yield* service | |
| 601 | .readPullRequest({ repository, number: 123 }) | |
| 602 | .pipe(Effect.forkScoped({ startImmediately: true })); | |
| 603 | h.setToken("second-account-token"); | |
| 604 | h.sockets[0]!.snapshot(detailSnapshot); | |
| 605 | expect(yield* Fiber.join(read)).toBeNull(); | |
| 606 | expect(h.sockets[0]?.readyState).toBe(3); | |
| 607 | }).pipe(Effect.provide(h.layer)); | |
| 608 | }, | |
| 609 | ); | |
| 610 | it.effect( | |
| 611 | "idle detail channels release their source subscription and reacquire on demand", | |
| 612 | () => { | |
| 613 | const h = harness(); | |
| 614 | return Effect.gen(function* () { | |
| 615 | const service = yield* MergebotQueue.MergebotQueue; | |
| 616 | yield* service.readPullRequest({ repository, number: 123 }); | |
| 617 | yield* TestClock.adjust("5 minutes"); | |
| 618 | expect(h.sockets[0]?.sent.at(-1)).toEqual({ | |
| 619 | type: "unsubscribe", | |
| 620 | channelKey: detailSnapshot.channelKey, | |
| 621 | }); | |
| 622 | expect(yield* service.readPullRequest({ repository, number: 123 })).toMatchObject({ | |
| 623 | headSha: "current-head", | |
| 624 | }); | |
| 625 | expect(h.sockets[0]?.sent.at(-1)).toEqual({ type: "subscribe", channel: detailChannel }); | |
| 626 | expect(h.requests).toHaveLength(1); | |
| 627 | }).pipe(Effect.provide(h.layer)); | |
| 628 | }, | |
| 629 | ); | |
| 630 | ||
| 631 | it.effect("active detail reads renew the lease without replacing the shared stream", () => { | |
| 632 | const h = harness(); | |
| 633 | return Effect.gen(function* () { | |
| 634 | const service = yield* MergebotQueue.MergebotQueue; | |
| 635 | yield* service.readPullRequest({ repository, number: 123 }); | |
| 636 | yield* TestClock.adjust("4 minutes"); | |
| 637 | yield* service.readPullRequest({ repository, number: 123 }); | |
| 638 | yield* TestClock.adjust("2 minutes"); | |
| 639 | expect(h.sockets[0]?.sent).toHaveLength(2); | |
| 640 | expect(h.requests).toHaveLength(1); | |
| 641 | }).pipe(Effect.provide(h.layer)); | |
| 642 | }); | |
| 643 | it.effect( | |
| 644 | "branch-only reads retry an unavailable overview channel without a UI subscriber", | |
| 645 | () => { | |
| 646 | const h = harness(); | |
| 647 | return Effect.gen(function* () { | |
| 648 | const service = yield* MergebotQueue.MergebotQueue; | |
| 649 | yield* service.findPullRequest({ repository, headBranch: "feature" }); | |
| 650 | const changed = yield* nextChange(service); | |
| 651 | h.sockets[0]!.snapshot({ | |
| 652 | type: "error", | |
| 653 | channelKey: repoSnapshot.channelKey, | |
| 654 | message: "Upstream unavailable", | |
| 655 | }); | |
| 656 | yield* Fiber.join(changed); | |
| 657 | expect(yield* service.findPullRequest({ repository, headBranch: "feature" })).toBeNull(); | |
| 658 | yield* TestClock.adjust("1 second"); | |
| 659 | expect(yield* service.findPullRequest({ repository, headBranch: "feature" })).toMatchObject( | |
| 660 | { number: 123 }, | |
| 661 | ); | |
| 662 | expect(h.sockets[0]?.sent.at(-1)).toEqual({ type: "subscribe", channel: repoChannel }); | |
| 663 | expect(h.requests).toHaveLength(1); | |
| 664 | }).pipe(Effect.provide(h.layer)); | |
| 665 | }, | |
| 666 | ); | |
| 667 | }); |
apps/server/src/mergebot/MergebotQueue.ts created+582| ... | ... | @@ -0,0 +1,582 @@ |
| 1 | import { | |
| 2 | MergebotConfiguration, | |
| 3 | MergebotPullRequestCheck, | |
| 4 | MergebotPullRequestDetails, | |
| 5 | MergebotPullRequestStatus, | |
| 6 | type MergebotPullRequestInput, | |
| 7 | type MergebotQueueInput, | |
| 8 | MergebotRepoQueue, | |
| 9 | PullRequestActor, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; | |
| 12 | import * as HostProcess from "@t3tools/shared/HostProcess"; | |
| 13 | import * as NodeSocket from "@effect/platform-node/NodeSocket"; | |
| 14 | import * as Struct from "effect/Struct"; | |
| 15 | import * as Clock from "effect/Clock"; | |
| 16 | import * as Context from "effect/Context"; | |
| 17 | import * as Effect from "effect/Effect"; | |
| 18 | import * as FileSystem from "effect/FileSystem"; | |
| 19 | import * as Layer from "effect/Layer"; | |
| 20 | import * as Path from "effect/Path"; | |
| 21 | import * as PubSub from "effect/PubSub"; | |
| 22 | import * as Redacted from "effect/Redacted"; | |
| 23 | import * as Schedule from "effect/Schedule"; | |
| 24 | import * as Schema from "effect/Schema"; | |
| 25 | import * as Stream from "effect/Stream"; | |
| 26 | import * as Socket from "effect/socket/Socket"; | |
| 27 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; | |
| 28 | ||
| 29 | const PullRequestRecord = Schema.Struct({ | |
| 30 | ...Struct.omit(MergebotPullRequestDetails.fields, [ | |
| 31 | "position", | |
| 32 | "checks", | |
| 33 | "author", | |
| 34 | "comments", | |
| 35 | "observedAt", | |
| 36 | ]), | |
| 37 | authorId: Schema.NullOr(Schema.String), | |
| 38 | id: Schema.String, | |
| 39 | repoId: Schema.String, | |
| 40 | }); | |
| 41 | const Comment = Schema.Struct({ | |
| 42 | id: Schema.String, | |
| 43 | kind: Schema.Literals(["comment", "review", "review_comment"]), | |
| 44 | authorId: Schema.NullOr(Schema.String), | |
| 45 | body: Schema.String, | |
| 46 | url: Schema.String, | |
| 47 | createdAt: Schema.String, | |
| 48 | updatedAt: Schema.String, | |
| 49 | state: Schema.NullOr(Schema.String), | |
| 50 | path: Schema.NullOr(Schema.String), | |
| 51 | }); | |
| 52 | const RepoChannel = Schema.Struct({ kind: Schema.Literal("repo_overview"), repoId: Schema.String }); | |
| 53 | const DetailChannel = Schema.Struct({ | |
| 54 | kind: Schema.Literal("pull_request_detail"), | |
| 55 | repoId: Schema.String, | |
| 56 | prNumber: Schema.Number, | |
| 57 | }); | |
| 58 | const RepoPayload = Schema.Struct({ | |
| 59 | entities: Schema.Struct({ | |
| 60 | repos: Schema.Record(Schema.String, Schema.Struct({ processing: Schema.Boolean })), | |
| 61 | pullRequests: Schema.Record(Schema.String, PullRequestRecord), | |
| 62 | }), | |
| 63 | view: Schema.Struct({ repoId: Schema.String, queuePrIds: Schema.Array(Schema.String) }), | |
| 64 | }); | |
| 65 | const Message = Schema.Union([ | |
| 66 | Schema.Struct({ | |
| 67 | type: Schema.Literal("snapshot"), | |
| 68 | channelKey: Schema.String, | |
| 69 | channel: RepoChannel, | |
| 70 | observedAt: Schema.NullOr(Schema.String), | |
| 71 | payload: RepoPayload, | |
| 72 | }), | |
| 73 | Schema.Struct({ | |
| 74 | type: Schema.Literal("snapshot"), | |
| 75 | channelKey: Schema.String, | |
| 76 | channel: DetailChannel, | |
| 77 | observedAt: Schema.NullOr(Schema.String), | |
| 78 | payload: Schema.Struct({ | |
| 79 | entities: Schema.Struct({ | |
| 80 | pullRequests: Schema.Record(Schema.String, PullRequestRecord), | |
| 81 | users: Schema.Record(Schema.String, PullRequestActor), | |
| 82 | }), | |
| 83 | view: Schema.Struct({ | |
| 84 | repoId: Schema.String, | |
| 85 | prId: Schema.String, | |
| 86 | isLoadingDetails: Schema.Boolean, | |
| 87 | checks: Schema.Array(MergebotPullRequestCheck), | |
| 88 | comments: Schema.Array(Comment), | |
| 89 | }), | |
| 90 | }), | |
| 91 | }), | |
| 92 | Schema.Struct({ | |
| 93 | type: Schema.Literal("error"), | |
| 94 | channelKey: Schema.optionalKey(Schema.String), | |
| 95 | message: Schema.String, | |
| 96 | }), | |
| 97 | ]); | |
| 98 | const decodeMessage = Schema.decodeEffect(Schema.fromJsonString(Message)); | |
| 99 | const encodeStatus = Schema.encodeSync(MergebotPullRequestStatus); | |
| 100 | const queueEquivalence = Schema.toEquivalence(Schema.NullOr(MergebotRepoQueue)); | |
| 101 | const decodeSession = Schema.decodeUnknownEffect( | |
| 102 | Schema.Struct({ | |
| 103 | token: Schema.String.check(Schema.isMinLength(1)), | |
| 104 | }), | |
| 105 | ); | |
| 106 | ||
| 107 | class MergebotAuthenticationError extends Schema.TaggedError<MergebotAuthenticationError>()( | |
| 108 | "MergebotAuthenticationError", | |
| 109 | { status: Schema.Number }, | |
| 110 | ) {} | |
| 111 | ||
| 112 | type Channel = typeof RepoChannel.Type | typeof DetailChannel.Type; | |
| 113 | type Change = { readonly repository: string; readonly number?: number }; | |
| 114 | ||
| 115 | export class MergebotQueue extends Context.Service< | |
| 116 | MergebotQueue, | |
| 117 | { | |
| 118 | readonly configuration: MergebotConfiguration | null; | |
| 119 | readonly read: (input: MergebotQueueInput) => Effect.Effect<MergebotRepoQueue | null>; | |
| 120 | readonly watchQueue: (input: MergebotQueueInput) => Stream.Stream<MergebotRepoQueue | null>; | |
| 121 | readonly readPullRequest: ( | |
| 122 | input: MergebotPullRequestInput, | |
| 123 | ) => Effect.Effect<MergebotPullRequestDetails | null>; | |
| 124 | readonly findPullRequest: (input: { | |
| 125 | readonly repository: string; | |
| 126 | readonly headBranch: string; | |
| 127 | }) => Effect.Effect<MergebotPullRequestDetails | null>; | |
| 128 | readonly streamChanges: Stream.Stream<Change>; | |
| 129 | } | |
| 130 | >()("t3/mergebot/MergebotQueue") {} | |
| 131 | ||
| 132 | const encodeSubscribe = Schema.encodeSync( | |
| 133 | Schema.fromJsonString( | |
| 134 | Schema.Struct({ | |
| 135 | type: Schema.Literal("subscribe"), | |
| 136 | channel: Schema.Union([RepoChannel, DetailChannel]), | |
| 137 | }), | |
| 138 | ), | |
| 139 | ); | |
| 140 | ||
| 141 | const encodeUnsubscribe = Schema.encodeSync( | |
| 142 | Schema.fromJsonString( | |
| 143 | Schema.Struct({ | |
| 144 | type: Schema.Literal("unsubscribe"), | |
| 145 | channelKey: Schema.String, | |
| 146 | }), | |
| 147 | ), | |
| 148 | ); | |
| 149 | ||
| 150 | const channelKey = (channel: Channel) => | |
| 151 | channel.kind === "repo_overview" | |
| 152 | ? `repo_overview:${channel.repoId}` | |
| 153 | : `pull_request_detail:${channel.repoId}#${channel.prNumber}`; | |
| 154 | ||
| 155 | const make = Effect.gen(function* () { | |
| 156 | const fs = yield* FileSystem.FileSystem; | |
| 157 | const path = yield* Path.Path; | |
| 158 | const home = yield* HostProcess.HomeDirectory; | |
| 159 | const configuration = yield* fs.readFileString(path.join(home, ".config/mergebot.json")).pipe( | |
| 160 | Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(MergebotConfiguration))), | |
| 161 | Effect.orElseSucceed(() => null), | |
| 162 | ); | |
| 163 | const http = HttpClient.withScope(yield* HttpClient.HttpClient); | |
| 164 | const credentials = yield* GitHubCredentials.GitHubCredentials; | |
| 165 | const constructSocket = yield* Socket.WebSocketConstructor; | |
| 166 | const scope = yield* Effect.scope; | |
| 167 | const changes = yield* PubSub.unbounded<Change>(); | |
| 168 | const repos = new Map<string, typeof RepoPayload.Type | null>(); | |
| 169 | const details = new Map<string, MergebotPullRequestDetails | null>(); | |
| 170 | const channels = new Map<string, Channel>(); | |
| 171 | const observations = new Map<string, number>(); | |
| 172 | const retryAt = new Map<string, number>(); | |
| 173 | const lastReadAt = new Map<string, number>(); | |
| 174 | let writer: Socket.Writer | undefined; | |
| 175 | let started = false; | |
| 176 | let account: string | undefined; | |
| 177 | let activeSocket: Socket.WebSocketLike | undefined; | |
| 178 | ||
| 179 | const repositoryFor = (repository: string) => | |
| 180 | configuration?.repositories.find((repo) => repo.toLowerCase() === repository.toLowerCase()); | |
| 181 | ||
| 182 | const invalidate = Effect.fnUntraced(function* (key?: string) { | |
| 183 | const invalidated: Change[] = []; | |
| 184 | for (const [currentKey, channel] of channels) { | |
| 185 | if (key !== undefined && currentKey !== key) continue; | |
| 186 | const previous = | |
| 187 | channel.kind === "repo_overview" ? repos.get(channel.repoId) : details.get(currentKey); | |
| 188 | const changed = previous !== null || observations.has(currentKey); | |
| 189 | observations.delete(currentKey); | |
| 190 | retryAt.delete(currentKey); | |
| 191 | if (channel.kind === "repo_overview") { | |
| 192 | repos.set(channel.repoId, null); | |
| 193 | } else details.set(currentKey, null); | |
| 194 | if (changed) | |
| 195 | invalidated.push({ | |
| 196 | repository: channel.repoId, | |
| 197 | ...(channel.kind === "pull_request_detail" ? { number: channel.prNumber } : {}), | |
| 198 | }); | |
| 199 | } | |
| 200 | yield* PubSub.publishAll(changes, invalidated); | |
| 201 | }); | |
| 202 | ||
| 203 | const queueFor = (repository: string): MergebotRepoQueue | null | undefined => { | |
| 204 | const payload = repos.get(repository); | |
| 205 | if (payload === null || payload === undefined) return payload; | |
| 206 | if (!configuration) return null; | |
| 207 | const url = `${configuration.origin}/${repository}`; | |
| 208 | return { | |
| 209 | repo: repository, | |
| 210 | url, | |
| 211 | status: "ready", | |
| 212 | processing: payload.entities.repos[repository]!.processing, | |
| 213 | pullRequests: Object.values(payload.entities.pullRequests).map((pr) => { | |
| 214 | const position = payload.view.queuePrIds.indexOf(pr.id); | |
| 215 | return encodeStatus({ | |
| 216 | ...pr, | |
| 217 | position: position < 0 ? null : position + 1, | |
| 218 | url: `${url}/pull/${pr.number}`, | |
| 219 | }); | |
| 220 | }), | |
| 221 | }; | |
| 222 | }; | |
| 223 | ||
| 224 | const consume = Effect.fnUntraced(function* (text: string) { | |
| 225 | const message = yield* decodeMessage(text); | |
| 226 | if (message.type === "error") { | |
| 227 | yield* invalidate(message.channelKey); | |
| 228 | const nextAttempt = (yield* Clock.currentTimeMillis) + 1_000; | |
| 229 | for (const key of channels.keys()) { | |
| 230 | if (message.channelKey === undefined || message.channelKey === key) | |
| 231 | retryAt.set(key, nextAttempt); | |
| 232 | } | |
| 233 | return; | |
| 234 | } | |
| 235 | const channel = channels.get(message.channelKey); | |
| 236 | if (!channel || channelKey(message.channel) !== message.channelKey) return; | |
| 237 | const repository = channel.repoId; | |
| 238 | if (!configuration) return; | |
| 239 | const url = `${configuration.origin}/${repository}`; | |
| 240 | const observedAt = message.observedAt === null ? NaN : Date.parse(message.observedAt); | |
| 241 | if (Number.isFinite(observedAt)) { | |
| 242 | const previous = observations.get(message.channelKey); | |
| 243 | if (previous !== undefined && observedAt < previous) return; | |
| 244 | observations.set(message.channelKey, observedAt); | |
| 245 | } | |
| 246 | const entities = message.payload.entities; | |
| 247 | if ("repos" in entities) { | |
| 248 | const payload = message.payload; | |
| 249 | const view = payload.view; | |
| 250 | if (!("queuePrIds" in view) || view.repoId !== repository) { | |
| 251 | yield* invalidate(message.channelKey); | |
| 252 | return; | |
| 253 | } | |
| 254 | if (!message.observedAt || !Number.isFinite(Date.parse(message.observedAt))) { | |
| 255 | repos.delete(repository); | |
| 256 | yield* PubSub.publish(changes, { repository }); | |
| 257 | return; | |
| 258 | } | |
| 259 | const repo = entities.repos[repository]; | |
| 260 | if (!repo) { | |
| 261 | yield* invalidate(message.channelKey); | |
| 262 | return; | |
| 263 | } | |
| 264 | const pullRequestRecords = Object.values(entities.pullRequests).filter( | |
| 265 | (pr) => pr.repoId === repository && pr.id === `${repository}#${pr.number}`, | |
| 266 | ); | |
| 267 | if (pullRequestRecords.length !== Object.keys(entities.pullRequests).length) { | |
| 268 | yield* invalidate(message.channelKey); | |
| 269 | return; | |
| 270 | } | |
| 271 | repos.set(repository, { | |
| 272 | entities: { repos: entities.repos, pullRequests: entities.pullRequests }, | |
| 273 | view, | |
| 274 | }); | |
| 275 | for (const record of pullRequestRecords) { | |
| 276 | const key = channelKey({ | |
| 277 | kind: "pull_request_detail", | |
| 278 | repoId: repository, | |
| 279 | prNumber: record.number, | |
| 280 | }); | |
| 281 | const detail = details.get(key); | |
| 282 | if ( | |
| 283 | detail && | |
| 284 | (detail.headSha !== record.headSha || detail.lifecycle !== record.lifecycle) && | |
| 285 | (observations.get(key) ?? -Infinity) <= observedAt | |
| 286 | ) { | |
| 287 | details.delete(key); | |
| 288 | } | |
| 289 | } | |
| 290 | yield* PubSub.publish(changes, { repository }); | |
| 291 | return; | |
| 292 | } | |
| 293 | if (message.channel.kind !== "pull_request_detail" || !("users" in entities)) return; | |
| 294 | const id = `${repository}#${message.channel.prNumber}`; | |
| 295 | const pr = message.payload.entities.pullRequests[id]; | |
| 296 | const view = message.payload.view; | |
| 297 | if (!("prId" in view) || view.repoId !== repository || view.prId !== id) { | |
| 298 | yield* invalidate(message.channelKey); | |
| 299 | return; | |
| 300 | } | |
| 301 | if ( | |
| 302 | !message.observedAt || | |
| 303 | !Number.isFinite(Date.parse(message.observedAt)) || | |
| 304 | view.isLoadingDetails | |
| 305 | ) { | |
| 306 | details.delete(message.channelKey); | |
| 307 | } else if ( | |
| 308 | pr?.id === id && | |
| 309 | pr.repoId === repository && | |
| 310 | pr.number === message.channel.prNumber | |
| 311 | ) { | |
| 312 | const known = repos.get(repository)?.entities.pullRequests[id]; | |
| 313 | if ( | |
| 314 | known && | |
| 315 | (known.headSha !== pr.headSha || known.lifecycle !== pr.lifecycle) && | |
| 316 | observedAt < (observations.get(`repo_overview:${repository}`) ?? -Infinity) | |
| 317 | ) { | |
| 318 | details.delete(message.channelKey); | |
| 319 | yield* PubSub.publish(changes, { repository, number: pr.number }); | |
| 320 | return; | |
| 321 | } | |
| 322 | const queue = queueFor(repository); | |
| 323 | const queued = queue?.pullRequests?.find((entry) => entry.number === pr.number); | |
| 324 | details.set(message.channelKey, { | |
| 325 | ...Struct.omit(pr, ["id", "repoId", "authorId"]), | |
| 326 | author: pr.authorId === null ? null : (entities.users[pr.authorId] ?? null), | |
| 327 | observedAt, | |
| 328 | comments: view.comments.map((comment) => ({ | |
| 329 | id: comment.id, | |
| 330 | kind: | |
| 331 | comment.kind === "comment" | |
| 332 | ? "issue-comment" | |
| 333 | : comment.kind === "review_comment" | |
| 334 | ? "review-comment" | |
| 335 | : "review", | |
| 336 | author: comment.authorId === null ? null : (entities.users[comment.authorId] ?? null), | |
| 337 | body: comment.body, | |
| 338 | createdAt: comment.createdAt, | |
| 339 | editedAt: comment.updatedAt, | |
| 340 | url: comment.url, | |
| 341 | path: comment.path, | |
| 342 | reviewState: comment.state, | |
| 343 | })), | |
| 344 | position: queued && queued.headSha === pr.headSha ? queued.position : null, | |
| 345 | url: `${url}/pull/${pr.number}`, | |
| 346 | checks: view.checks, | |
| 347 | }); | |
| 348 | } else { | |
| 349 | details.set(message.channelKey, null); | |
| 350 | } | |
| 351 | yield* PubSub.publish(changes, { repository, number: message.channel.prNumber }); | |
| 352 | }); | |
| 353 | ||
| 354 | const connect = Effect.gen(function* () { | |
| 355 | if (!configuration) return; | |
| 356 | const credential = yield* credentials.get("github.com"); | |
| 357 | if (account !== undefined && account !== credential.fingerprint) { | |
| 358 | yield* invalidate(); | |
| 359 | } | |
| 360 | account = credential.fingerprint; | |
| 361 | const response = yield* http | |
| 362 | .execute( | |
| 363 | HttpClientRequest.post(`${configuration.origin}/api/auth/github/token`).pipe( | |
| 364 | HttpClientRequest.setHeader( | |
| 365 | "Authorization", | |
| 366 | `Bearer ${Redacted.value(credential.token)}`, | |
| 367 | ), | |
| 368 | HttpClientRequest.setHeader("accept", "application/json"), | |
| 369 | ), | |
| 370 | ) | |
| 371 | .pipe( | |
| 372 | Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), | |
| 373 | Effect.timeout("10 seconds"), | |
| 374 | ); | |
| 375 | if (response.status !== 200) { | |
| 376 | if (response.status === 401) yield* credentials.invalidate("github.com"); | |
| 377 | return yield* new MergebotAuthenticationError({ status: response.status }); | |
| 378 | } | |
| 379 | const session = yield* response.json.pipe(Effect.flatMap(decodeSession)); | |
| 380 | const token = Redacted.make(session.token); | |
| 381 | const socket = yield* Socket.fromWebSocket( | |
| 382 | Effect.acquireRelease( | |
| 383 | Effect.sync(() => { | |
| 384 | activeSocket = constructSocket( | |
| 385 | `${configuration.origin.replace(/^https:/, "wss:")}/api/state/ws`, | |
| 386 | { headers: { Authorization: `Bearer ${Redacted.value(token)}` } }, | |
| 387 | ); | |
| 388 | return activeSocket; | |
| 389 | }), | |
| 390 | (socket) => Effect.sync(() => socket.close(1000)), | |
| 391 | ), | |
| 392 | { openTimeout: "10 seconds" }, | |
| 393 | ); | |
| 394 | return yield* Effect.scoped( | |
| 395 | Effect.gen(function* () { | |
| 396 | const { pull } = yield* socket.reader; | |
| 397 | const currentWriter = yield* socket.writer; | |
| 398 | writer = currentWriter; | |
| 399 | for (const channel of channels.values()) { | |
| 400 | yield* currentWriter.write(encodeSubscribe({ type: "subscribe", channel })); | |
| 401 | } | |
| 402 | while (true) { | |
| 403 | const frames = yield* pull; | |
| 404 | for (const frame of frames) { | |
| 405 | yield* consume(typeof frame === "string" ? frame : new TextDecoder().decode(frame)); | |
| 406 | } | |
| 407 | } | |
| 408 | }), | |
| 409 | ).pipe( | |
| 410 | Effect.ensuring( | |
| 411 | Effect.sync(() => { | |
| 412 | writer = undefined; | |
| 413 | activeSocket = undefined; | |
| 414 | }), | |
| 415 | ), | |
| 416 | ); | |
| 417 | }); | |
| 418 | ||
| 419 | const authorize = Effect.fnUntraced(function* () { | |
| 420 | if (activeSocket && activeSocket.readyState >= 2) { | |
| 421 | yield* invalidate(); | |
| 422 | return null; | |
| 423 | } | |
| 424 | const credential = yield* credentials.get("github.com").pipe(Effect.orElseSucceed(() => null)); | |
| 425 | if (credential && (account === undefined || credential.fingerprint === account)) | |
| 426 | return credential.fingerprint; | |
| 427 | yield* invalidate(); | |
| 428 | activeSocket?.close(1000); | |
| 429 | return null; | |
| 430 | }); | |
| 431 | ||
| 432 | const releaseIdleChannels = Effect.gen(function* () { | |
| 433 | const now = yield* Clock.currentTimeMillis; | |
| 434 | for (const [key, at] of lastReadAt) { | |
| 435 | if (now - at < 5 * 60_000) continue; | |
| 436 | lastReadAt.delete(key); | |
| 437 | channels.delete(key); | |
| 438 | details.delete(key); | |
| 439 | observations.delete(key); | |
| 440 | retryAt.delete(key); | |
| 441 | if (writer) | |
| 442 | yield* writer | |
| 443 | .write(encodeUnsubscribe({ type: "unsubscribe", channelKey: key })) | |
| 444 | .pipe(Effect.orElseSucceed(() => undefined)); | |
| 445 | } | |
| 446 | }); | |
| 447 | ||
| 448 | const start = Effect.fnUntraced(function* () { | |
| 449 | if (started || !configuration) return; | |
| 450 | started = true; | |
| 451 | for (const repository of configuration.repositories) { | |
| 452 | const channel = { kind: "repo_overview", repoId: repository } as const; | |
| 453 | channels.set(channelKey(channel), channel); | |
| 454 | } | |
| 455 | yield* Effect.forkIn( | |
| 456 | releaseIdleChannels.pipe(Effect.repeat(Schedule.spaced("1 minute"))), | |
| 457 | scope, | |
| 458 | ); | |
| 459 | yield* Effect.forkIn( | |
| 460 | connect.pipe( | |
| 461 | Effect.scoped, | |
| 462 | Effect.ensuring(invalidate()), | |
| 463 | Effect.retry({ schedule: Schedule.spaced("1 second") }), | |
| 464 | ), | |
| 465 | scope, | |
| 466 | ); | |
| 467 | }); | |
| 468 | ||
| 469 | const retryChannel = Effect.fnUntraced(function* (key: string) { | |
| 470 | const nextAttempt = retryAt.get(key); | |
| 471 | if (nextAttempt === undefined || !writer || (yield* Clock.currentTimeMillis) < nextAttempt) | |
| 472 | return; | |
| 473 | const channel = channels.get(key); | |
| 474 | if (!channel) return; | |
| 475 | retryAt.delete(key); | |
| 476 | if (channel.kind === "repo_overview") repos.delete(channel.repoId); | |
| 477 | else details.delete(key); | |
| 478 | yield* writer | |
| 479 | .write(encodeSubscribe({ type: "subscribe", channel })) | |
| 480 | .pipe(Effect.catch(() => Effect.sync(() => activeSocket?.close(1000)))); | |
| 481 | }); | |
| 482 | ||
| 483 | const waitFor = <A>(fingerprint: string, read: () => A | null | undefined) => | |
| 484 | Effect.gen(function* () { | |
| 485 | const value = yield* Effect.gen(function* () { | |
| 486 | const subscription = yield* PubSub.subscribe(changes); | |
| 487 | let value = read(); | |
| 488 | while (value === undefined) { | |
| 489 | yield* PubSub.take(subscription); | |
| 490 | value = read(); | |
| 491 | } | |
| 492 | return value; | |
| 493 | }).pipe( | |
| 494 | Effect.scoped, | |
| 495 | Effect.timeout("10 seconds"), | |
| 496 | Effect.orElseSucceed(() => null), | |
| 497 | ); | |
| 498 | return (yield* authorize()) === fingerprint ? value : null; | |
| 499 | }); | |
| 500 | ||
| 501 | const readPullRequest = Effect.fn("MergebotQueue.readPullRequest")(function* ( | |
| 502 | input: MergebotPullRequestInput, | |
| 503 | ) { | |
| 504 | const repository = repositoryFor(input.repository); | |
| 505 | if (!repository) return null; | |
| 506 | const fingerprint = yield* authorize(); | |
| 507 | if (fingerprint === null) return null; | |
| 508 | yield* start(); | |
| 509 | const channel = { | |
| 510 | kind: "pull_request_detail", | |
| 511 | repoId: repository, | |
| 512 | prNumber: input.number, | |
| 513 | } as const; | |
| 514 | const key = channelKey(channel); | |
| 515 | lastReadAt.set(key, yield* Clock.currentTimeMillis); | |
| 516 | if (!channels.has(key)) { | |
| 517 | channels.set(key, channel); | |
| 518 | if (writer) | |
| 519 | yield* writer | |
| 520 | .write(encodeSubscribe({ type: "subscribe", channel })) | |
| 521 | .pipe(Effect.orElseSucceed(() => undefined)); | |
| 522 | } | |
| 523 | yield* retryChannel(key); | |
| 524 | return yield* waitFor(fingerprint, () => details.get(key)); | |
| 525 | }); | |
| 526 | ||
| 527 | const read = Effect.fn("MergebotQueue.read")(function* (input: MergebotQueueInput) { | |
| 528 | const repository = repositoryFor(input.repository); | |
| 529 | if (!repository) return null; | |
| 530 | const fingerprint = yield* authorize(); | |
| 531 | if (fingerprint === null) return null; | |
| 532 | yield* start(); | |
| 533 | yield* retryChannel(channelKey({ kind: "repo_overview", repoId: repository })); | |
| 534 | return yield* waitFor(fingerprint, () => queueFor(repository)); | |
| 535 | }); | |
| 536 | ||
| 537 | return MergebotQueue.of({ | |
| 538 | configuration, | |
| 539 | read, | |
| 540 | watchQueue: (input) => | |
| 541 | Stream.unwrap( | |
| 542 | Effect.gen(function* () { | |
| 543 | const subscription = yield* PubSub.subscribe(changes); | |
| 544 | return Stream.concat( | |
| 545 | Stream.fromEffect(read(input)), | |
| 546 | Stream.fromSubscription(subscription).pipe( | |
| 547 | Stream.filter( | |
| 548 | (change) => change.repository.toLowerCase() === input.repository.toLowerCase(), | |
| 549 | ), | |
| 550 | Stream.mapEffect(() => read(input)), | |
| 551 | ), | |
| 552 | ); | |
| 553 | }), | |
| 554 | ).pipe(Stream.changesWith(queueEquivalence)), | |
| 555 | readPullRequest, | |
| 556 | findPullRequest: Effect.fn("MergebotQueue.findPullRequest")(function* (input) { | |
| 557 | const repository = repositoryFor(input.repository); | |
| 558 | if (!repository) return null; | |
| 559 | const fingerprint = yield* authorize(); | |
| 560 | if (fingerprint === null) return null; | |
| 561 | yield* start(); | |
| 562 | yield* retryChannel(channelKey({ kind: "repo_overview", repoId: repository })); | |
| 563 | const queue = yield* waitFor(fingerprint, () => queueFor(repository)); | |
| 564 | if (!queue || queue.status !== "ready") return null; | |
| 565 | const matches = Object.values(repos.get(repository)?.entities.pullRequests ?? {}) | |
| 566 | .filter((pr) => pr.headBranch === input.headBranch) | |
| 567 | .sort( | |
| 568 | (a, b) => | |
| 569 | Number(b.lifecycle === "open") - Number(a.lifecycle === "open") || | |
| 570 | b.updatedAt.localeCompare(a.updatedAt), | |
| 571 | ); | |
| 572 | const match = matches?.[0]; | |
| 573 | return match ? yield* readPullRequest({ repository, number: match.number }) : null; | |
| 574 | }), | |
| 575 | streamChanges: Stream.fromPubSub(changes), | |
| 576 | }); | |
| 577 | }); | |
| 578 | ||
| 579 | export const layerWithSocketConstructor = Layer.effect(MergebotQueue, make); | |
| 580 | export const layer = layerWithSocketConstructor.pipe( | |
| 581 | Layer.provide(NodeSocket.layerWebSocketConstructorWS), | |
| 582 | ); |
apps/server/src/mergebot/pullRequestRead.ts created+60| ... | ... | @@ -0,0 +1,60 @@ |
| 1 | import type { | |
| 2 | MergebotPullRequestDetails, | |
| 3 | PullRequestCheck, | |
| 4 | PullRequestMergeability, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | ||
| 7 | export function mergebotChecks( | |
| 8 | details: MergebotPullRequestDetails, | |
| 9 | ): ReadonlyArray<PullRequestCheck> { | |
| 10 | return details.checks.map((check) => { | |
| 11 | const conclusion = check.conclusion; | |
| 12 | const status = | |
| 13 | check.status !== "completed" | |
| 14 | ? "pending" | |
| 15 | : conclusion === "success" || conclusion === "neutral" || conclusion === "skipped" | |
| 16 | ? conclusion | |
| 17 | : conclusion === "cancelled" | |
| 18 | ? "cancelled" | |
| 19 | : conclusion === "action_required" | |
| 20 | ? "action-required" | |
| 21 | : conclusion === "failure" || | |
| 22 | conclusion === "timed_out" || | |
| 23 | conclusion === "startup_failure" | |
| 24 | ? "failure" | |
| 25 | : "pending"; | |
| 26 | return { | |
| 27 | name: check.workflowName === null ? check.name : `${check.workflowName} / ${check.name}`, | |
| 28 | status, | |
| 29 | description: null, | |
| 30 | url: check.url, | |
| 31 | ...(check.completedAt === null ? {} : { completedAt: check.completedAt }), | |
| 32 | }; | |
| 33 | }); | |
| 34 | } | |
| 35 | ||
| 36 | export function mergebotMergeability(details: MergebotPullRequestDetails): PullRequestMergeability { | |
| 37 | return details.hasConflicts === null | |
| 38 | ? "unknown" | |
| 39 | : details.hasConflicts | |
| 40 | ? "conflicting" | |
| 41 | : "mergeable"; | |
| 42 | } | |
| 43 | ||
| 44 | export function mergebotWatchFingerprint(details: MergebotPullRequestDetails) { | |
| 45 | return { | |
| 46 | status: [ | |
| 47 | details.headSha, | |
| 48 | details.lifecycle, | |
| 49 | details.draft, | |
| 50 | details.hasConflicts, | |
| 51 | details.updatedAt, | |
| 52 | ...details.checks.map((check) => | |
| 53 | [check.id, check.status, check.conclusion, check.completedAt].join("\u0000"), | |
| 54 | ), | |
| 55 | ].join("\u0001"), | |
| 56 | remarks: details.comments | |
| 57 | .map((comment) => [comment.id, comment.editedAt ?? comment.createdAt].join("\u0000")) | |
| 58 | .join("\u0001"), | |
| 59 | }; | |
| 60 | } |
apps/server/src/observability/RpcInstrumentation.ts+2| ... | ... | @@ -99,6 +99,8 @@ const RPC_AGGREGATES = { |
| 99 | 99 | [WS_METHODS.secretsAnswerRequest]: "secrets", |
| 100 | 100 | [WS_METHODS.cloudGetRelayClientStatus]: "cloud", |
| 101 | 101 | [WS_METHODS.cloudInstallRelayClient]: "cloud", |
| 102 | [WS_METHODS.mergebotQueue]: "mergebot", | |
| 103 | [WS_METHODS.mergebotSubscribe]: "mergebot", | |
| 102 | 104 | [WS_METHODS.pullRequestsList]: "pull-requests", |
| 103 | 105 | [WS_METHODS.pullRequestsListStats]: "pull-requests", |
| 104 | 106 | [WS_METHODS.pullRequestsSummary]: "pull-requests", |
apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts+35| ... | ... | @@ -33,6 +33,7 @@ import * as Ref from "effect/Ref"; |
| 33 | 33 | import { TestClock } from "effect/testing"; |
| 34 | 34 | |
| 35 | 35 | import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; |
| 36 | import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; | |
| 36 | 37 | import * as GitManager from "../git/GitManager.ts"; |
| 37 | 38 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; |
| 38 | 39 | import * as ServerActivation from "../serverActivation.ts"; |
| ... | ... | @@ -164,6 +165,7 @@ function makeSummary( |
| 164 | 165 | } |
| 165 | 166 | |
| 166 | 167 | interface HarnessOptions { |
| 168 | readonly mergebot?: MergebotQueue.MergebotQueue["Service"]["configuration"]; | |
| 167 | 169 | readonly invalidate?: PullRequestService.PullRequestService["Service"]["invalidate"]; |
| 168 | 170 | readonly snapshot: TestShellSnapshot; |
| 169 | 171 | readonly summary?: ( |
| ... | ... | @@ -218,6 +220,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: |
| 218 | 220 | }; |
| 219 | 221 | |
| 220 | 222 | const layerDependencies = Layer.mergeAll( |
| 223 | Layer.mock(MergebotQueue.MergebotQueue)({ configuration: options.mergebot ?? null }), | |
| 221 | 224 | Layer.mock(PullRequestService.PullRequestService)({ |
| 222 | 225 | summary, |
| 223 | 226 | stack, |
| ... | ... | @@ -387,6 +390,38 @@ function applySync( |
| 387 | 390 | } |
| 388 | 391 | |
| 389 | 392 | describe("PullRequestSyncReactor", () => { |
| 393 | it.effect("work summaries sync while unsupported stacks retain their existing links", () => | |
| 394 | Effect.scoped( | |
| 395 | Effect.gen(function* () { | |
| 396 | yield* TestClock.setTime(Date.parse(NOW)); | |
| 397 | const stack = { | |
| 398 | kind: "native" as const, | |
| 399 | id: "stack", | |
| 400 | number: 7, | |
| 401 | url: "https://github.com/owner/repository/stacks/7", | |
| 402 | base: "main", | |
| 403 | layers: [{ number: 7, headBranch: "feature", state: "open" as const }], | |
| 404 | }; | |
| 405 | const fixture = yield* makeHarness({ | |
| 406 | mergebot: { origin: "https://mergebot.example", repositories: ["owner/repository"] }, | |
| 407 | snapshot: makeSnapshot([ | |
| 408 | makeThread("work", { pullRequests: [makeLink(7, null, { stack })] }), | |
| 409 | ]), | |
| 410 | summary: (input) => | |
| 411 | Effect.succeed(makeSummary(input, { state: "merged", mergedAt: NOW })), | |
| 412 | stack: () => Effect.die("Work sync must not read unsupported GitHub stacks"), | |
| 413 | }); | |
| 414 | yield* Effect.gen(function* () { | |
| 415 | yield* startAndSweep(fixture); | |
| 416 | const commands = yield* Ref.get(fixture.syncCommands); | |
| 417 | assert.strictEqual(commands[0]?.snapshot?.state, "merged"); | |
| 418 | assert.deepStrictEqual(commands[0]?.stack, stack); | |
| 419 | assert.deepStrictEqual(yield* Ref.get(fixture.stackCalls), []); | |
| 420 | }).pipe(Effect.provide(fixture.layer)); | |
| 421 | }), | |
| 422 | ), | |
| 423 | ); | |
| 424 | ||
| 390 | 425 | it.effect("retries a failed stack read after the summary becomes terminal", () => |
| 391 | 426 | Effect.scoped( |
| 392 | 427 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/PullRequestSyncReactor.ts+16-9| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { isMergebotRepository } from "@t3tools/contracts"; | |
| 2 | import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; | |
| 1 | 3 | import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; |
| 2 | 4 | import { |
| 3 | 5 | CommandId, |
| ... | ... | @@ -156,6 +158,7 @@ export const make = Effect.gen(function* () { |
| 156 | 158 | const engine = yield* Orchestrator.OrchestratorV2; |
| 157 | 159 | const projections = yield* ProjectionStore.ProjectionStoreV2; |
| 158 | 160 | const pullRequests = yield* PullRequestService.PullRequestService; |
| 161 | const mergebot = yield* MergebotQueue.MergebotQueue; | |
| 159 | 162 | const git = yield* GitManager.GitManager; |
| 160 | 163 | const crypto = yield* Crypto.Crypto; |
| 161 | 164 | |
| ... | ... | @@ -288,16 +291,20 @@ export const make = Effect.gen(function* () { |
| 288 | 291 | if (generation !== undefined) yield* pullRequests.invalidate({ reference: ref }); |
| 289 | 292 | const summary = yield* pullRequests.summary(ref, { recoverTransientFailure: false }); |
| 290 | 293 | const fields = snapshotFieldsOf(summary); |
| 294 | const work = | |
| 295 | ref.host === "github.com" && | |
| 296 | isMergebotRepository(mergebot.configuration ?? undefined, ref.repository); | |
| 291 | 297 | const needsStack = |
| 292 | generation !== undefined || | |
| 293 | retryStacks.has(key) || | |
| 294 | entries.some( | |
| 295 | (entry) => | |
| 296 | entry.link.snapshot === null || | |
| 297 | !snapshotFieldsEqual(entry.link.snapshot, fields) || | |
| 298 | (summary.stack !== undefined && | |
| 299 | (entry.link.stack?.number ?? null) !== (summary.stack?.number ?? null)), | |
| 300 | ); | |
| 298 | !work && | |
| 299 | (generation !== undefined || | |
| 300 | retryStacks.has(key) || | |
| 301 | entries.some( | |
| 302 | (entry) => | |
| 303 | entry.link.snapshot === null || | |
| 304 | !snapshotFieldsEqual(entry.link.snapshot, fields) || | |
| 305 | (summary.stack !== undefined && | |
| 306 | (entry.link.stack?.number ?? null) !== (summary.stack?.number ?? null)), | |
| 307 | )); | |
| 301 | 308 | // A summary that says the pull request is in no stack, for links that hold none, already |
| 302 | 309 | // answers what the stack read would. |
| 303 | 310 | const knownUnstacked = |
apps/server/src/orchestration-v2/runtimeLayer.test.ts+194-159| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; |
| 2 | import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; | |
| 2 | 3 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 3 | 4 | import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; |
| 4 | 5 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| ... | ... | @@ -2505,12 +2506,18 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 2505 | 2506 | }); |
| 2506 | 2507 | const reactor = yield* PullRequestWatchReactor.make.pipe( |
| 2507 | 2508 | Effect.provide( |
| 2508 | Layer.mergeAll( | |
| 2509 | NodeServices.layer, | |
| 2510 | Layer.mock(PullRequestService.PullRequestService)({ | |
| 2511 | detail: () => Effect.die("host unreachable"), | |
| 2512 | activity: () => Effect.die("host unreachable"), | |
| 2509 | Layer.merge( | |
| 2510 | Layer.mock(MergebotQueue.MergebotQueue)({ | |
| 2511 | configuration: null, | |
| 2512 | streamChanges: Stream.empty, | |
| 2513 | 2513 | }), |
| 2514 | Layer.mergeAll( | |
| 2515 | NodeServices.layer, | |
| 2516 | Layer.mock(PullRequestService.PullRequestService)({ | |
| 2517 | detail: () => Effect.die("host unreachable"), | |
| 2518 | activity: () => Effect.die("host unreachable"), | |
| 2519 | }), | |
| 2520 | ), | |
| 2514 | 2521 | ), |
| 2515 | 2522 | ), |
| 2516 | 2523 | ); |
| ... | ... | @@ -2584,37 +2591,43 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 2584 | 2591 | let reads = 0; |
| 2585 | 2592 | const reactor = yield* PullRequestWatchReactor.make.pipe( |
| 2586 | 2593 | Effect.provide( |
| 2587 | Layer.mergeAll( | |
| 2588 | NodeServices.layer, | |
| 2589 | Layer.mock(PullRequestService.PullRequestService)({ | |
| 2590 | detail: () => | |
| 2591 | Effect.suspend(() => { | |
| 2592 | reads += 1; | |
| 2593 | return host === "rate-limited" | |
| 2594 | Layer.merge( | |
| 2595 | Layer.mock(MergebotQueue.MergebotQueue)({ | |
| 2596 | configuration: null, | |
| 2597 | streamChanges: Stream.empty, | |
| 2598 | }), | |
| 2599 | Layer.mergeAll( | |
| 2600 | NodeServices.layer, | |
| 2601 | Layer.mock(PullRequestService.PullRequestService)({ | |
| 2602 | detail: () => | |
| 2603 | Effect.suspend(() => { | |
| 2604 | reads += 1; | |
| 2605 | return host === "rate-limited" | |
| 2606 | ? Effect.fail(rateLimited) | |
| 2607 | : Effect.succeed({ | |
| 2608 | ...watchedPullRequestDetail({ | |
| 2609 | projectId, | |
| 2610 | number: 9, | |
| 2611 | at: "2026-10-02T12:00:00.000Z", | |
| 2612 | }), | |
| 2613 | state: host, | |
| 2614 | checks: [ | |
| 2615 | { name: "lint", status: "success", description: null, url: null }, | |
| 2616 | ], | |
| 2617 | }); | |
| 2618 | }), | |
| 2619 | activity: () => | |
| 2620 | host === "rate-limited" | |
| 2594 | 2621 | ? Effect.fail(rateLimited) |
| 2595 | 2622 | : Effect.succeed({ |
| 2596 | ...watchedPullRequestDetail({ | |
| 2597 | projectId, | |
| 2598 | number: 9, | |
| 2599 | at: "2026-10-02T12:00:00.000Z", | |
| 2600 | }), | |
| 2601 | state: host, | |
| 2602 | checks: [ | |
| 2603 | { name: "lint", status: "success", description: null, url: null }, | |
| 2604 | ], | |
| 2605 | }); | |
| 2606 | }), | |
| 2607 | activity: () => | |
| 2608 | host === "rate-limited" | |
| 2609 | ? Effect.fail(rateLimited) | |
| 2610 | : Effect.succeed({ | |
| 2611 | comments: [], | |
| 2612 | commentCount: 0, | |
| 2613 | commentsTruncated: false, | |
| 2614 | reviewThreads: [], | |
| 2615 | commits: [], | |
| 2616 | }), | |
| 2617 | }), | |
| 2623 | comments: [], | |
| 2624 | commentCount: 0, | |
| 2625 | commentsTruncated: false, | |
| 2626 | reviewThreads: [], | |
| 2627 | commits: [], | |
| 2628 | }), | |
| 2629 | }), | |
| 2630 | ), | |
| 2618 | 2631 | ), |
| 2619 | 2632 | ), |
| 2620 | 2633 | ); |
| ... | ... | @@ -2720,24 +2733,34 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 2720 | 2733 | }); |
| 2721 | 2734 | const reactor = yield* PullRequestWatchReactor.make.pipe( |
| 2722 | 2735 | Effect.provide( |
| 2723 | Layer.mergeAll( | |
| 2724 | NodeServices.layer, | |
| 2725 | Layer.mock(PullRequestService.PullRequestService)({ | |
| 2726 | detail: () => | |
| 2727 | Effect.sync(() => ({ | |
| 2728 | ...watchedPullRequestDetail({ projectId, number: key.number, at: "2026-10-02" }), | |
| 2729 | headSha, | |
| 2730 | checks: [], | |
| 2731 | })), | |
| 2732 | activity: () => | |
| 2733 | Effect.succeed({ | |
| 2734 | comments: [], | |
| 2735 | commentCount: 0, | |
| 2736 | commentsTruncated: false, | |
| 2737 | reviewThreads: [], | |
| 2738 | commits: [], | |
| 2739 | }), | |
| 2736 | Layer.merge( | |
| 2737 | Layer.mock(MergebotQueue.MergebotQueue)({ | |
| 2738 | configuration: null, | |
| 2739 | streamChanges: Stream.empty, | |
| 2740 | 2740 | }), |
| 2741 | Layer.mergeAll( | |
| 2742 | NodeServices.layer, | |
| 2743 | Layer.mock(PullRequestService.PullRequestService)({ | |
| 2744 | detail: () => | |
| 2745 | Effect.sync(() => ({ | |
| 2746 | ...watchedPullRequestDetail({ | |
| 2747 | projectId, | |
| 2748 | number: key.number, | |
| 2749 | at: "2026-10-02", | |
| 2750 | }), | |
| 2751 | headSha, | |
| 2752 | checks: [], | |
| 2753 | })), | |
| 2754 | activity: () => | |
| 2755 | Effect.succeed({ | |
| 2756 | comments: [], | |
| 2757 | commentCount: 0, | |
| 2758 | commentsTruncated: false, | |
| 2759 | reviewThreads: [], | |
| 2760 | commits: [], | |
| 2761 | }), | |
| 2762 | }), | |
| 2763 | ), | |
| 2741 | 2764 | ), |
| 2742 | 2765 | ), |
| 2743 | 2766 | ); |
| ... | ... | @@ -2822,61 +2845,67 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 2822 | 2845 | let activityReads = 0; |
| 2823 | 2846 | const reactor = yield* PullRequestWatchReactor.make.pipe( |
| 2824 | 2847 | Effect.provide( |
| 2825 | Layer.mergeAll( | |
| 2826 | NodeServices.layer, | |
| 2827 | Layer.mock(PullRequestService.PullRequestService)({ | |
| 2828 | watchFingerprint: () => | |
| 2829 | Effect.suspend(() => | |
| 2830 | fingerprint === "rate-limited" | |
| 2831 | ? Effect.fail( | |
| 2832 | new PullRequestOperationError({ | |
| 2833 | operation: "watchFingerprint", | |
| 2834 | detail: "paused", | |
| 2835 | cause: new PullRequestProviderError({ | |
| 2836 | provider: SourceControlProviderKind.make("github"), | |
| 2837 | operation: "getChangeRequestWatchFingerprint", | |
| 2838 | reason: "rate-limited", | |
| 2848 | Layer.merge( | |
| 2849 | Layer.mock(MergebotQueue.MergebotQueue)({ | |
| 2850 | configuration: null, | |
| 2851 | streamChanges: Stream.empty, | |
| 2852 | }), | |
| 2853 | Layer.mergeAll( | |
| 2854 | NodeServices.layer, | |
| 2855 | Layer.mock(PullRequestService.PullRequestService)({ | |
| 2856 | watchFingerprint: () => | |
| 2857 | Effect.suspend(() => | |
| 2858 | fingerprint === "rate-limited" | |
| 2859 | ? Effect.fail( | |
| 2860 | new PullRequestOperationError({ | |
| 2861 | operation: "watchFingerprint", | |
| 2839 | 2862 | detail: "paused", |
| 2863 | cause: new PullRequestProviderError({ | |
| 2864 | provider: SourceControlProviderKind.make("github"), | |
| 2865 | operation: "getChangeRequestWatchFingerprint", | |
| 2866 | reason: "rate-limited", | |
| 2867 | detail: "paused", | |
| 2868 | }), | |
| 2840 | 2869 | }), |
| 2841 | }), | |
| 2842 | ) | |
| 2843 | : Effect.succeed(fingerprint), | |
| 2844 | ), | |
| 2845 | invalidate: () => | |
| 2846 | Effect.sync(() => { | |
| 2847 | cachedChecks = null; | |
| 2848 | }), | |
| 2849 | detail: () => | |
| 2850 | Effect.sync(() => { | |
| 2851 | detailReads += 1; | |
| 2852 | const answer = cachedChecks ?? checks; | |
| 2853 | cachedChecks = null; | |
| 2854 | return { | |
| 2855 | ...watchedPullRequestDetail({ | |
| 2856 | projectId, | |
| 2857 | number: 11, | |
| 2858 | at: "2026-10-02T12:00:00.000Z", | |
| 2859 | }), | |
| 2860 | checks: Object.entries(answer).map(([name, status]) => ({ | |
| 2861 | name, | |
| 2862 | status, | |
| 2863 | description: null, | |
| 2864 | url: null, | |
| 2865 | })), | |
| 2866 | }; | |
| 2867 | }), | |
| 2868 | activity: () => | |
| 2869 | Effect.sync(() => { | |
| 2870 | activityReads += 1; | |
| 2871 | return { | |
| 2872 | comments, | |
| 2873 | commentCount: comments.length, | |
| 2874 | commentsTruncated: false, | |
| 2875 | reviewThreads: [], | |
| 2876 | commits: [], | |
| 2877 | }; | |
| 2878 | }), | |
| 2879 | }), | |
| 2870 | ) | |
| 2871 | : Effect.succeed(fingerprint), | |
| 2872 | ), | |
| 2873 | invalidate: () => | |
| 2874 | Effect.sync(() => { | |
| 2875 | cachedChecks = null; | |
| 2876 | }), | |
| 2877 | detail: () => | |
| 2878 | Effect.sync(() => { | |
| 2879 | detailReads += 1; | |
| 2880 | const answer = cachedChecks ?? checks; | |
| 2881 | cachedChecks = null; | |
| 2882 | return { | |
| 2883 | ...watchedPullRequestDetail({ | |
| 2884 | projectId, | |
| 2885 | number: 11, | |
| 2886 | at: "2026-10-02T12:00:00.000Z", | |
| 2887 | }), | |
| 2888 | checks: Object.entries(answer).map(([name, status]) => ({ | |
| 2889 | name, | |
| 2890 | status, | |
| 2891 | description: null, | |
| 2892 | url: null, | |
| 2893 | })), | |
| 2894 | }; | |
| 2895 | }), | |
| 2896 | activity: () => | |
| 2897 | Effect.sync(() => { | |
| 2898 | activityReads += 1; | |
| 2899 | return { | |
| 2900 | comments, | |
| 2901 | commentCount: comments.length, | |
| 2902 | commentsTruncated: false, | |
| 2903 | reviewThreads: [], | |
| 2904 | commits: [], | |
| 2905 | }; | |
| 2906 | }), | |
| 2907 | }), | |
| 2908 | ), | |
| 2880 | 2909 | ), |
| 2881 | 2910 | ), |
| 2882 | 2911 | ); |
| ... | ... | @@ -3031,62 +3060,68 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 3031 | 3060 | let pagesRead = 0; |
| 3032 | 3061 | const reactor = yield* PullRequestWatchReactor.make.pipe( |
| 3033 | 3062 | Effect.provide( |
| 3034 | Layer.mergeAll( | |
| 3035 | NodeServices.layer, | |
| 3036 | Layer.mock(PullRequestService.PullRequestService)({ | |
| 3037 | detail: () => Effect.succeed(detail), | |
| 3038 | activity: () => | |
| 3039 | Effect.succeed({ | |
| 3040 | comments: | |
| 3041 | mode === "single page" | |
| 3042 | ? [remark] | |
| 3043 | : [...firstTen, { ...remark, kind: "issue-comment" }], | |
| 3044 | commentCount: mode === "single page" ? 1 : 13, | |
| 3045 | commentsTruncated: mode !== "single page", | |
| 3046 | reviewThreadsTruncated: mode === "thread list truncated" && !recovering, | |
| 3047 | reviewThreads: | |
| 3048 | mode === "single page" | |
| 3049 | ? [] | |
| 3050 | : [ | |
| 3051 | { | |
| 3052 | id: "review-thread", | |
| 3053 | path: "src/index.ts", | |
| 3054 | line: 1, | |
| 3055 | side: "right", | |
| 3056 | isResolved: false, | |
| 3057 | isOutdated: false, | |
| 3058 | comments: firstTen, | |
| 3059 | commentCount: 12, | |
| 3060 | nextCommentsCursor: "after-10", | |
| 3061 | }, | |
| 3062 | ], | |
| 3063 | commits: [], | |
| 3064 | }), | |
| 3065 | threadComments: (input) => { | |
| 3066 | pagesRead += 1; | |
| 3067 | assert.equal(input.threadId, "review-thread"); | |
| 3068 | if (input.cursor === "after-10") { | |
| 3069 | return Effect.succeed({ comments: [eleventh], nextCursor: "after-11" }); | |
| 3070 | } | |
| 3071 | assert.equal(input.cursor, "after-11"); | |
| 3072 | if (!recovering && mode === "page failure") { | |
| 3073 | return Effect.fail( | |
| 3074 | new PullRequestOperationError({ | |
| 3075 | operation: "threadComments", | |
| 3076 | detail: "Page unavailable", | |
| 3077 | }), | |
| 3078 | ); | |
| 3079 | } | |
| 3080 | if (!recovering && mode === "repeated cursor") { | |
| 3081 | return Effect.succeed({ comments: [eleventh], nextCursor: "after-11" }); | |
| 3082 | } | |
| 3083 | if (!recovering && mode === "missing comments") { | |
| 3084 | return Effect.succeed({ comments: [], nextCursor: null }); | |
| 3085 | } | |
| 3086 | // Overlapping pages must not report the same reply twice. | |
| 3087 | return Effect.succeed({ comments: [eleventh, twelfth], nextCursor: null }); | |
| 3088 | }, | |
| 3063 | Layer.merge( | |
| 3064 | Layer.mock(MergebotQueue.MergebotQueue)({ | |
| 3065 | configuration: null, | |
| 3066 | streamChanges: Stream.empty, | |
| 3089 | 3067 | }), |
| 3068 | Layer.mergeAll( | |
| 3069 | NodeServices.layer, | |
| 3070 | Layer.mock(PullRequestService.PullRequestService)({ | |
| 3071 | detail: () => Effect.succeed(detail), | |
| 3072 | activity: () => | |
| 3073 | Effect.succeed({ | |
| 3074 | comments: | |
| 3075 | mode === "single page" | |
| 3076 | ? [remark] | |
| 3077 | : [...firstTen, { ...remark, kind: "issue-comment" }], | |
| 3078 | commentCount: mode === "single page" ? 1 : 13, | |
| 3079 | commentsTruncated: mode !== "single page", | |
| 3080 | reviewThreadsTruncated: mode === "thread list truncated" && !recovering, | |
| 3081 | reviewThreads: | |
| 3082 | mode === "single page" | |
| 3083 | ? [] | |
| 3084 | : [ | |
| 3085 | { | |
| 3086 | id: "review-thread", | |
| 3087 | path: "src/index.ts", | |
| 3088 | line: 1, | |
| 3089 | side: "right", | |
| 3090 | isResolved: false, | |
| 3091 | isOutdated: false, | |
| 3092 | comments: firstTen, | |
| 3093 | commentCount: 12, | |
| 3094 | nextCommentsCursor: "after-10", | |
| 3095 | }, | |
| 3096 | ], | |
| 3097 | commits: [], | |
| 3098 | }), | |
| 3099 | threadComments: (input) => { | |
| 3100 | pagesRead += 1; | |
| 3101 | assert.equal(input.threadId, "review-thread"); | |
| 3102 | if (input.cursor === "after-10") { | |
| 3103 | return Effect.succeed({ comments: [eleventh], nextCursor: "after-11" }); | |
| 3104 | } | |
| 3105 | assert.equal(input.cursor, "after-11"); | |
| 3106 | if (!recovering && mode === "page failure") { | |
| 3107 | return Effect.fail( | |
| 3108 | new PullRequestOperationError({ | |
| 3109 | operation: "threadComments", | |
| 3110 | detail: "Page unavailable", | |
| 3111 | }), | |
| 3112 | ); | |
| 3113 | } | |
| 3114 | if (!recovering && mode === "repeated cursor") { | |
| 3115 | return Effect.succeed({ comments: [eleventh], nextCursor: "after-11" }); | |
| 3116 | } | |
| 3117 | if (!recovering && mode === "missing comments") { | |
| 3118 | return Effect.succeed({ comments: [], nextCursor: null }); | |
| 3119 | } | |
| 3120 | // Overlapping pages must not report the same reply twice. | |
| 3121 | return Effect.succeed({ comments: [eleventh, twelfth], nextCursor: null }); | |
| 3122 | }, | |
| 3123 | }), | |
| 3124 | ), | |
| 3090 | 3125 | ), |
| 3091 | 3126 | ), |
| 3092 | 3127 | ); |
apps/server/src/pullRequest/PullRequestService.test.ts+101| ... | ... | @@ -28,6 +28,7 @@ import type { ServerSettings } from "@t3tools/contracts"; |
| 28 | 28 | import * as ServerSettingsService from "../serverSettings.ts"; |
| 29 | 29 | import { PullRequestOperationError } from "@t3tools/contracts"; |
| 30 | 30 | |
| 31 | import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; | |
| 31 | 32 | import * as ProjectService from "../project/ProjectService.ts"; |
| 32 | 33 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 33 | 34 | import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; |
| ... | ... | @@ -534,6 +535,7 @@ function fakeProvider( |
| 534 | 535 | } |
| 535 | 536 | |
| 536 | 537 | function makeService(input: { |
| 538 | readonly mergebot?: MergebotQueue.MergebotQueue["Service"]; | |
| 537 | 539 | readonly settings?: ServerSettings; |
| 538 | 540 | readonly projects: ReadonlyArray<OrchestrationProjectShell>; |
| 539 | 541 | readonly providers: ReadonlyArray<PullRequestProviderApi>; |
| ... | ... | @@ -545,6 +547,12 @@ function makeService(input: { |
| 545 | 547 | return Effect.flatMap( |
| 546 | 548 | Layer.build( |
| 547 | 549 | Layer.mergeAll( |
| 550 | input.mergebot | |
| 551 | ? Layer.succeed(MergebotQueue.MergebotQueue, input.mergebot) | |
| 552 | : Layer.mock(MergebotQueue.MergebotQueue)({ | |
| 553 | configuration: null, | |
| 554 | streamChanges: Stream.empty, | |
| 555 | }), | |
| 548 | 556 | Layer.mock(ServerSettingsService.ServerSettingsService)({ |
| 549 | 557 | getSettings: Effect.succeed(input.settings ?? DEFAULT_SERVER_SETTINGS), |
| 550 | 558 | }), |
| ... | ... | @@ -7903,3 +7911,96 @@ it.effect.each([ |
| 7903 | 7911 | assert.deepStrictEqual(calls, [expected]); |
| 7904 | 7912 | }), |
| 7905 | 7913 | ); |
| 7914 | ||
| 7915 | it.effect( | |
| 7916 | "configured work PR reads use live Mergebot, and unsupported routes never query GitHub", | |
| 7917 | () => | |
| 7918 | Effect.gen(function* () { | |
| 7919 | let value: import("@t3tools/contracts").MergebotPullRequestDetails | null = { | |
| 7920 | number: 42, | |
| 7921 | headSha: "head", | |
| 7922 | lifecycle: "open", | |
| 7923 | queueStatus: "waiting_ci", | |
| 7924 | position: 1, | |
| 7925 | mergeEtaAt: null, | |
| 7926 | title: "Ship change", | |
| 7927 | body: null, | |
| 7928 | url: "https://mergebot.example/work/app/pull/42", | |
| 7929 | createdAt: "2026-10-14T10:00:00Z", | |
| 7930 | updatedAt: "2026-10-14T10:01:00Z", | |
| 7931 | author: { login: "clover", name: null, avatarUrl: null }, | |
| 7932 | additions: null, | |
| 7933 | deletions: null, | |
| 7934 | changedFilesCount: null, | |
| 7935 | labels: [], | |
| 7936 | comments: [], | |
| 7937 | observedAt: 1791972060000, | |
| 7938 | draft: false, | |
| 7939 | baseBranch: "main", | |
| 7940 | headBranch: "feature", | |
| 7941 | ciStatus: "pending", | |
| 7942 | hasConflicts: null, | |
| 7943 | checks: [], | |
| 7944 | }; | |
| 7945 | const service = yield* makeService({ | |
| 7946 | projects: [ | |
| 7947 | project({ id: "work", title: "Work", workspaceRoot: "/work", repository: "work/app" }), | |
| 7948 | ], | |
| 7949 | providers: [ | |
| 7950 | fakeProvider(SourceControlProviderKind.make("github"), { | |
| 7951 | getViewer: () => Effect.die("Work reads must not query GitHub"), | |
| 7952 | getChangeRequestSummary: () => Effect.die("Work reads must not query GitHub"), | |
| 7953 | getChangeRequestPreview: () => Effect.die("Work reads must not query GitHub"), | |
| 7954 | getChangeRequestChecks: () => Effect.die("Work reads must not query GitHub"), | |
| 7955 | getChangeRequestWatchFingerprint: () => Effect.die("Work reads must not query GitHub"), | |
| 7956 | listChangeRequests: () => Effect.die("Work reads must not query GitHub"), | |
| 7957 | }), | |
| 7958 | ], | |
| 7959 | mergebot: { | |
| 7960 | configuration: { origin: "https://mergebot.example", repositories: ["work/app"] }, | |
| 7961 | read: () => Effect.succeed(null), | |
| 7962 | watchQueue: () => Stream.empty, | |
| 7963 | readPullRequest: () => Effect.sync(() => value), | |
| 7964 | findPullRequest: () => Effect.succeed(null), | |
| 7965 | streamChanges: Stream.empty, | |
| 7966 | }, | |
| 7967 | }); | |
| 7968 | const reference = { projectId: "work" as ProjectId, repository: "work/app", number: 42 }; | |
| 7969 | const first = yield* service.summary(reference); | |
| 7970 | assert.strictEqual(first.title, "Ship change"); | |
| 7971 | assert.isUndefined(first.additions); | |
| 7972 | assert.isUndefined(first.mergedAt); | |
| 7973 | assert.strictEqual((yield* service.preview(reference)).createdAt, "2026-10-14T10:00:00Z"); | |
| 7974 | assert.deepStrictEqual((yield* service.listStats({ refs: [reference] })).stats, []); | |
| 7975 | assert.deepStrictEqual( | |
| 7976 | (yield* service.list({ state: "open" })).errors.map((error) => error.projectId), | |
| 7977 | [reference.projectId], | |
| 7978 | ); | |
| 7979 | assert.include((yield* Effect.flip(service.detail(reference))).message, "Mergebot"); | |
| 7980 | assert.include((yield* Effect.flip(service.activity(reference))).message, "Mergebot"); | |
| 7981 | assert.include((yield* Effect.flip(service.stack(reference))).message, "Mergebot"); | |
| 7982 | const fingerprint = yield* service.watchFingerprint(reference); | |
| 7983 | value = { | |
| 7984 | ...value!, | |
| 7985 | title: "Changed live", | |
| 7986 | ciStatus: "failed", | |
| 7987 | checks: [ | |
| 7988 | { | |
| 7989 | id: "check:2", | |
| 7990 | name: "Tests", | |
| 7991 | workflowName: "CI", | |
| 7992 | status: "completed", | |
| 7993 | conclusion: "failure", | |
| 7994 | url: null, | |
| 7995 | startedAt: null, | |
| 7996 | completedAt: "2026-10-14T10:02:00Z", | |
| 7997 | }, | |
| 7998 | ], | |
| 7999 | }; | |
| 8000 | assert.strictEqual((yield* service.summary(reference)).title, "Changed live"); | |
| 8001 | assert.strictEqual((yield* service.checks(reference))?.checks[0]?.status, "failure"); | |
| 8002 | assert.notDeepEqual(yield* service.watchFingerprint(reference), fingerprint); | |
| 8003 | value = null; | |
| 8004 | assert.include((yield* Effect.flip(service.summary(reference))).message, "unavailable"); | |
| 8005 | }).pipe(Effect.scoped), | |
| 8006 | ); |
apps/server/src/pullRequest/PullRequestService.ts+201-36| ... | ... | @@ -21,6 +21,7 @@ import * as Stream from "effect/Stream"; |
| 21 | 21 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 22 | 22 | import { |
| 23 | 23 | PullRequestOperationError, |
| 24 | isMergebotRepository, | |
| 24 | 25 | PullRequestUnavailableError, |
| 25 | 26 | pullRequestHostOf, |
| 26 | 27 | pullRequestProviderRequirement, |
| ... | ... | @@ -78,6 +79,12 @@ import { |
| 78 | 79 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 79 | 80 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; |
| 80 | 81 | |
| 82 | import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; | |
| 83 | import { | |
| 84 | mergebotChecks, | |
| 85 | mergebotMergeability, | |
| 86 | mergebotWatchFingerprint, | |
| 87 | } from "../mergebot/pullRequestRead.ts"; | |
| 81 | 88 | import * as ProjectService from "../project/ProjectService.ts"; |
| 82 | 89 | import * as ServerSettings from "../serverSettings.ts"; |
| 83 | 90 | import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; |
| ... | ... | @@ -711,6 +718,7 @@ export const make = Effect.gen(function* () { |
| 711 | 718 | const stateChanges = yield* PubSub.sliding<ThreadPullRequestKey>(64); |
| 712 | 719 | const pullRequestRefreshes = yield* SubscriptionRef.make(0); |
| 713 | 720 | const registry = yield* PullRequestProviderRegistry.PullRequestProviderRegistry; |
| 721 | const mergebot = yield* MergebotQueue.MergebotQueue; | |
| 714 | 722 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 715 | 723 | const projects = yield* ProjectService.ProjectService; |
| 716 | 724 | const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; |
| ... | ... | @@ -1002,6 +1010,11 @@ export const make = Effect.gen(function* () { |
| 1002 | 1010 | }), |
| 1003 | 1011 | ); |
| 1004 | 1012 | |
| 1013 | const isWorkProject = (project: SupportedProject) => | |
| 1014 | project.api.kind === "github" && | |
| 1015 | project.host === "github.com" && | |
| 1016 | isMergebotRepository(mergebot.configuration ?? undefined, project.repository); | |
| 1017 | ||
| 1005 | 1018 | const canonicalRef = Effect.fn("PullRequestService.canonicalRef")(function* < |
| 1006 | 1019 | I extends PullRequestRef, |
| 1007 | 1020 | >(input: I) { |
| ... | ... | @@ -1267,11 +1280,14 @@ export const make = Effect.gen(function* () { |
| 1267 | 1280 | // issued, so one that does not read as one means the page is sending something it made up, |
| 1268 | 1281 | // and reading part of the listing under that assumption would quietly lose rows. |
| 1269 | 1282 | const continuation = yield* decodeCursors(input.cursors); |
| 1270 | const { | |
| 1271 | supported: projects, | |
| 1272 | unimplemented, | |
| 1273 | viewerRoots, | |
| 1274 | } = yield* listWorkspaceProjects(input); | |
| 1283 | const { supported, unimplemented, viewerRoots } = yield* listWorkspaceProjects(input); | |
| 1284 | const workProjects = supported.filter(isWorkProject); | |
| 1285 | const projects = supported.filter((project) => !isWorkProject(project)); | |
| 1286 | const workErrors = workProjects.map(({ project }) => ({ | |
| 1287 | projectId: project.id, | |
| 1288 | projectTitle: project.title, | |
| 1289 | message: "Open this repository in Mergebot to browse its pull requests.", | |
| 1290 | })); | |
| 1275 | 1291 | const projectCounts = new Map<string, number>(); |
| 1276 | 1292 | for (const { host } of projects) { |
| 1277 | 1293 | projectCounts.set(host, (projectCounts.get(host) ?? 0) + 1); |
| ... | ... | @@ -1351,7 +1367,7 @@ export const make = Effect.gen(function* () { |
| 1351 | 1367 | viewers: viewers as PullRequestListResult["viewers"], |
| 1352 | 1368 | providers, |
| 1353 | 1369 | entries: [], |
| 1354 | errors: [], | |
| 1370 | errors: workErrors, | |
| 1355 | 1371 | truncated: false, |
| 1356 | 1372 | nextCursors: {}, |
| 1357 | 1373 | }; |
| ... | ... | @@ -1585,7 +1601,7 @@ export const make = Effect.gen(function* () { |
| 1585 | 1601 | entries: batches |
| 1586 | 1602 | .flatMap((batch) => batch.entries) |
| 1587 | 1603 | .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)), |
| 1588 | errors: [...unreadable, ...batches.flatMap((batch) => batch.errors)], | |
| 1604 | errors: [...workErrors, ...unreadable, ...batches.flatMap((batch) => batch.errors)], | |
| 1589 | 1605 | truncated: batches.some((batch) => batch.truncated), |
| 1590 | 1606 | nextCursors, |
| 1591 | 1607 | }; |
| ... | ... | @@ -3575,17 +3591,139 @@ export const make = Effect.gen(function* () { |
| 3575 | 3591 | ); |
| 3576 | 3592 | }); |
| 3577 | 3593 | |
| 3594 | const withWorkRead = | |
| 3595 | <I extends PullRequestRef, Args extends ReadonlyArray<unknown>, A>( | |
| 3596 | normal: (input: I, ...args: Args) => Effect.Effect<A, PullRequestError>, | |
| 3597 | work: (input: I) => Effect.Effect<A, PullRequestError>, | |
| 3598 | ) => | |
| 3599 | (input: I, ...args: Args) => | |
| 3600 | Effect.gen(function* () { | |
| 3601 | if (!isMergebotRepository(mergebot.configuration ?? undefined, input.repository)) | |
| 3602 | return yield* normal(input, ...args); | |
| 3603 | const project = yield* requireProject(input); | |
| 3604 | if (!isWorkProject(project)) return yield* normal(input, ...args); | |
| 3605 | return yield* work({ | |
| 3606 | ...input, | |
| 3607 | projectId: project.project.id, | |
| 3608 | repository: project.repository, | |
| 3609 | host: project.host, | |
| 3610 | }); | |
| 3611 | }); | |
| 3612 | ||
| 3613 | const workDetails = Effect.fn("PullRequestService.mergebotDetails")(function* ( | |
| 3614 | input: PullRequestRef, | |
| 3615 | ) { | |
| 3616 | const value = yield* mergebot.readPullRequest(input); | |
| 3617 | if (value === null) | |
| 3618 | return yield* new PullRequestOperationError({ | |
| 3619 | operation: "mergebot", | |
| 3620 | detail: "Mergebot is unavailable. Try again after it reconnects.", | |
| 3621 | }); | |
| 3622 | return value; | |
| 3623 | }); | |
| 3624 | ||
| 3625 | const unsupportedWorkRead = <I extends PullRequestRef, Args extends ReadonlyArray<unknown>, A>( | |
| 3626 | operation: string, | |
| 3627 | normal: (input: I, ...args: Args) => Effect.Effect<A, PullRequestError>, | |
| 3628 | ) => | |
| 3629 | withWorkRead( | |
| 3630 | normal, | |
| 3631 | () => | |
| 3632 | new PullRequestOperationError({ | |
| 3633 | operation, | |
| 3634 | detail: "Open this pull request in Mergebot to continue.", | |
| 3635 | }), | |
| 3636 | ); | |
| 3637 | ||
| 3638 | const workSummary = Effect.fn("PullRequestService.mergebotSummary")(function* ( | |
| 3639 | input: PullRequestRef, | |
| 3640 | ): Effect.fn.Return<PullRequestSummary, PullRequestError> { | |
| 3641 | const value = yield* workDetails(input); | |
| 3642 | return { | |
| 3643 | projectId: input.projectId, | |
| 3644 | repository: input.repository, | |
| 3645 | number: value.number, | |
| 3646 | provider: SourceControlProviderKind.make("github"), | |
| 3647 | title: value.title, | |
| 3648 | url: value.url, | |
| 3649 | state: value.lifecycle, | |
| 3650 | headBranch: value.headBranch, | |
| 3651 | baseBranch: value.baseBranch, | |
| 3652 | isDraft: value.draft, | |
| 3653 | updatedAt: value.updatedAt, | |
| 3654 | observedAt: value.observedAt, | |
| 3655 | author: value.author, | |
| 3656 | ...(value.mergedAt === undefined ? {} : { mergedAt: value.mergedAt }), | |
| 3657 | ...(value.closedAt === undefined ? {} : { closedAt: value.closedAt }), | |
| 3658 | ...(value.additions === null ? {} : { additions: value.additions }), | |
| 3659 | ...(value.deletions === null ? {} : { deletions: value.deletions }), | |
| 3660 | ...(value.changedFilesCount === null ? {} : { changedFiles: value.changedFilesCount }), | |
| 3661 | mergeability: mergebotMergeability(value), | |
| 3662 | ...(value.ciStatus === "passed" | |
| 3663 | ? { checksState: "passing" as const } | |
| 3664 | : value.ciStatus === "failed" | |
| 3665 | ? { checksState: "failing" as const } | |
| 3666 | : value.ciStatus === "pending" | |
| 3667 | ? { checksState: "pending" as const } | |
| 3668 | : {}), | |
| 3669 | }; | |
| 3670 | }); | |
| 3671 | ||
| 3672 | yield* mergebot.streamChanges.pipe( | |
| 3673 | Stream.runForEach((change) => | |
| 3674 | Effect.gen(function* () { | |
| 3675 | const numbers = | |
| 3676 | change.number === undefined | |
| 3677 | ? ((yield* mergebot.read(change))?.pullRequests?.map((pr) => pr.number) ?? []) | |
| 3678 | : [change.number]; | |
| 3679 | yield* PubSub.publishAll( | |
| 3680 | stateChanges, | |
| 3681 | numbers.map((number) => ({ host: "github.com", repository: change.repository, number })), | |
| 3682 | ); | |
| 3683 | }), | |
| 3684 | ), | |
| 3685 | Effect.forkScoped, | |
| 3686 | ); | |
| 3687 | ||
| 3578 | 3688 | return PullRequestService.of({ |
| 3579 | 3689 | routing, |
| 3580 | 3690 | routingIdentity, |
| 3581 | 3691 | withRoutingCredential, |
| 3582 | list, | |
| 3692 | list: (input) => | |
| 3693 | mergebot.configuration === null | |
| 3694 | ? list(input) | |
| 3695 | : listWorkspaceProjects(input).pipe( | |
| 3696 | Effect.flatMap(({ supported }) => | |
| 3697 | supported.some(isWorkProject) ? listUncached(input) : list(input), | |
| 3698 | ), | |
| 3699 | ), | |
| 3583 | 3700 | listStats: (input) => |
| 3584 | Effect.forEach(input.refs, (ref) => canonicalRef(ref).pipe(Effect.option)).pipe( | |
| 3585 | Effect.flatMap((refs) => listStats({ ...input, refs: refs.flatMap(Option.toArray) })), | |
| 3586 | ), | |
| 3587 | summary: credentialCached(summary), | |
| 3588 | stack: credentialCached(stack), | |
| 3701 | Effect.gen(function* () { | |
| 3702 | const refs = (yield* Effect.forEach(input.refs, (ref) => | |
| 3703 | canonicalRef(ref).pipe(Effect.option), | |
| 3704 | )).flatMap(Option.toArray); | |
| 3705 | const ordinary: PullRequestRef[] = []; | |
| 3706 | const workStats: PullRequestDiffStat[] = []; | |
| 3707 | for (const ref of refs) { | |
| 3708 | if (!isWorkProject(yield* requireProject(ref))) { | |
| 3709 | ordinary.push(ref); | |
| 3710 | continue; | |
| 3711 | } | |
| 3712 | const value = yield* workDetails(ref); | |
| 3713 | if (value.additions !== null && value.deletions !== null) | |
| 3714 | workStats.push({ | |
| 3715 | projectId: ref.projectId, | |
| 3716 | repository: ref.repository, | |
| 3717 | number: ref.number, | |
| 3718 | additions: value.additions, | |
| 3719 | deletions: value.deletions, | |
| 3720 | }); | |
| 3721 | } | |
| 3722 | const result = yield* listStats({ ...input, refs: ordinary }); | |
| 3723 | return { stats: [...workStats, ...result.stats] }; | |
| 3724 | }), | |
| 3725 | summary: withWorkRead(credentialCached(summary), workSummary), | |
| 3726 | stack: unsupportedWorkRead("stack", credentialCached(stack)), | |
| 3589 | 3727 | subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( |
| 3590 | 3728 | Effect.map((subscription) => Stream.fromSubscription(subscription)), |
| 3591 | 3729 | ), |
| ... | ... | @@ -3597,31 +3735,58 @@ export const make = Effect.gen(function* () { |
| 3597 | 3735 | Stream.filter((revision) => revision > 0), |
| 3598 | 3736 | ), |
| 3599 | 3737 | refreshAfterTurn, |
| 3600 | detail: credentialCached(detail), | |
| 3601 | checks: credentialCached((input) => Cache.get(checksCache, refCacheKey(input))), | |
| 3602 | watchFingerprint: credentialCached((input) => | |
| 3603 | Cache.get(watchFingerprintCache, refCacheKey(input)), | |
| 3738 | detail: unsupportedWorkRead("detail", credentialCached(detail)), | |
| 3739 | checks: withWorkRead( | |
| 3740 | credentialCached((input) => Cache.get(checksCache, refCacheKey(input))), | |
| 3741 | (input) => | |
| 3742 | workDetails(input).pipe( | |
| 3743 | Effect.map((value) => ({ state: value.lifecycle, checks: mergebotChecks(value) })), | |
| 3744 | ), | |
| 3604 | 3745 | ), |
| 3605 | activity: credentialCached(activity), | |
| 3606 | preview: credentialCached(preview), | |
| 3607 | threadComments, | |
| 3608 | diff: credentialCached(diff), | |
| 3609 | diffFileContents, | |
| 3610 | filesViewed: credentialCached(filesViewed), | |
| 3611 | setFilesViewed, | |
| 3612 | runAction: runActionAndInvalidate, | |
| 3613 | update: invalidatedByMutation(update), | |
| 3614 | comment: invalidatedByMutation(comment), | |
| 3615 | updateComment: invalidatedByMutation(updateComment), | |
| 3616 | submitReview: invalidatedByMutation(submitReview), | |
| 3617 | replyToThread: invalidatedByMutation(replyToThread), | |
| 3618 | setThreadResolution: invalidatedByMutation(setThreadResolution), | |
| 3619 | setReaction: invalidatedByMutation(setReaction), | |
| 3746 | watchFingerprint: withWorkRead( | |
| 3747 | credentialCached((input) => Cache.get(watchFingerprintCache, refCacheKey(input))), | |
| 3748 | (input) => workDetails(input).pipe(Effect.map(mergebotWatchFingerprint)), | |
| 3749 | ), | |
| 3750 | activity: unsupportedWorkRead("activity", credentialCached(activity)), | |
| 3751 | preview: withWorkRead(credentialCached(preview), (input) => | |
| 3752 | workDetails(input).pipe( | |
| 3753 | Effect.map((value) => ({ | |
| 3754 | projectId: input.projectId, | |
| 3755 | repository: input.repository, | |
| 3756 | number: value.number, | |
| 3757 | title: value.title, | |
| 3758 | url: value.url, | |
| 3759 | author: value.author, | |
| 3760 | state: value.lifecycle, | |
| 3761 | isDraft: value.draft, | |
| 3762 | createdAt: value.createdAt, | |
| 3763 | })), | |
| 3764 | ), | |
| 3765 | ), | |
| 3766 | threadComments: unsupportedWorkRead("threadComments", threadComments), | |
| 3767 | diff: unsupportedWorkRead("diff", credentialCached(diff)), | |
| 3768 | diffFileContents: unsupportedWorkRead("diffFileContents", diffFileContents), | |
| 3769 | filesViewed: unsupportedWorkRead("filesViewed", credentialCached(filesViewed)), | |
| 3770 | setFilesViewed: unsupportedWorkRead("setFilesViewed", setFilesViewed), | |
| 3771 | runAction: unsupportedWorkRead("runAction", runActionAndInvalidate), | |
| 3772 | update: unsupportedWorkRead("update", invalidatedByMutation(update)), | |
| 3773 | comment: unsupportedWorkRead("comment", invalidatedByMutation(comment)), | |
| 3774 | updateComment: unsupportedWorkRead("updateComment", invalidatedByMutation(updateComment)), | |
| 3775 | submitReview: unsupportedWorkRead("submitReview", invalidatedByMutation(submitReview)), | |
| 3776 | replyToThread: unsupportedWorkRead("replyToThread", invalidatedByMutation(replyToThread)), | |
| 3777 | setThreadResolution: unsupportedWorkRead( | |
| 3778 | "setThreadResolution", | |
| 3779 | invalidatedByMutation(setThreadResolution), | |
| 3780 | ), | |
| 3781 | setReaction: unsupportedWorkRead("setReaction", invalidatedByMutation(setReaction)), | |
| 3620 | 3782 | // The candidate list is deliberately read fresh per menu-open, so it stays uncached. |
| 3621 | reviewerCandidates, | |
| 3622 | requestReviewers: invalidatedByMutation(requestReviewers), | |
| 3623 | labelCandidates, | |
| 3624 | setLabels: invalidatedByMutation(setLabels), | |
| 3783 | reviewerCandidates: unsupportedWorkRead("reviewerCandidates", reviewerCandidates), | |
| 3784 | requestReviewers: unsupportedWorkRead( | |
| 3785 | "requestReviewers", | |
| 3786 | invalidatedByMutation(requestReviewers), | |
| 3787 | ), | |
| 3788 | labelCandidates: unsupportedWorkRead("labelCandidates", labelCandidates), | |
| 3789 | setLabels: unsupportedWorkRead("setLabels", invalidatedByMutation(setLabels)), | |
| 3625 | 3790 | invalidate, |
| 3626 | 3791 | }); |
| 3627 | 3792 | }); |
apps/server/src/server.ts+4| ... | ... | @@ -117,6 +117,7 @@ import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; |
| 117 | 117 | import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; |
| 118 | 118 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; |
| 119 | 119 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 120 | import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; | |
| 120 | 121 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; |
| 121 | 122 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; |
| 122 | 123 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| ... | ... | @@ -363,11 +364,13 @@ const layerPullRequestService = PullRequestService.layer.pipe( |
| 363 | 364 | // Where the viewed-file marks live for a host that keeps none of its own. |
| 364 | 365 | Layer.provide(PullRequestFilesViewed.layer), |
| 365 | 366 | Layer.provide(PullRequestReadCache.layer), |
| 367 | Layer.provide(MergebotQueue.layer), | |
| 366 | 368 | Layer.provide(layerSourceControlProviderRegistry), |
| 367 | 369 | Layer.provide(SourceControlRateLimit.layer), |
| 368 | 370 | ); |
| 369 | 371 | |
| 370 | 372 | const layerGitManager = GitManager.layer.pipe( |
| 373 | Layer.provide(MergebotQueue.layer), | |
| 371 | 374 | // Per-project git settings resolve the acting thread's project. |
| 372 | 375 | Layer.provide(Layer.merge(ProjectionStoreV2.layer, ProjectStore.layer)), |
| 373 | 376 | Layer.provideMerge(RuntimeLayer.layerProjectSetupScriptRunner), |
| ... | ... | @@ -646,6 +649,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( |
| 646 | 649 | Layer.provideMerge(RuntimeLayer.layerEventInfrastructure), |
| 647 | 650 | Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), |
| 648 | 651 | Layer.provideMerge(layerServerSettings), |
| 652 | Layer.provideMerge(MergebotQueue.layer), | |
| 649 | 653 | // The asset route uses the registry's GitHub credential for private PR media, which the |
| 650 | 654 | // built-in drivers' layer provides alongside the registry. |
| 651 | 655 | Layer.provideMerge(layerSourceControlProviderRegistry), |
apps/server/src/ws.ts+5| ... | ... | @@ -189,6 +189,7 @@ import { parseBase64DataUrl } from "./imageMime.ts"; |
| 189 | 189 | import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; |
| 190 | 190 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 191 | 191 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 192 | import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; | |
| 192 | 193 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; |
| 193 | 194 | import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; |
| 194 | 195 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; |
| ... | ... | @@ -1169,6 +1170,7 @@ const layerWsRpc = ( |
| 1169 | 1170 | const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; |
| 1170 | 1171 | const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; |
| 1171 | 1172 | const secretRequests = yield* SecretRequests.SecretRequests; |
| 1173 | const mergebotQueue = yield* MergebotQueue.MergebotQueue; | |
| 1172 | 1174 | const scriptProgress = yield* ScriptProgress.ScriptProgress; |
| 1173 | 1175 | const pullRequests = yield* PullRequestService.PullRequestService; |
| 1174 | 1176 | const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; |
| ... | ... | @@ -1673,6 +1675,7 @@ const layerWsRpc = ( |
| 1673 | 1675 | otlpLogsEnabled: config.otlpLogsUrl !== undefined, |
| 1674 | 1676 | }, |
| 1675 | 1677 | settings, |
| 1678 | ...(mergebotQueue.configuration ? { mergebot: mergebotQueue.configuration } : {}), | |
| 1676 | 1679 | shellResumeCompletionMarker: true, |
| 1677 | 1680 | threadResumeCompletionMarker: true, |
| 1678 | 1681 | threadSnapshotPagination: true, |
| ... | ... | @@ -2409,6 +2412,8 @@ const layerWsRpc = ( |
| 2409 | 2412 | Effect.forkScoped, |
| 2410 | 2413 | ), |
| 2411 | 2414 | ), |
| 2415 | [WS_METHODS.mergebotQueue]: (input) => mergebotQueue.read(input), | |
| 2416 | [WS_METHODS.mergebotSubscribe]: (input) => mergebotQueue.watchQueue(input), | |
| 2412 | 2417 | [WS_METHODS.pullRequestsList]: (input) => pullRequests.list(input), |
| 2413 | 2418 | [WS_METHODS.pullRequestsListStats]: (input) => pullRequests.listStats(input), |
| 2414 | 2419 | [WS_METHODS.pullRequestsRoutingIdentity]: (input) => pullRequests.routingIdentity(input), |
apps/web/src/components/ChatView.tsx+6| ... | ... | @@ -11237,6 +11237,12 @@ export default function ChatView(props: ChatViewProps) { |
| 11237 | 11237 | } |
| 11238 | 11238 | key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`} |
| 11239 | 11239 | environmentId={activeThread.environmentId} |
| 11240 | onMergebotReady={(tabId) => | |
| 11241 | activeThreadRef !== null && | |
| 11242 | useRightPanelStore | |
| 11243 | .getState() | |
| 11244 | .replacePullRequestWithBrowser(activeThreadRef, renderedRightPanelSurface.id, tabId) | |
| 11245 | } | |
| 11240 | 11246 | onSelectPullRequest={(reference) => { |
| 11241 | 11247 | if (activeThreadRef) |
| 11242 | 11248 | useRightPanelStore.getState().openPullRequest(activeThreadRef, { |
apps/web/src/components/pullRequest/MergebotBrowserPanel.test.tsx created+255| ... | ... | @@ -0,0 +1,255 @@ |
| 1 | import { AsyncResult } from "effect/reactivity"; | |
| 2 | import * as Cause from "effect/Cause"; | |
| 3 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 4 | import { act } from "react"; | |
| 5 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 6 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 7 | ||
| 8 | const state = vi.hoisted(() => ({ | |
| 9 | listError: null as string | null, | |
| 10 | refresh: vi.fn(), | |
| 11 | available: true, | |
| 12 | canOperate: true, | |
| 13 | thread: null as { settledOverride: "settled" | null; archivedAt: string | null } | null, | |
| 14 | preview: { | |
| 15 | listLoaded: false, | |
| 16 | sessions: {} as Record<string, { tabId: string; navStatus: { _tag: "Success"; url: string } }>, | |
| 17 | recentlySeenUrls: [] as string[], | |
| 18 | }, | |
| 19 | close: vi.fn(), | |
| 20 | onReady: undefined as ((tabId: string) => boolean) | undefined, | |
| 21 | open: vi.fn(), | |
| 22 | })); | |
| 23 | vi.mock("~/browser/previewRuntime", () => ({ usePreviewAvailable: () => state.available })); | |
| 24 | vi.mock("~/previewStateStore", () => ({ | |
| 25 | useThreadPreviewState: () => state.preview, | |
| 26 | readThreadPreviewState: () => state.preview, | |
| 27 | })); | |
| 28 | vi.mock("~/state/entities", () => ({ useThreadShell: () => state.thread })); | |
| 29 | vi.mock("~/state/session", () => ({ useEnvironmentScope: () => state.canOperate })); | |
| 30 | vi.mock("~/state/preview", () => ({ | |
| 31 | previewEnvironment: { open: {}, close: {}, list: () => ({}) }, | |
| 32 | })); | |
| 33 | vi.mock("~/state/query", () => ({ | |
| 34 | useEnvironmentQuery: () => ({ error: state.listError, refresh: state.refresh }), | |
| 35 | })); | |
| 36 | vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); | |
| 37 | vi.mock("../preview/usePreviewSession", () => ({ usePreviewSession: () => {} })); | |
| 38 | vi.mock("../preview/closePreviewSession", () => ({ closePreviewSession: state.close })); | |
| 39 | vi.mock("../preview/openPreviewSession", () => ({ openPreviewSession: state.open })); | |
| 40 | vi.mock("../preview/PreviewPanel", () => ({ | |
| 41 | PreviewPanel: ({ tabId }: { tabId: string }) => <div data-browser-tab={tabId} />, | |
| 42 | })); | |
| 43 | vi.mock("../ui/button", () => ({ Button: "button" })); | |
| 44 | import { MergebotBrowserPanel } from "./MergebotBrowserPanel"; | |
| 45 | ||
| 46 | const threadRef = { | |
| 47 | environmentId: EnvironmentId.make("environment"), | |
| 48 | threadId: ThreadId.make("thread"), | |
| 49 | }; | |
| 50 | const url = "https://mergebot.example/Work/App/pull/12"; | |
| 51 | let renderer: ReactTestRenderer | null = null; | |
| 52 | const render = async (owner = threadRef) => { | |
| 53 | await act(() => { | |
| 54 | if (renderer) | |
| 55 | renderer.update(<MergebotBrowserPanel threadRef={owner} url={url} onReady={state.onReady} />); | |
| 56 | else | |
| 57 | renderer = create( | |
| 58 | <MergebotBrowserPanel threadRef={owner} url={url} onReady={state.onReady} />, | |
| 59 | ); | |
| 60 | }); | |
| 61 | }; | |
| 62 | beforeEach(() => { | |
| 63 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 64 | state.onReady = undefined; | |
| 65 | state.close.mockReset().mockResolvedValue(AsyncResult.success(undefined)); | |
| 66 | state.listError = null; | |
| 67 | state.refresh.mockReset(); | |
| 68 | state.available = true; | |
| 69 | state.canOperate = true; | |
| 70 | state.thread = { settledOverride: null, archivedAt: null }; | |
| 71 | state.preview = { listLoaded: false, sessions: {}, recentlySeenUrls: [] }; | |
| 72 | state.open.mockReset().mockImplementation(async () => { | |
| 73 | state.preview.sessions.opened = { tabId: "opened", navStatus: { _tag: "Success", url } }; | |
| 74 | state.preview.recentlySeenUrls.push(url); | |
| 75 | return AsyncResult.success({ tabId: "opened" }); | |
| 76 | }); | |
| 77 | }); | |
| 78 | afterEach(async () => { | |
| 79 | await act(() => renderer?.unmount()); | |
| 80 | renderer = null; | |
| 81 | vi.unstubAllGlobals(); | |
| 82 | }); | |
| 83 | ||
| 84 | it("waits for the authoritative tab list before opening the owned Mergebot view", async () => { | |
| 85 | await render(); | |
| 86 | expect(state.open).not.toHaveBeenCalled(); | |
| 87 | state.preview.listLoaded = true; | |
| 88 | await render(); | |
| 89 | expect(state.open).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ threadRef, url })); | |
| 90 | expect(renderer!.root.findByProps({ "data-browser-tab": "opened" })).toBeDefined(); | |
| 91 | }); | |
| 92 | it("reuses an existing URL after remount without opening duplicate tabs", async () => { | |
| 93 | state.preview.listLoaded = true; | |
| 94 | state.preview.sessions.existing = { tabId: "existing", navStatus: { _tag: "Success", url } }; | |
| 95 | await render(); | |
| 96 | await act(() => renderer?.unmount()); | |
| 97 | renderer = null; | |
| 98 | await render(); | |
| 99 | expect(state.open).not.toHaveBeenCalled(); | |
| 100 | expect(renderer!.root.findByProps({ "data-browser-tab": "existing" })).toBeDefined(); | |
| 101 | }); | |
| 102 | it("does not reopen tabs closed by Settle, including after remount", async () => { | |
| 103 | state.preview.listLoaded = true; | |
| 104 | await render(); | |
| 105 | state.preview.sessions = {}; | |
| 106 | state.thread = { settledOverride: "settled", archivedAt: null }; | |
| 107 | await render(); | |
| 108 | await act(() => renderer?.unmount()); | |
| 109 | renderer = null; | |
| 110 | await render(); | |
| 111 | expect(state.open).toHaveBeenCalledTimes(1); | |
| 112 | }); | |
| 113 | it("keeps a settled chat closed until the reader explicitly opens Mergebot", async () => { | |
| 114 | state.preview.listLoaded = true; | |
| 115 | state.thread = { settledOverride: "settled", archivedAt: null }; | |
| 116 | await render(); | |
| 117 | expect(state.open).not.toHaveBeenCalled(); | |
| 118 | await act(() => renderer!.root.findByType("button").props.onClick()); | |
| 119 | expect(state.open).toHaveBeenCalledOnce(); | |
| 120 | }); | |
| 121 | it("shows a retryable failure without opening externally", async () => { | |
| 122 | state.preview.listLoaded = true; | |
| 123 | state.open.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("offline")))); | |
| 124 | await render(); | |
| 125 | expect(renderer!.root.findByProps({ role: "alert" }).children).toContain("offline"); | |
| 126 | await act(() => renderer!.root.findByType("button").props.onClick()); | |
| 127 | expect(state.open).toHaveBeenCalledTimes(2); | |
| 128 | }); | |
| 129 | it("does not request a browser when permission or support is unavailable", async () => { | |
| 130 | state.preview.listLoaded = true; | |
| 131 | state.canOperate = false; | |
| 132 | await render(); | |
| 133 | expect(state.open).not.toHaveBeenCalled(); | |
| 134 | expect(renderer!.root.findAllByType("button")).toHaveLength(0); | |
| 135 | }); | |
| 136 | ||
| 137 | it("retries a failed authoritative list instead of opening duplicate unlisted tabs", async () => { | |
| 138 | state.listError = "Connection unavailable"; | |
| 139 | await render(); | |
| 140 | expect(renderer!.root.findByProps({ role: "alert" }).children).toContain( | |
| 141 | "Connection unavailable", | |
| 142 | ); | |
| 143 | await act(() => renderer!.root.findByType("button").props.onClick()); | |
| 144 | expect(state.refresh).toHaveBeenCalledOnce(); | |
| 145 | expect(state.open).not.toHaveBeenCalled(); | |
| 146 | }); | |
| 147 | ||
| 148 | it("waits for a real owner’s lifecycle state before automatically opening tabs", async () => { | |
| 149 | state.thread = null; | |
| 150 | state.preview.listLoaded = true; | |
| 151 | await render(); | |
| 152 | expect(state.open).not.toHaveBeenCalled(); | |
| 153 | state.thread = { settledOverride: null, archivedAt: null }; | |
| 154 | await render(); | |
| 155 | expect(state.open).toHaveBeenCalledOnce(); | |
| 156 | }); | |
| 157 | ||
| 158 | it("closes a newly created tab when its PR surface was closed while opening", async () => { | |
| 159 | state.preview.listLoaded = true; | |
| 160 | let finish!: (value: unknown) => void; | |
| 161 | state.open.mockReturnValue( | |
| 162 | new Promise((resolve) => { | |
| 163 | finish = resolve; | |
| 164 | }), | |
| 165 | ); | |
| 166 | const ready = vi.fn(() => true); | |
| 167 | state.onReady = ready; | |
| 168 | await render(); | |
| 169 | ready.mockReturnValue(false); | |
| 170 | await act(async () => { | |
| 171 | finish(AsyncResult.success({ tabId: "late" })); | |
| 172 | }); | |
| 173 | expect(state.close).toHaveBeenCalledWith( | |
| 174 | expect.objectContaining({ threadRef, tabId: "late", snapshot: { tabId: "late" } }), | |
| 175 | ); | |
| 176 | }); | |
| 177 | it("hands an existing tab to the browser surface without reopening or closing it", async () => { | |
| 178 | state.preview.listLoaded = true; | |
| 179 | state.preview.sessions.existing = { tabId: "existing", navStatus: { _tag: "Success", url } }; | |
| 180 | const ready = vi.fn(() => true); | |
| 181 | state.onReady = ready; | |
| 182 | await render(); | |
| 183 | expect(ready).toHaveBeenCalledWith("existing"); | |
| 184 | expect(state.open).not.toHaveBeenCalled(); | |
| 185 | expect(state.close).not.toHaveBeenCalled(); | |
| 186 | }); | |
| 187 | ||
| 188 | it("does not close a browser already handed off by its published snapshot", async () => { | |
| 189 | state.preview.listLoaded = true; | |
| 190 | let finish!: (value: unknown) => void; | |
| 191 | state.open.mockReturnValue( | |
| 192 | new Promise((resolve) => { | |
| 193 | finish = resolve; | |
| 194 | }), | |
| 195 | ); | |
| 196 | let placeholderPresent = true; | |
| 197 | const ready = vi.fn(() => { | |
| 198 | if (!placeholderPresent) return false; | |
| 199 | placeholderPresent = false; | |
| 200 | return true; | |
| 201 | }); | |
| 202 | state.onReady = ready; | |
| 203 | await render(); | |
| 204 | state.preview.sessions.published = { | |
| 205 | tabId: "published", | |
| 206 | navStatus: { _tag: "Success", url }, | |
| 207 | }; | |
| 208 | await render(); | |
| 209 | expect(ready).toHaveBeenCalledTimes(1); | |
| 210 | expect(placeholderPresent).toBe(false); | |
| 211 | await act(async () => { | |
| 212 | finish(AsyncResult.success({ tabId: "published" })); | |
| 213 | }); | |
| 214 | expect(ready).toHaveBeenCalledTimes(1); | |
| 215 | expect(state.close).not.toHaveBeenCalled(); | |
| 216 | }); | |
| 217 | ||
| 218 | it("reopens a previously closed URL for a fresh explicit chat PR placeholder", async () => { | |
| 219 | state.preview.listLoaded = true; | |
| 220 | state.preview.recentlySeenUrls = [url]; | |
| 221 | state.onReady = vi.fn(() => true); | |
| 222 | await render(); | |
| 223 | expect(state.open).toHaveBeenCalledOnce(); | |
| 224 | }); | |
| 225 | ||
| 226 | it("does not reopen a settled chat's URL for a fresh PR placeholder", async () => { | |
| 227 | state.preview.listLoaded = true; | |
| 228 | state.preview.recentlySeenUrls = [url]; | |
| 229 | state.thread = { settledOverride: "settled", archivedAt: null }; | |
| 230 | state.onReady = vi.fn(() => true); | |
| 231 | await render(); | |
| 232 | expect(state.open).not.toHaveBeenCalled(); | |
| 233 | }); | |
| 234 | ||
| 235 | it("does not reopen an archived chat's URL for a fresh PR placeholder", async () => { | |
| 236 | state.preview.listLoaded = true; | |
| 237 | state.preview.recentlySeenUrls = [url]; | |
| 238 | state.thread = { settledOverride: null, archivedAt: "2026-10-14T10:00:00Z" }; | |
| 239 | state.onReady = vi.fn(() => true); | |
| 240 | await render(); | |
| 241 | expect(state.open).not.toHaveBeenCalled(); | |
| 242 | }); | |
| 243 | ||
| 244 | it("keeps a closed standalone PR embed closed on remount", async () => { | |
| 245 | state.thread = null; | |
| 246 | state.preview.listLoaded = true; | |
| 247 | state.preview.recentlySeenUrls = [url]; | |
| 248 | state.onReady = vi.fn(() => true); | |
| 249 | const owner = { ...threadRef, threadId: ThreadId.make("mergebot-browser:work/app:12") }; | |
| 250 | await render(owner); | |
| 251 | await act(() => renderer?.unmount()); | |
| 252 | renderer = null; | |
| 253 | await render(owner); | |
| 254 | expect(state.open).not.toHaveBeenCalled(); | |
| 255 | }); |
apps/web/src/components/pullRequest/MergebotBrowserPanel.tsx created+177| ... | ... | @@ -0,0 +1,177 @@ |
| 1 | import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; | |
| 2 | import { | |
| 3 | AuthPreviewOperateScope, | |
| 4 | MERGEBOT_BROWSER_THREAD_ID, | |
| 5 | type ScopedThreadRef, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { | |
| 8 | isAtomCommandInterrupted, | |
| 9 | squashAtomCommandFailure, | |
| 10 | } from "@t3tools/client-runtime/state/runtime"; | |
| 11 | ||
| 12 | import { usePreviewAvailable } from "~/browser/previewRuntime"; | |
| 13 | import { readThreadPreviewState, useThreadPreviewState } from "~/previewStateStore"; | |
| 14 | import { useEnvironmentQuery } from "~/state/query"; | |
| 15 | import { useThreadShell } from "~/state/entities"; | |
| 16 | import { previewEnvironment } from "~/state/preview"; | |
| 17 | import { useEnvironmentScope } from "~/state/session"; | |
| 18 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 19 | import { Button } from "../ui/button"; | |
| 20 | import { PreviewPanel } from "../preview/PreviewPanel"; | |
| 21 | import { closePreviewSession } from "../preview/closePreviewSession"; | |
| 22 | import { openPreviewSession } from "../preview/openPreviewSession"; | |
| 23 | import { usePreviewSession } from "../preview/usePreviewSession"; | |
| 24 | ||
| 25 | export function MergebotBrowserPanel({ | |
| 26 | threadRef, | |
| 27 | url, | |
| 28 | onReady, | |
| 29 | }: { | |
| 30 | threadRef: ScopedThreadRef; | |
| 31 | url: string; | |
| 32 | onReady?: ((tabId: string) => boolean) | undefined; | |
| 33 | }) { | |
| 34 | usePreviewSession(threadRef); | |
| 35 | const preview = useThreadPreviewState(threadRef); | |
| 36 | const list = useEnvironmentQuery( | |
| 37 | previewEnvironment.list({ | |
| 38 | environmentId: threadRef.environmentId, | |
| 39 | input: { threadId: threadRef.threadId }, | |
| 40 | }), | |
| 41 | ); | |
| 42 | const thread = useThreadShell(threadRef); | |
| 43 | const ownerKnown = | |
| 44 | thread !== null || | |
| 45 | threadRef.threadId === MERGEBOT_BROWSER_THREAD_ID || | |
| 46 | threadRef.threadId.startsWith(`${MERGEBOT_BROWSER_THREAD_ID}:`); | |
| 47 | const available = usePreviewAvailable(threadRef.environmentId); | |
| 48 | const canOperate = useEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope); | |
| 49 | const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); | |
| 50 | const closePreview = useAtomCommand(previewEnvironment.close); | |
| 51 | const [tabId, setTabId] = useState<string | null>(null); | |
| 52 | const [pending, setPending] = useState(false); | |
| 53 | const [error, setError] = useState<string | null>(null); | |
| 54 | const attempted = useRef(false); | |
| 55 | const busy = useRef(false); | |
| 56 | const handoffs = useRef(new Map<string, boolean>()); | |
| 57 | const matchingTab = Object.values(preview.sessions).find( | |
| 58 | (session) => session.navStatus._tag !== "Idle" && session.navStatus.url === url, | |
| 59 | ); | |
| 60 | const activeTabId = tabId && preview.sessions[tabId] ? tabId : (matchingTab?.tabId ?? null); | |
| 61 | const isChatHandoff = onReady !== undefined && thread !== null; | |
| 62 | ||
| 63 | const handOff = useCallback( | |
| 64 | (openedTabId: string) => { | |
| 65 | const previous = handoffs.current.get(openedTabId); | |
| 66 | if (previous !== undefined) return previous; | |
| 67 | const accepted = onReady?.(openedTabId) ?? true; | |
| 68 | handoffs.current.set(openedTabId, accepted); | |
| 69 | return accepted; | |
| 70 | }, | |
| 71 | [onReady], | |
| 72 | ); | |
| 73 | ||
| 74 | const open = useCallback(async () => { | |
| 75 | if (!available || !canOperate || busy.current) return; | |
| 76 | busy.current = true; | |
| 77 | setPending(true); | |
| 78 | setError(null); | |
| 79 | try { | |
| 80 | const matching = Object.values(readThreadPreviewState(threadRef).sessions).find( | |
| 81 | (session) => session.navStatus._tag !== "Idle" && session.navStatus.url === url, | |
| 82 | ); | |
| 83 | if (matching) { | |
| 84 | handOff(matching.tabId); | |
| 85 | setTabId(matching.tabId); | |
| 86 | return; | |
| 87 | } | |
| 88 | const result = await openPreviewSession({ threadRef, url, openPreview }); | |
| 89 | if (isAtomCommandInterrupted(result)) return; | |
| 90 | if (result._tag === "Failure") throw squashAtomCommandFailure(result); | |
| 91 | if (!handOff(result.value.tabId)) { | |
| 92 | await closePreviewSession({ | |
| 93 | closePreview, | |
| 94 | snapshot: result.value, | |
| 95 | tabId: result.value.tabId, | |
| 96 | threadRef, | |
| 97 | }); | |
| 98 | return; | |
| 99 | } | |
| 100 | setTabId(result.value.tabId); | |
| 101 | } catch (cause) { | |
| 102 | setError(cause instanceof Error ? cause.message : "Try opening Mergebot again."); | |
| 103 | } finally { | |
| 104 | busy.current = false; | |
| 105 | setPending(false); | |
| 106 | } | |
| 107 | }, [available, canOperate, closePreview, handOff, openPreview, threadRef, url]); | |
| 108 | ||
| 109 | useLayoutEffect(() => { | |
| 110 | if (activeTabId) handOff(activeTabId); | |
| 111 | }, [activeTabId, handOff]); | |
| 112 | ||
| 113 | useEffect(() => { | |
| 114 | if ( | |
| 115 | !ownerKnown || | |
| 116 | !preview.listLoaded || | |
| 117 | activeTabId || | |
| 118 | attempted.current || | |
| 119 | !available || | |
| 120 | !canOperate | |
| 121 | ) | |
| 122 | return; | |
| 123 | attempted.current = true; | |
| 124 | if ( | |
| 125 | thread?.settledOverride === "settled" || | |
| 126 | thread?.archivedAt != null || | |
| 127 | (!isChatHandoff && preview.recentlySeenUrls.includes(url)) | |
| 128 | ) | |
| 129 | return; | |
| 130 | void open(); | |
| 131 | }, [ | |
| 132 | activeTabId, | |
| 133 | available, | |
| 134 | canOperate, | |
| 135 | isChatHandoff, | |
| 136 | open, | |
| 137 | ownerKnown, | |
| 138 | preview.listLoaded, | |
| 139 | preview.recentlySeenUrls, | |
| 140 | thread?.archivedAt, | |
| 141 | thread?.settledOverride, | |
| 142 | url, | |
| 143 | ]); | |
| 144 | ||
| 145 | if (activeTabId && available && canOperate) { | |
| 146 | return <PreviewPanel mode="embedded" threadRef={threadRef} tabId={activeTabId} visible />; | |
| 147 | } | |
| 148 | const displayedError = error ?? (!preview.listLoaded ? list.error : null); | |
| 149 | return ( | |
| 150 | <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-6 text-center"> | |
| 151 | <p | |
| 152 | className="max-w-sm text-sm text-muted-foreground" | |
| 153 | role={displayedError ? "alert" : "status"} | |
| 154 | > | |
| 155 | {displayedError ?? | |
| 156 | (!canOperate | |
| 157 | ? "Pair this client with browser access to open Mergebot." | |
| 158 | : !available | |
| 159 | ? "Connect to an environment with browser support to open Mergebot." | |
| 160 | : pending || !preview.listLoaded | |
| 161 | ? "Opening Mergebot…" | |
| 162 | : "Mergebot is closed.")} | |
| 163 | </p> | |
| 164 | {canOperate && available && (preview.listLoaded || displayedError) ? ( | |
| 165 | <Button | |
| 166 | variant="outline" | |
| 167 | size="sm" | |
| 168 | disabled={pending} | |
| 169 | aria-busy={pending} | |
| 170 | onClick={() => (preview.listLoaded ? void open() : list.refresh())} | |
| 171 | > | |
| 172 | {displayedError ? "Retry" : "Open Mergebot"} | |
| 173 | </Button> | |
| 174 | ) : null} | |
| 175 | </div> | |
| 176 | ); | |
| 177 | } |
apps/web/src/components/pullRequest/MergebotQueueBadge.test.tsx created+106| ... | ... | @@ -0,0 +1,106 @@ |
| 1 | import type { EnvironmentId, MergebotRepoQueue } from "@t3tools/contracts"; | |
| 2 | import { act, createElement } from "react"; | |
| 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 4 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 5 | ||
| 6 | const state = vi.hoisted(() => ({ queue: null as MergebotRepoQueue | null, read: vi.fn() })); | |
| 7 | vi.mock("../../state/environments", () => ({ | |
| 8 | useEnvironment: () => ({ | |
| 9 | serverConfig: { mergebot: { origin: "https://mergebot.example", repositories: ["work/app"] } }, | |
| 10 | }), | |
| 11 | })); | |
| 12 | vi.mock("../../state/mergebot", () => ({ | |
| 13 | useMergebotQueue: (...args: unknown[]) => { | |
| 14 | state.read(...args); | |
| 15 | return state.queue; | |
| 16 | }, | |
| 17 | })); | |
| 18 | vi.mock("../ui/badge", () => ({ Badge: (props: object) => createElement("span", props) })); | |
| 19 | vi.mock("../ui/tooltip", () => ({ | |
| 20 | Tooltip: "div", | |
| 21 | TooltipPopup: "aside", | |
| 22 | TooltipTrigger: ({ | |
| 23 | render, | |
| 24 | children, | |
| 25 | }: { | |
| 26 | render: React.ReactElement; | |
| 27 | children: React.ReactNode; | |
| 28 | }) => createElement(render.type, render.props, children), | |
| 29 | })); | |
| 30 | ||
| 31 | import { MergebotQueueBadge } from "./MergebotQueueBadge"; | |
| 32 | const environmentId = "local" as EnvironmentId; | |
| 33 | const repository = "work/app"; | |
| 34 | let renderer: ReactTestRenderer | undefined; | |
| 35 | function ready(position: number | null = 2): MergebotRepoQueue { | |
| 36 | return { | |
| 37 | repo: repository, | |
| 38 | url: `https://mergebot.example/${repository}`, | |
| 39 | status: "ready", | |
| 40 | processing: true, | |
| 41 | pullRequests: [ | |
| 42 | { | |
| 43 | number: 12, | |
| 44 | headSha: "current", | |
| 45 | lifecycle: "open", | |
| 46 | queueStatus: "queued", | |
| 47 | position, | |
| 48 | mergeEtaAt: null, | |
| 49 | url: `https://mergebot.example/${repository}/pull/12`, | |
| 50 | }, | |
| 51 | ], | |
| 52 | }; | |
| 53 | } | |
| 54 | async function render(repo = repository, headSha = "current") { | |
| 55 | await act(() => { | |
| 56 | const badge = ( | |
| 57 | <MergebotQueueBadge | |
| 58 | environmentId={environmentId} | |
| 59 | repository={repo} | |
| 60 | number={12} | |
| 61 | headSha={headSha} | |
| 62 | /> | |
| 63 | ); | |
| 64 | if (renderer) renderer.update(badge); | |
| 65 | else renderer = create(badge); | |
| 66 | }); | |
| 67 | } | |
| 68 | function label() { | |
| 69 | return renderer!.root.findByType("span").children.join(""); | |
| 70 | } | |
| 71 | beforeEach(() => { | |
| 72 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 73 | state.queue = ready(); | |
| 74 | state.read.mockReset(); | |
| 75 | }); | |
| 76 | afterEach(async () => { | |
| 77 | await act(() => renderer?.unmount()); | |
| 78 | renderer = undefined; | |
| 79 | vi.unstubAllGlobals(); | |
| 80 | }); | |
| 81 | ||
| 82 | it("does not mount queue polling for a repository Mergebot does not manage", async () => { | |
| 83 | await render("pingdotgg/t3code"); | |
| 84 | expect(state.read).not.toHaveBeenCalled(); | |
| 85 | expect(renderer!.toJSON()).toBeNull(); | |
| 86 | await render(); | |
| 87 | expect(state.read).toHaveBeenCalledExactlyOnceWith(environmentId, repository); | |
| 88 | expect(label()).toBe("Mergebot: Queued #2"); | |
| 89 | }); | |
| 90 | it("replaces a known queue position with Unknown after a head mismatch or unavailable observation", async () => { | |
| 91 | await render(); | |
| 92 | expect(label()).toBe("Mergebot: Queued #2"); | |
| 93 | await render(repository, "new-push"); | |
| 94 | expect(label()).toBe("Mergebot: Unknown"); | |
| 95 | state.queue = null; | |
| 96 | await render(); | |
| 97 | expect(label()).toBe("Mergebot: Unknown"); | |
| 98 | state.queue = ready(); | |
| 99 | await render(); | |
| 100 | expect(label()).toBe("Mergebot: Queued #2"); | |
| 101 | }); | |
| 102 | it("identifies a queue entry without an active position as held", async () => { | |
| 103 | state.queue = ready(null); | |
| 104 | await render(); | |
| 105 | expect(label()).toBe("Mergebot: Queued (held)"); | |
| 106 | }); |
apps/web/src/components/pullRequest/MergebotQueueBadge.tsx created+66| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | import { | |
| 2 | isMergebotRepository, | |
| 3 | mergebotPullRequestStatus, | |
| 4 | type EnvironmentId, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | ||
| 7 | import { useMergebotQueue } from "../../state/mergebot"; | |
| 8 | import { useEnvironment } from "../../state/environments"; | |
| 9 | import { Badge } from "../ui/badge"; | |
| 10 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 11 | ||
| 12 | const PHASE_LABEL = { | |
| 13 | not_queued: "Not queued", | |
| 14 | queued: "Queued", | |
| 15 | updating_branch: "Updating branch", | |
| 16 | waiting_ci: "Waiting for CI", | |
| 17 | merging: "Merging", | |
| 18 | ci_failed: "CI failed", | |
| 19 | } as const; | |
| 20 | ||
| 21 | interface Props { | |
| 22 | environmentId: EnvironmentId; | |
| 23 | repository: string; | |
| 24 | number: number; | |
| 25 | headSha?: string | undefined; | |
| 26 | } | |
| 27 | ||
| 28 | export function MergebotQueueBadge(props: Props) { | |
| 29 | const configuration = useEnvironment(props.environmentId)?.serverConfig?.mergebot; | |
| 30 | return isMergebotRepository(configuration, props.repository) ? ( | |
| 31 | <SupportedQueueBadge {...props} /> | |
| 32 | ) : null; | |
| 33 | } | |
| 34 | ||
| 35 | function SupportedQueueBadge({ environmentId, repository, number, headSha }: Props) { | |
| 36 | const queue = useMergebotQueue(environmentId, repository); | |
| 37 | const entry = mergebotPullRequestStatus(queue, number, headSha); | |
| 38 | const phase = entry?.lifecycle === "open" ? entry.queueStatus : null; | |
| 39 | const held = phase !== null && phase !== "not_queued" && entry?.position === null; | |
| 40 | const label = phase | |
| 41 | ? `${PHASE_LABEL[phase]}${entry?.position ? ` #${entry.position}` : held ? " (held)" : ""}` | |
| 42 | : "Unknown"; | |
| 43 | const variant = | |
| 44 | phase === "ci_failed" | |
| 45 | ? "error" | |
| 46 | : held | |
| 47 | ? "warning" | |
| 48 | : phase && phase !== "not_queued" | |
| 49 | ? "info" | |
| 50 | : "secondary"; | |
| 51 | ||
| 52 | return ( | |
| 53 | <Tooltip> | |
| 54 | <TooltipTrigger | |
| 55 | render={ | |
| 56 | <Badge size="sm" variant={variant} aria-label={`Mergebot: ${label}`} role="status" /> | |
| 57 | } | |
| 58 | > | |
| 59 | Mergebot: {label} | |
| 60 | </TooltipTrigger> | |
| 61 | <TooltipPopup> | |
| 62 | {phase ? `Mergebot: ${label}` : "Mergebot queue status unavailable for this revision."} | |
| 63 | </TooltipPopup> | |
| 64 | </Tooltip> | |
| 65 | ); | |
| 66 | } |
apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx+100-2| ... | ... | @@ -13,10 +13,11 @@ import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 13 | 13 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 14 | 14 | import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 15 | 15 | |
| 16 | const { newThread, prepareThread, refresh, Wrapper, Trigger } = vi.hoisted(() => ({ | |
| 16 | const { newThread, prepareThread, refresh, browserState, Wrapper, Trigger } = vi.hoisted(() => ({ | |
| 17 | 17 | newThread: vi.fn(), |
| 18 | 18 | prepareThread: vi.fn(), |
| 19 | 19 | refresh: vi.fn(), |
| 20 | browserState: { configured: false }, | |
| 20 | 21 | Wrapper: ({ children }: { children?: ReactNode }) => children, |
| 21 | 22 | Trigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => ( |
| 22 | 23 | <> |
| ... | ... | @@ -32,8 +33,25 @@ vi.mock("~/state/session", async (original) => ({ |
| 32 | 33 | })); |
| 33 | 34 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); |
| 34 | 35 | vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: {} })); |
| 35 | vi.mock("~/state/entities", () => ({ useProjects: () => [], useServerConfigs: () => new Map() })); | |
| 36 | vi.mock("~/state/entities", () => ({ | |
| 37 | useProjects: () => [], | |
| 38 | useServerConfigs: () => | |
| 39 | browserState.configured | |
| 40 | ? new Map([ | |
| 41 | [ | |
| 42 | "env-1", | |
| 43 | { mergebot: { origin: "https://mergebot.example", repositories: ["Owner/Repo"] } }, | |
| 44 | ], | |
| 45 | ]) | |
| 46 | : new Map(), | |
| 47 | })); | |
| 48 | vi.mock("./MergebotBrowserPanel", () => ({ | |
| 49 | MergebotBrowserPanel: ({ threadRef, url }: { threadRef: ScopedThreadRef; url: string }) => ( | |
| 50 | <div data-mergebot-owner={threadRef.threadId} data-mergebot-url={url} /> | |
| 51 | ), | |
| 52 | })); | |
| 36 | 53 | vi.mock("~/state/environments", () => ({ |
| 54 | useEnvironment: () => null, | |
| 37 | 55 | useEnvironments: () => ({ environments: [] }), |
| 38 | 56 | usePrimaryEnvironmentId: () => EnvironmentId.make("env-1"), |
| 39 | 57 | })); |
| ... | ... | @@ -226,6 +244,7 @@ const newDraftId = DraftId.make("new-draft"); |
| 226 | 244 | let renderer: ReactTestRenderer; |
| 227 | 245 | |
| 228 | 246 | beforeEach(() => { |
| 247 | browserState.configured = false; | |
| 229 | 248 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 230 | 249 | vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); |
| 231 | 250 | useComposerDraftStore.setState({ draftsByThreadKey: {} }); |
| ... | ... | @@ -436,3 +455,82 @@ it("changes the title only for the active tab's main scroller", async () => { |
| 436 | 455 | await act(async () => mainScroll.props.onScrollCapture({ target: code })); |
| 437 | 456 | expect(titleFold().props.inert).toBe(false); |
| 438 | 457 | }); |
| 458 | ||
| 459 | it.each([true, false])( | |
| 460 | "opens the actual Mergebot UI before native detail hooks (chat owner: %s)", | |
| 461 | async (owned) => { | |
| 462 | browserState.configured = true; | |
| 463 | await act(async () => { | |
| 464 | renderer = create( | |
| 465 | <PullRequestDetailPanel | |
| 466 | environmentId={threadRef.environmentId} | |
| 467 | reference={detail} | |
| 468 | threadRef={owned ? threadRef : null} | |
| 469 | shortcutsEnabled={false} | |
| 470 | getShortcutContext={() => ({ | |
| 471 | terminalFocus: false, | |
| 472 | terminalOpen: false, | |
| 473 | previewFocus: false, | |
| 474 | previewOpen: false, | |
| 475 | isWeb: true, | |
| 476 | isDesktop: false, | |
| 477 | })} | |
| 478 | />, | |
| 479 | ); | |
| 480 | }); | |
| 481 | expect( | |
| 482 | renderer.root.findByProps({ | |
| 483 | "data-mergebot-url": "https://mergebot.example/Owner/Repo/pull/1", | |
| 484 | }).props["data-mergebot-owner"], | |
| 485 | ).toBe(owned ? "thread-1" : "mergebot-browser:owner/repo:1"); | |
| 486 | expect(renderer.root.findAllByType("h1")).toHaveLength(0); | |
| 487 | }, | |
| 488 | ); | |
| 489 | ||
| 490 | it.each(["back", "close", "both"])( | |
| 491 | "preserves provided %s app navigation above Mergebot", | |
| 492 | async (controls) => { | |
| 493 | browserState.configured = true; | |
| 494 | const back = vi.fn(); | |
| 495 | const close = vi.fn(); | |
| 496 | await act(async () => { | |
| 497 | renderer = create( | |
| 498 | <PullRequestDetailPanel | |
| 499 | environmentId={threadRef.environmentId} | |
| 500 | reference={detail} | |
| 501 | threadRef={threadRef} | |
| 502 | {...(controls !== "close" ? { onBack: back } : {})} | |
| 503 | {...(controls !== "back" ? { onClose: close } : {})} | |
| 504 | shortcutsEnabled={false} | |
| 505 | getShortcutContext={() => ({ | |
| 506 | terminalFocus: false, | |
| 507 | terminalOpen: false, | |
| 508 | previewFocus: false, | |
| 509 | previewOpen: false, | |
| 510 | isWeb: true, | |
| 511 | isDesktop: false, | |
| 512 | })} | |
| 513 | />, | |
| 514 | ); | |
| 515 | }); | |
| 516 | if (controls !== "close") { | |
| 517 | await click("Back to this thread's pull requests"); | |
| 518 | expect(back).toHaveBeenCalledOnce(); | |
| 519 | } else | |
| 520 | expect( | |
| 521 | renderer.root.findAllByProps({ "aria-label": "Back to this thread's pull requests" }), | |
| 522 | ).toHaveLength(0); | |
| 523 | if (controls !== "back") { | |
| 524 | await click("Collapse pull request panel"); | |
| 525 | expect(close).toHaveBeenCalledOnce(); | |
| 526 | } else | |
| 527 | expect( | |
| 528 | renderer.root.findAllByProps({ "aria-label": "Collapse pull request panel" }), | |
| 529 | ).toHaveLength(0); | |
| 530 | expect( | |
| 531 | renderer.root.findByProps({ | |
| 532 | "data-mergebot-url": "https://mergebot.example/Owner/Repo/pull/1", | |
| 533 | }), | |
| 534 | ).toBeDefined(); | |
| 535 | }, | |
| 536 | ); |
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+79-1| ... | ... | @@ -1,3 +1,9 @@ |
| 1 | import { | |
| 2 | mergebotPullRequestUrl, | |
| 3 | mergebotPullRequestBrowserThreadId, | |
| 4 | ThreadId, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { MergebotQueueBadge } from "./MergebotQueueBadge"; | |
| 1 | 7 | import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; |
| 2 | 8 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | 9 | import { usePullRequestStack } from "~/state/usePullRequestStack"; |
| ... | ... | @@ -412,7 +418,68 @@ function PullRequestBaseFreshnessWarning({ |
| 412 | 418 | ); |
| 413 | 419 | } |
| 414 | 420 | |
| 415 | export function PullRequestDetailPanel({ | |
| 421 | const MergebotBrowserPanel = lazy(() => | |
| 422 | import("./MergebotBrowserPanel").then((module) => ({ default: module.MergebotBrowserPanel })), | |
| 423 | ); | |
| 424 | ||
| 425 | export function PullRequestDetailPanel(props: Parameters<typeof NativePullRequestDetailPanel>[0]) { | |
| 426 | const configurations = useServerConfigs(); | |
| 427 | const projects = useProjects(); | |
| 428 | const project = projects.find( | |
| 429 | (candidate) => | |
| 430 | candidate.id === props.reference.projectId && candidate.environmentId === props.environmentId, | |
| 431 | ); | |
| 432 | const reference = resolvePullRequestReferenceHost(props.reference, project?.repositoryIdentity); | |
| 433 | const url = mergebotPullRequestUrl(configurations.get(props.environmentId)?.mergebot, reference); | |
| 434 | return url ? ( | |
| 435 | <div className="flex h-full min-h-0 flex-col bg-background"> | |
| 436 | {props.onBack || props.onClose ? ( | |
| 437 | <div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1"> | |
| 438 | {props.onBack ? ( | |
| 439 | <Button | |
| 440 | size="icon-xs" | |
| 441 | variant="ghost" | |
| 442 | aria-label="Back to this thread's pull requests" | |
| 443 | onClick={props.onBack} | |
| 444 | > | |
| 445 | <ArrowLeftIcon aria-hidden className="size-3.5" /> | |
| 446 | </Button> | |
| 447 | ) : null} | |
| 448 | {props.onClose ? ( | |
| 449 | <div className="ml-auto"> | |
| 450 | <Button | |
| 451 | size="icon-xs" | |
| 452 | variant="ghost" | |
| 453 | aria-label="Collapse pull request panel" | |
| 454 | onClick={props.onClose} | |
| 455 | > | |
| 456 | <PanelRightIcon aria-hidden className="size-3.5" /> | |
| 457 | </Button> | |
| 458 | </div> | |
| 459 | ) : null} | |
| 460 | </div> | |
| 461 | ) : null} | |
| 462 | <Suspense fallback={<div role="status">Opening Mergebot…</div>}> | |
| 463 | <MergebotBrowserPanel | |
| 464 | key={`${props.environmentId}:${url}`} | |
| 465 | threadRef={ | |
| 466 | props.threadRef ?? | |
| 467 | scopeThreadRef( | |
| 468 | props.environmentId, | |
| 469 | ThreadId.make(mergebotPullRequestBrowserThreadId(reference)), | |
| 470 | ) | |
| 471 | } | |
| 472 | url={url} | |
| 473 | {...(props.onMergebotReady ? { onReady: props.onMergebotReady } : {})} | |
| 474 | /> | |
| 475 | </Suspense> | |
| 476 | </div> | |
| 477 | ) : ( | |
| 478 | <NativePullRequestDetailPanel {...props} /> | |
| 479 | ); | |
| 480 | } | |
| 481 | ||
| 482 | function NativePullRequestDetailPanel({ | |
| 416 | 483 | environmentId, |
| 417 | 484 | shortcutsEnabled, |
| 418 | 485 | getShortcutContext, |
| ... | ... | @@ -432,6 +499,7 @@ export function PullRequestDetailPanel({ |
| 432 | 499 | shortcutsEnabled: boolean; |
| 433 | 500 | getShortcutContext: () => ShortcutMatchContext; |
| 434 | 501 | onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined; |
| 502 | onMergebotReady?: ((tabId: string) => boolean) | undefined; | |
| 435 | 503 | /** |
| 436 | 504 | * The thread this panel sits beside, if any. Links that are not the pull |
| 437 | 505 | * request itself (check details, host permalinks) can open in that thread's |
| ... | ... | @@ -2775,6 +2843,16 @@ export function PullRequestDetailPanel({ |
| 2775 | 2843 | } |
| 2776 | 2844 | /> |
| 2777 | 2845 | ) : null} |
| 2846 | {detail?.state === "open" && !condensed ? ( | |
| 2847 | <div className="px-4 pb-3"> | |
| 2848 | <MergebotQueueBadge | |
| 2849 | environmentId={environmentId} | |
| 2850 | repository={detail.repository} | |
| 2851 | number={detail.number} | |
| 2852 | headSha={detail.headSha} | |
| 2853 | /> | |
| 2854 | </div> | |
| 2855 | ) : null} | |
| 2778 | 2856 | </div> |
| 2779 | 2857 | </div> |
| 2780 | 2858 |
apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx+8| ... | ... | @@ -48,6 +48,7 @@ import { |
| 48 | 48 | } from "./pullRequestPresentation"; |
| 49 | 49 | import { PullRequestGlyph } from "./pullRequestIcons"; |
| 50 | 50 | import { pullRequestQuickActions } from "./pullRequestList.logic"; |
| 51 | import { MergebotQueueBadge } from "./MergebotQueueBadge"; | |
| 51 | 52 | import { PullRequestSpeedActions } from "./PullRequestSpeedActions"; |
| 52 | 53 | |
| 53 | 54 | const SOURCE_LABELS: Record<ThreadPullRequestLink["source"], string> = { |
| ... | ... | @@ -206,6 +207,13 @@ function LinkRow({ |
| 206 | 207 | } |
| 207 | 208 | meta={ |
| 208 | 209 | <> |
| 210 | {open ? ( | |
| 211 | <MergebotQueueBadge | |
| 212 | environmentId={threadRef.environmentId} | |
| 213 | repository={link.repository} | |
| 214 | number={link.number} | |
| 215 | /> | |
| 216 | ) : null} | |
| 209 | 217 | {stack ? ( |
| 210 | 218 | <Tooltip> |
| 211 | 219 | <TooltipTrigger |
apps/web/src/components/sidebar/SidebarChrome.tsx+3-49| ... | ... | @@ -1,11 +1,9 @@ |
| 1 | 1 | import { ArrowLeftIcon } from "lucide-react"; |
| 2 | import type { ReactNode } from "react"; | |
| 3 | 2 | import { memo, useCallback } from "react"; |
| 4 | import { useLocation, useNavigate } from "@tanstack/react-router"; | |
| 3 | import { useLocation } from "@tanstack/react-router"; | |
| 5 | 4 | |
| 6 | 5 | import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; |
| 7 | 6 | import { cn } from "../../lib/utils"; |
| 8 | import { usePullRequestsSupported } from "../../state/environments"; | |
| 9 | 7 | import { |
| 10 | 8 | resolveEnvironmentIdentificationPillLabel, |
| 11 | 9 | useEnvironmentStageLabel, |
| ... | ... | @@ -19,12 +17,10 @@ import { |
| 19 | 17 | SidebarTrigger, |
| 20 | 18 | useSidebar, |
| 21 | 19 | } from "../ui/sidebar"; |
| 22 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 23 | import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences"; | |
| 24 | 20 | import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation"; |
| 25 | 21 | import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; |
| 26 | 22 | import { AppMenu } from "./AppMenu"; |
| 27 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; | |
| 23 | import { SidebarMergebotButton } from "./SidebarMergebotButton"; | |
| 28 | 24 | import { SidebarUsageButton } from "./SidebarUsageButton"; |
| 29 | 25 | |
| 30 | 26 | export const SidebarChromeHeader = memo(function SidebarChromeHeader({ |
| ... | ... | @@ -65,51 +61,17 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ |
| 65 | 61 | ); |
| 66 | 62 | }); |
| 67 | 63 | |
| 68 | function SidebarUtilityItem({ | |
| 69 | icon, | |
| 70 | label, | |
| 71 | onClick, | |
| 72 | }: { | |
| 73 | icon: ReactNode; | |
| 74 | label: string; | |
| 75 | onClick: () => void; | |
| 76 | }) { | |
| 77 | return ( | |
| 78 | <SidebarMenuItem className="shrink-0"> | |
| 79 | <Tooltip> | |
| 80 | <TooltipTrigger | |
| 81 | render={ | |
| 82 | <SidebarMenuButton aria-label={label} onClick={onClick} size="icon"> | |
| 83 | {icon} | |
| 84 | </SidebarMenuButton> | |
| 85 | } | |
| 86 | /> | |
| 87 | <TooltipPopup side="top">{label}</TooltipPopup> | |
| 88 | </Tooltip> | |
| 89 | </SidebarMenuItem> | |
| 90 | ); | |
| 91 | } | |
| 92 | ||
| 93 | 64 | export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { |
| 94 | const navigate = useNavigate(); | |
| 95 | 65 | const navigateToMainApp = useNavigateToMainApp(); |
| 96 | 66 | const { isMobile, setOpenMobile } = useSidebar(); |
| 97 | 67 | const isOnUtilityPage = useLocation({ |
| 98 | 68 | select: (location) => isSidebarUtilityPage(location.pathname), |
| 99 | 69 | }); |
| 100 | const pullRequestsSupported = usePullRequestsSupported(); | |
| 101 | 70 | const closeMobileSidebar = useCallback(() => { |
| 102 | 71 | if (isMobile) { |
| 103 | 72 | setOpenMobile(false); |
| 104 | 73 | } |
| 105 | 74 | }, [isMobile, setOpenMobile]); |
| 106 | const handlePullRequestsClick = useCallback(() => { | |
| 107 | closeMobileSidebar(); | |
| 108 | void navigate({ | |
| 109 | to: "/pull-requests", | |
| 110 | search: readPullRequestListPreferences(), | |
| 111 | }); | |
| 112 | }, [closeMobileSidebar, navigate]); | |
| 113 | 75 | const handleBackClick = useCallback(() => { |
| 114 | 76 | closeMobileSidebar(); |
| 115 | 77 | void navigateToMainApp(); |
| ... | ... | @@ -125,15 +87,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { |
| 125 | 87 | </SidebarMenuButton> |
| 126 | 88 | </SidebarMenuItem> |
| 127 | 89 | ) : ( |
| 128 | <> | |
| 129 | {pullRequestsSupported ? ( | |
| 130 | <SidebarUtilityItem | |
| 131 | icon={<PullRequestGlyph.pullRequest />} | |
| 132 | label="Pull Requests" | |
| 133 | onClick={handlePullRequestsClick} | |
| 134 | /> | |
| 135 | ) : null} | |
| 136 | </> | |
| 90 | <SidebarMergebotButton /> | |
| 137 | 91 | )} |
| 138 | 92 | </SidebarMenu> |
| 139 | 93 | ); |
apps/web/src/components/sidebar/SidebarMergebotButton.test.tsx created+134| ... | ... | @@ -0,0 +1,134 @@ |
| 1 | import { AsyncResult } from "effect/reactivity"; | |
| 2 | import * as Cause from "effect/Cause"; | |
| 3 | import { act } from "react"; | |
| 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 5 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 6 | ||
| 7 | const state = vi.hoisted(() => ({ | |
| 8 | navigate: vi.fn(), | |
| 9 | params: { environmentId: "local", threadId: "active" } as Record<string, string>, | |
| 10 | canPreview: true, | |
| 11 | configured: true, | |
| 12 | open: vi.fn(), | |
| 13 | external: vi.fn(), | |
| 14 | showBrowser: vi.fn(), | |
| 15 | toast: vi.fn(), | |
| 16 | })); | |
| 17 | vi.mock("@tanstack/react-router", () => ({ | |
| 18 | useNavigate: () => state.navigate, | |
| 19 | useParams: ({ select }: { select: (value: object) => unknown }) => select(state.params), | |
| 20 | })); | |
| 21 | vi.mock("../../state/environments", () => ({ | |
| 22 | usePrimaryEnvironmentId: () => "local", | |
| 23 | useEnvironment: () => ({ | |
| 24 | serverConfig: { | |
| 25 | mergebot: state.configured | |
| 26 | ? { origin: "https://mergebot.example", repositories: ["work/app"] } | |
| 27 | : undefined, | |
| 28 | }, | |
| 29 | }), | |
| 30 | })); | |
| 31 | vi.mock("../../state/session", () => ({ useEnvironmentScope: () => state.canPreview })); | |
| 32 | vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); | |
| 33 | vi.mock("../../state/preview", () => ({ previewEnvironment: { open: {} } })); | |
| 34 | vi.mock("../preview/openPreviewSession", () => ({ openPreviewSession: state.open })); | |
| 35 | vi.mock("../../rightPanelStore", () => ({ | |
| 36 | useRightPanelStore: { getState: () => ({ openBrowser: state.showBrowser }) }, | |
| 37 | })); | |
| 38 | vi.mock("../../previewStateStore", () => ({ readThreadPreviewState: () => ({ sessions: {} }) })); | |
| 39 | vi.mock("../../localApi", () => ({ | |
| 40 | readLocalApi: () => ({ shell: { openExternal: state.external } }), | |
| 41 | })); | |
| 42 | vi.mock("../ui/toast", () => ({ toastManager: { add: state.toast } })); | |
| 43 | vi.mock("../ui/sidebar", () => ({ | |
| 44 | useSidebar: () => ({ isMobile: false, setOpenMobile: vi.fn() }), | |
| 45 | SidebarMenuItem: "div", | |
| 46 | SidebarMenuButton: "button", | |
| 47 | })); | |
| 48 | vi.mock("../ui/tooltip", () => ({ | |
| 49 | Tooltip: "div", | |
| 50 | TooltipPopup: "aside", | |
| 51 | TooltipTrigger: ({ render }: { render: unknown }) => render, | |
| 52 | })); | |
| 53 | ||
| 54 | import { SidebarMergebotButton } from "./SidebarMergebotButton"; | |
| 55 | let renderer: ReactTestRenderer; | |
| 56 | async function mount() { | |
| 57 | await act(() => { | |
| 58 | renderer = create(<SidebarMergebotButton />); | |
| 59 | }); | |
| 60 | } | |
| 61 | const click = () => renderer.root.findByType("button").props.onClick(); | |
| 62 | beforeEach(() => { | |
| 63 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 64 | state.params = { environmentId: "local", threadId: "active" }; | |
| 65 | state.canPreview = true; | |
| 66 | state.configured = true; | |
| 67 | state.open.mockReset().mockResolvedValue(AsyncResult.success({ tabId: "mergebot" })); | |
| 68 | state.navigate.mockReset().mockResolvedValue(undefined); | |
| 69 | state.external.mockReset().mockResolvedValue(undefined); | |
| 70 | state.showBrowser.mockReset(); | |
| 71 | state.toast.mockReset(); | |
| 72 | }); | |
| 73 | afterEach(async () => { | |
| 74 | await act(() => renderer?.unmount()); | |
| 75 | vi.unstubAllGlobals(); | |
| 76 | }); | |
| 77 | ||
| 78 | it("opens the live queue beside the active chat, and rapid clicks create one tab", async () => { | |
| 79 | let finish!: (result: unknown) => void; | |
| 80 | state.open.mockReturnValue( | |
| 81 | new Promise((resolve) => { | |
| 82 | finish = resolve; | |
| 83 | }), | |
| 84 | ); | |
| 85 | await mount(); | |
| 86 | await act(() => { | |
| 87 | click(); | |
| 88 | click(); | |
| 89 | }); | |
| 90 | expect(state.open).toHaveBeenCalledTimes(1); | |
| 91 | expect(state.open.mock.calls[0]![0]).toMatchObject({ | |
| 92 | url: "https://mergebot.example/", | |
| 93 | threadRef: { environmentId: "local", threadId: "active" }, | |
| 94 | }); | |
| 95 | await act(async () => { | |
| 96 | finish(AsyncResult.success({ tabId: "mergebot" })); | |
| 97 | }); | |
| 98 | expect(state.showBrowser).toHaveBeenCalledExactlyOnceWith( | |
| 99 | { environmentId: "local", threadId: "active" }, | |
| 100 | "mergebot", | |
| 101 | ); | |
| 102 | expect(state.external).not.toHaveBeenCalled(); | |
| 103 | }); | |
| 104 | it("opens the queue inside the PR page without an active chat", async () => { | |
| 105 | state.params = {}; | |
| 106 | await mount(); | |
| 107 | await act(() => click()); | |
| 108 | expect(state.open).not.toHaveBeenCalled(); | |
| 109 | expect(state.navigate).toHaveBeenCalledExactlyOnceWith({ | |
| 110 | to: "/pull-requests", | |
| 111 | search: { involvement: "all", state: "open", mergebot: "local" }, | |
| 112 | }); | |
| 113 | expect(state.external).not.toHaveBeenCalled(); | |
| 114 | }); | |
| 115 | it("reports an integrated-browser failure and allows retry", async () => { | |
| 116 | state.open.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("offline")))); | |
| 117 | await mount(); | |
| 118 | await act(() => click()); | |
| 119 | expect(state.toast).toHaveBeenCalledWith( | |
| 120 | expect.objectContaining({ type: "error", description: "offline" }), | |
| 121 | ); | |
| 122 | expect(state.external).not.toHaveBeenCalled(); | |
| 123 | await act(() => click()); | |
| 124 | expect(state.open).toHaveBeenCalledTimes(2); | |
| 125 | expect(state.showBrowser).toHaveBeenCalledTimes(1); | |
| 126 | }); | |
| 127 | ||
| 128 | it("hides the shortcut when the destination has no Mergebot configuration", async () => { | |
| 129 | state.configured = false; | |
| 130 | await mount(); | |
| 131 | expect(renderer.toJSON()).toBeNull(); | |
| 132 | expect(state.open).not.toHaveBeenCalled(); | |
| 133 | expect(state.external).not.toHaveBeenCalled(); | |
| 134 | }); |
apps/web/src/components/sidebar/SidebarMergebotButton.tsx created+94| ... | ... | @@ -0,0 +1,94 @@ |
| 1 | import { | |
| 2 | isAtomCommandInterrupted, | |
| 3 | squashAtomCommandFailure, | |
| 4 | } from "@t3tools/client-runtime/state/runtime"; | |
| 5 | import { AuthPreviewOperateScope } from "@t3tools/contracts"; | |
| 6 | import { useNavigate, useParams } from "@tanstack/react-router"; | |
| 7 | import { ListOrderedIcon } from "lucide-react"; | |
| 8 | import { useRef, useState } from "react"; | |
| 9 | ||
| 10 | import { useRightPanelStore } from "../../rightPanelStore"; | |
| 11 | import { previewEnvironment } from "../../state/preview"; | |
| 12 | import { useEnvironmentScope } from "../../state/session"; | |
| 13 | import { useEnvironment, usePrimaryEnvironmentId } from "../../state/environments"; | |
| 14 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 15 | import { resolveThreadRouteRef } from "../../threadRoutes"; | |
| 16 | import { readThreadPreviewState } from "../../previewStateStore"; | |
| 17 | import { openPreviewSession } from "../preview/openPreviewSession"; | |
| 18 | import { SidebarMenuButton, SidebarMenuItem, useSidebar } from "../ui/sidebar"; | |
| 19 | import { toastManager } from "../ui/toast"; | |
| 20 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 21 | ||
| 22 | export function SidebarMergebotButton() { | |
| 23 | const navigate = useNavigate(); | |
| 24 | const threadRef = useParams({ strict: false, select: resolveThreadRouteRef }); | |
| 25 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 26 | const environment = useEnvironment(threadRef?.environmentId ?? primaryEnvironmentId); | |
| 27 | const configuration = environment?.serverConfig?.mergebot; | |
| 28 | const origin = configuration ? `${configuration.origin}/` : undefined; | |
| 29 | const canPreview = useEnvironmentScope(threadRef?.environmentId ?? null, AuthPreviewOperateScope); | |
| 30 | const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); | |
| 31 | const { isMobile, setOpenMobile } = useSidebar(); | |
| 32 | const busy = useRef(false); | |
| 33 | const [pending, setPending] = useState(false); | |
| 34 | const open = async () => { | |
| 35 | if (!origin || busy.current) return; | |
| 36 | busy.current = true; | |
| 37 | setPending(true); | |
| 38 | try { | |
| 39 | if (threadRef && canPreview) { | |
| 40 | const matching = Object.values(readThreadPreviewState(threadRef).sessions).find( | |
| 41 | (session) => session.navStatus._tag !== "Idle" && session.navStatus.url === origin, | |
| 42 | ); | |
| 43 | if (matching) useRightPanelStore.getState().openBrowser(threadRef, matching.tabId); | |
| 44 | else { | |
| 45 | const result = await openPreviewSession({ threadRef, url: origin, openPreview }); | |
| 46 | if (isAtomCommandInterrupted(result)) return; | |
| 47 | if (result._tag === "Failure") throw squashAtomCommandFailure(result); | |
| 48 | useRightPanelStore.getState().openBrowser(threadRef, result.value.tabId); | |
| 49 | } | |
| 50 | } else { | |
| 51 | const environmentId = threadRef?.environmentId ?? primaryEnvironmentId; | |
| 52 | if (!environmentId) return; | |
| 53 | await navigate({ | |
| 54 | to: "/pull-requests", | |
| 55 | search: { involvement: "all", state: "open", mergebot: environmentId }, | |
| 56 | }); | |
| 57 | } | |
| 58 | if (isMobile) setOpenMobile(false); | |
| 59 | } catch (error) { | |
| 60 | toastManager.add({ | |
| 61 | type: "error", | |
| 62 | title: "Could not open Mergebot", | |
| 63 | description: | |
| 64 | error instanceof Error ? error.message : "Try again when the environment is connected.", | |
| 65 | }); | |
| 66 | } finally { | |
| 67 | busy.current = false; | |
| 68 | setPending(false); | |
| 69 | } | |
| 70 | }; | |
| 71 | ||
| 72 | if (!origin) return null; | |
| 73 | ||
| 74 | return ( | |
| 75 | <SidebarMenuItem className="shrink-0"> | |
| 76 | <Tooltip> | |
| 77 | <TooltipTrigger | |
| 78 | render={ | |
| 79 | <SidebarMenuButton | |
| 80 | aria-label="Mergebot queue" | |
| 81 | aria-busy={pending} | |
| 82 | disabled={pending} | |
| 83 | onClick={() => void open()} | |
| 84 | size="icon" | |
| 85 | > | |
| 86 | <ListOrderedIcon aria-hidden="true" /> | |
| 87 | </SidebarMenuButton> | |
| 88 | } | |
| 89 | /> | |
| 90 | <TooltipPopup side="top">Mergebot queue</TooltipPopup> | |
| 91 | </Tooltip> | |
| 92 | </SidebarMenuItem> | |
| 93 | ); | |
| 94 | } |
apps/web/src/hooks/useOpenPanelPullRequestUrl.ts+20-6| ... | ... | @@ -1,5 +1,10 @@ |
| 1 | 1 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { EnvironmentId, ProjectId, type ScopedThreadRef } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | EnvironmentId, | |
| 4 | ProjectId, | |
| 5 | mergebotPullRequestUrl, | |
| 6 | type ScopedThreadRef, | |
| 7 | } from "@t3tools/contracts"; | |
| 3 | 8 | import { useMemo } from "react"; |
| 4 | 9 | |
| 5 | 10 | import { |
| ... | ... | @@ -9,7 +14,7 @@ import { |
| 9 | 14 | } from "../components/pullRequest/pullRequestDetail.logic"; |
| 10 | 15 | import { gitHubPullRequestBrowserUrl } from "../lib/openPullRequestLink"; |
| 11 | 16 | import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanelStore"; |
| 12 | import { useProject } from "../state/entities"; | |
| 17 | import { useProject, useServerConfigs } from "../state/entities"; | |
| 13 | 18 | import { pullRequestEnvironment } from "../state/pullRequests"; |
| 14 | 19 | import { useEnvironmentQuery } from "../state/query"; |
| 15 | 20 | import { useSupportsMultiplePullRequests } from "./useSupportsMultiplePullRequests"; |
| ... | ... | @@ -45,8 +50,13 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { |
| 45 | 50 | ) |
| 46 | 51 | : input; |
| 47 | 52 | }, [requestedReference, supportsMultiplePullRequests, project?.repositoryIdentity]); |
| 48 | const detail = useEnvironmentQuery( | |
| 53 | const configurations = useServerConfigs(); | |
| 54 | const mergebotUrl = | |
| 49 | 55 | reference && environmentId |
| 56 | ? mergebotPullRequestUrl(configurations.get(environmentId)?.mergebot, reference) | |
| 57 | : null; | |
| 58 | const detail = useEnvironmentQuery( | |
| 59 | reference && environmentId && !mergebotUrl | |
| 50 | 60 | ? pullRequestEnvironment.detail({ |
| 51 | 61 | environmentId, |
| 52 | 62 | input: reference, |
| ... | ... | @@ -64,13 +74,17 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { |
| 64 | 74 | : null, |
| 65 | 75 | [environmentId, reference], |
| 66 | 76 | ); |
| 67 | return reference | |
| 68 | ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference })?.url ?? | |
| 77 | return ( | |
| 78 | mergebotUrl ?? | |
| 79 | (reference | |
| 80 | ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference }) | |
| 81 | ?.url ?? | |
| 69 | 82 | requestedReference?.url ?? |
| 70 | 83 | gitHubPullRequestBrowserUrl( |
| 71 | 84 | project?.repositoryIdentity, |
| 72 | 85 | reference.repository, |
| 73 | 86 | reference.number, |
| 74 | 87 | )) |
| 75 | : undefined; | |
| 88 | : undefined) | |
| 89 | ); | |
| 76 | 90 | } |
apps/web/src/lib/openMergebotPullRequestLink.test.ts created+108| ... | ... | @@ -0,0 +1,108 @@ |
| 1 | import { beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 2 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | const state = vi.hoisted(() => ({ | |
| 5 | navigate: vi.fn(), | |
| 6 | panel: vi.fn(), | |
| 7 | hasProject: true, | |
| 8 | })); | |
| 9 | vi.mock("react", async (original) => ({ | |
| 10 | ...(await original<typeof import("react")>()), | |
| 11 | useCallback: (callback: unknown) => callback, | |
| 12 | useMemo: (fn: () => unknown) => fn(), | |
| 13 | })); | |
| 14 | vi.mock("../components/ui/toast", () => ({ | |
| 15 | toastManager: { add: vi.fn() }, | |
| 16 | stackedThreadToast: (value: unknown) => value, | |
| 17 | })); | |
| 18 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); | |
| 19 | vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate })); | |
| 20 | vi.mock("../browser/useOpenLink", () => ({ useOpenLink: () => vi.fn() })); | |
| 21 | vi.mock("../state/server", () => ({ serverEnvironment: {} })); | |
| 22 | vi.mock("../state/environments", () => ({ usePrimaryEnvironmentId: () => "env" })); | |
| 23 | vi.mock("../rightPanelStore", () => ({ | |
| 24 | useRightPanelStore: { getState: () => ({ openPullRequest: state.panel }) }, | |
| 25 | })); | |
| 26 | vi.mock("../state/entities", () => ({ | |
| 27 | readThreadShell: () => ({ projectId: "project" }), | |
| 28 | useProjects: () => | |
| 29 | state.hasProject | |
| 30 | ? [ | |
| 31 | { | |
| 32 | id: "project", | |
| 33 | environmentId: "env", | |
| 34 | repositoryIdentity: { | |
| 35 | provider: "github", | |
| 36 | canonicalKey: "github.com/work/app", | |
| 37 | locator: { | |
| 38 | source: "git-remote", | |
| 39 | remoteName: "origin", | |
| 40 | remoteUrl: "https://github.com/work/app.git", | |
| 41 | }, | |
| 42 | }, | |
| 43 | }, | |
| 44 | ] | |
| 45 | : [], | |
| 46 | useServerConfigs: () => | |
| 47 | new Map([ | |
| 48 | [ | |
| 49 | "env", | |
| 50 | { | |
| 51 | mergebot: { origin: "https://mergebot.example", repositories: ["Work/App"] }, | |
| 52 | environment: { capabilities: { pullRequests: true, threadPullRequests: true } }, | |
| 53 | }, | |
| 54 | ], | |
| 55 | ]), | |
| 56 | })); | |
| 57 | import { useOpenChangeRequestLink } from "./openPullRequestLink"; | |
| 58 | const ref = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; | |
| 59 | const event = () => ({ | |
| 60 | preventDefault: vi.fn(), | |
| 61 | stopPropagation: vi.fn(), | |
| 62 | metaKey: false, | |
| 63 | ctrlKey: false, | |
| 64 | }); | |
| 65 | beforeEach(() => { | |
| 66 | state.hasProject = true; | |
| 67 | state.navigate.mockReset(); | |
| 68 | state.panel.mockReset(); | |
| 69 | }); | |
| 70 | it.each(["https://github.com/work/app/pull/12", "https://mergebot.example/work/app/pull/12"])( | |
| 71 | "opens %s in the owned PR panel that embeds Mergebot", | |
| 72 | (url) => { | |
| 73 | expect(useOpenChangeRequestLink(ref)(event(), url)).toBe(true); | |
| 74 | expect(state.panel).toHaveBeenCalledWith( | |
| 75 | ref, | |
| 76 | expect.objectContaining({ | |
| 77 | projectId: ProjectId.make("project"), | |
| 78 | host: "github.com", | |
| 79 | repository: expect.stringMatching(/^work\/app$/i), | |
| 80 | number: 12, | |
| 81 | }), | |
| 82 | ); | |
| 83 | expect(state.navigate).not.toHaveBeenCalled(); | |
| 84 | }, | |
| 85 | ); | |
| 86 | it.each(["https://mergebot.example/work/app/pull/12", "https://github.com/work/app/pull/12"])( | |
| 87 | "opens %s in the PR page without inventing a project or conversation", | |
| 88 | (url) => { | |
| 89 | state.hasProject = false; | |
| 90 | expect(useOpenChangeRequestLink()(event(), url)).toBe(true); | |
| 91 | expect(state.navigate).toHaveBeenCalledWith({ | |
| 92 | to: "/pull-requests", | |
| 93 | search: expect.objectContaining({ | |
| 94 | mergebot: "env", | |
| 95 | repository: expect.stringMatching(/^work\/app$/i), | |
| 96 | number: 12, | |
| 97 | }), | |
| 98 | }); | |
| 99 | expect(state.panel).not.toHaveBeenCalled(); | |
| 100 | }, | |
| 101 | ); | |
| 102 | it("leaves lookalike Mergebot origins as ordinary links", () => { | |
| 103 | expect( | |
| 104 | useOpenChangeRequestLink(ref)(event(), "https://mergebot.example.evil/work/app/pull/12"), | |
| 105 | ).toBe(false); | |
| 106 | expect(state.panel).not.toHaveBeenCalled(); | |
| 107 | expect(state.navigate).not.toHaveBeenCalled(); | |
| 108 | }); |
apps/web/src/lib/openPullRequestLink.ts+46-4| ... | ... | @@ -1,4 +1,10 @@ |
| 1 | import type { EnvironmentId, PullRequestRef, ScopedThreadRef } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | parseMergebotPullRequestUrl, | |
| 3 | mergebotPullRequestUrl, | |
| 4 | type EnvironmentId, | |
| 5 | type PullRequestRef, | |
| 6 | type ScopedThreadRef, | |
| 7 | } from "@t3tools/contracts"; | |
| 2 | 8 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | 9 | import { useNavigate } from "@tanstack/react-router"; |
| 4 | 10 | import { type MouseEvent, useCallback, useMemo } from "react"; |
| ... | ... | @@ -12,7 +18,7 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast"; |
| 12 | 18 | import { useRightPanelStore } from "../rightPanelStore"; |
| 13 | 19 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 14 | 20 | |
| 15 | import { useProjects, useServerConfigs } from "../state/entities"; | |
| 21 | import { readThreadShell, useProjects, useServerConfigs } from "../state/entities"; | |
| 16 | 22 | import { serverEnvironment } from "../state/server"; |
| 17 | 23 | import { usePrimaryEnvironmentId } from "../state/environments"; |
| 18 | 24 | |
| ... | ... | @@ -150,7 +156,18 @@ export function useOpenChangeRequestLink( |
| 150 | 156 | if (shouldOpenPullRequestExternally(event)) return false; |
| 151 | 157 | const resolvedThreadRef = targetThreadRef ?? threadRef; |
| 152 | 158 | const resolvedPanelRef = panelRef ?? resolvedThreadRef; |
| 153 | const parsed = parseChangeRequestUrl(targetUrl); | |
| 159 | const requestedEnvironmentId = targetEnvironmentId ?? resolvedThreadRef?.environmentId; | |
| 160 | const hostReference = parseChangeRequestUrl(targetUrl); | |
| 161 | const mergebot = [...serverConfigs].flatMap(([environmentId, configuration]) => { | |
| 162 | if (requestedEnvironmentId && environmentId !== requestedEnvironmentId) return []; | |
| 163 | const reference = | |
| 164 | parseMergebotPullRequestUrl(configuration.mergebot, targetUrl) ?? | |
| 165 | (hostReference && mergebotPullRequestUrl(configuration.mergebot, hostReference) | |
| 166 | ? hostReference | |
| 167 | : null); | |
| 168 | return reference ? [{ environmentId, reference }] : []; | |
| 169 | })[0]; | |
| 170 | const parsed: ChangeRequestLink | null = mergebot?.reference ?? hostReference; | |
| 154 | 171 | if (parsed === null) return false; |
| 155 | 172 | const reads = (environmentId: string) => |
| 156 | 173 | serverConfigs.get(environmentId as EnvironmentId)?.environment.capabilities.pullRequests === |
| ... | ... | @@ -173,6 +190,9 @@ export function useOpenChangeRequestLink( |
| 173 | 190 | Number(left.environmentId === primaryEnvironmentId), |
| 174 | 191 | ); |
| 175 | 192 | const exactProject = findProjectForChangeRequest(projects, parsed); |
| 193 | const threadProjectId = resolvedThreadRef | |
| 194 | ? readThreadShell(resolvedThreadRef)?.projectId | |
| 195 | : undefined; | |
| 176 | 196 | const project = |
| 177 | 197 | exactProject ?? |
| 178 | 198 | (resolvedPanelRef |
| ... | ... | @@ -184,8 +204,30 @@ export function useOpenChangeRequestLink( |
| 184 | 204 | ), |
| 185 | 205 | parsed, |
| 186 | 206 | ) |
| 207 | : undefined) ?? | |
| 208 | (mergebot && resolvedThreadRef | |
| 209 | ? projects.find( | |
| 210 | (candidate) => | |
| 211 | candidate.environmentId === resolvedThreadRef.environmentId && | |
| 212 | candidate.id === threadProjectId, | |
| 213 | ) | |
| 187 | 214 | : undefined); |
| 188 | if (project === undefined || !reads(project.environmentId)) return false; | |
| 215 | if (project === undefined && mergebot) { | |
| 216 | event.preventDefault(); | |
| 217 | event.stopPropagation(); | |
| 218 | void navigate({ | |
| 219 | to: "/pull-requests", | |
| 220 | search: { | |
| 221 | involvement: "all", | |
| 222 | state: "all", | |
| 223 | mergebot: mergebot.environmentId, | |
| 224 | repository: parsed.repository, | |
| 225 | number: parsed.number, | |
| 226 | }, | |
| 227 | }); | |
| 228 | return true; | |
| 229 | } | |
| 230 | if (project === undefined || (!mergebot && !reads(project.environmentId))) return false; | |
| 189 | 231 | const repository = |
| 190 | 232 | serverConfigs.get(project.environmentId)?.environment.capabilities.threadPullRequests === |
| 191 | 233 | true |
apps/web/src/rightPanelStore.test.ts+51| ... | ... | @@ -1240,3 +1240,54 @@ describe("rightPanelStore", () => { |
| 1240 | 1240 | expect(store.getUserActionRevision(refA)).toBe(revision); |
| 1241 | 1241 | }); |
| 1242 | 1242 | }); |
| 1243 | ||
| 1244 | describe("Mergebot browser handoff", () => { | |
| 1245 | it("atomically replaces a PR with its canonical browser while preserving the linked list", () => { | |
| 1246 | const store = useRightPanelStore.getState(); | |
| 1247 | const pr = pullRequestSurface({ | |
| 1248 | projectId: "project", | |
| 1249 | host: "github.com", | |
| 1250 | repository: "work/app", | |
| 1251 | number: 1, | |
| 1252 | }); | |
| 1253 | store.open(refA, "pull-requests"); | |
| 1254 | store.openPullRequest(refA, pr); | |
| 1255 | store.reconcileBrowserSurfaces(refA, ["mergebot"]); | |
| 1256 | const revision = store.getUserActionRevision(refA); | |
| 1257 | ||
| 1258 | expect(store.replacePullRequestWithBrowser(refA, pr.id, "mergebot")).toBe(true); | |
| 1259 | store.reconcileBrowserSurfaces(refA, ["mergebot"]); | |
| 1260 | ||
| 1261 | const panel = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); | |
| 1262 | expect(panel.surfaces.map((surface) => surface.id)).toEqual([ | |
| 1263 | "pull-requests", | |
| 1264 | "browser:mergebot", | |
| 1265 | ]); | |
| 1266 | expect(panel.activeSurfaceId).toBe("browser:mergebot"); | |
| 1267 | expect(store.getUserActionRevision(refA)).toBe(revision); | |
| 1268 | store.closeSurface(refA, "browser:mergebot"); | |
| 1269 | store.reconcileBrowserSurfaces(refA, []); | |
| 1270 | expect( | |
| 1271 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map( | |
| 1272 | (surface) => surface.id, | |
| 1273 | ), | |
| 1274 | ).toEqual(["pull-requests"]); | |
| 1275 | }); | |
| 1276 | it("rejects a delayed handoff after the user closes its PR and preserves another active surface", () => { | |
| 1277 | const store = useRightPanelStore.getState(); | |
| 1278 | const pr = pullRequestSurface({ projectId: "project", repository: "work/app", number: 1 }); | |
| 1279 | store.openPullRequest(refA, pr); | |
| 1280 | store.open(refA, "files"); | |
| 1281 | expect(store.replacePullRequestWithBrowser(refA, pr.id, "first")).toBe(true); | |
| 1282 | expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)?.id).toBe( | |
| 1283 | "files", | |
| 1284 | ); | |
| 1285 | store.closeSurface(refA, "browser:first"); | |
| 1286 | expect(store.replacePullRequestWithBrowser(refA, pr.id, "late")).toBe(false); | |
| 1287 | expect( | |
| 1288 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map( | |
| 1289 | (surface) => surface.id, | |
| 1290 | ), | |
| 1291 | ).toEqual(["files"]); | |
| 1292 | }); | |
| 1293 | }); |
apps/web/src/rightPanelStore.ts+29| ... | ... | @@ -151,6 +151,11 @@ interface RightPanelStoreState { |
| 151 | 151 | openDevice: (ref: ScopedThreadRef, target: DeviceTabTarget, automatic?: boolean) => void; |
| 152 | 152 | renameDevice: (ref: ScopedThreadRef, surfaceId: string, title: string) => void; |
| 153 | 153 | openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; |
| 154 | replacePullRequestWithBrowser: ( | |
| 155 | ref: ScopedThreadRef, | |
| 156 | surfaceId: string, | |
| 157 | tabId: string, | |
| 158 | ) => boolean; | |
| 154 | 159 | openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void; |
| 155 | 160 | openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void; |
| 156 | 161 | openPullRequest: ( |
| ... | ... | @@ -692,6 +697,30 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 692 | 697 | return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); |
| 693 | 698 | }), |
| 694 | 699 | ), |
| 700 | replacePullRequestWithBrowser: (ref, surfaceId, tabId) => { | |
| 701 | let replaced = false; | |
| 702 | set((state) => | |
| 703 | automaticUpdate(state, scopedThreadKey(ref), (current) => { | |
| 704 | if ( | |
| 705 | !current.surfaces.some( | |
| 706 | (surface) => surface.id === surfaceId && surface.kind === "pull-request", | |
| 707 | ) | |
| 708 | ) | |
| 709 | return current; | |
| 710 | replaced = true; | |
| 711 | const browser = browserSurface(tabId); | |
| 712 | return { | |
| 713 | ...current, | |
| 714 | surfaces: current.surfaces.flatMap((surface) => | |
| 715 | surface.id === surfaceId ? [browser] : surface.id === browser.id ? [] : [surface], | |
| 716 | ), | |
| 717 | activeSurfaceId: | |
| 718 | current.activeSurfaceId === surfaceId ? browser.id : current.activeSurfaceId, | |
| 719 | }; | |
| 720 | }), | |
| 721 | ); | |
| 722 | return replaced; | |
| 723 | }, | |
| 695 | 724 | openPullRequest: (ref, target) => |
| 696 | 725 | set((state) => |
| 697 | 726 | userAction(state, scopedThreadKey(ref), (current) => { |
apps/web/src/routes/_chat.pull-requests.tsx+128-19| ... | ... | @@ -1,3 +1,14 @@ |
| 1 | import { | |
| 2 | MERGEBOT_BROWSER_THREAD_ID, | |
| 3 | ThreadId, | |
| 4 | mergebotPullRequestUrl, | |
| 5 | mergebotPullRequestBrowserThreadId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 8 | import { MergebotBrowserPanel } from "../components/pullRequest/MergebotBrowserPanel"; | |
| 9 | import { clearThreadPreviewState } from "../previewStateStore"; | |
| 10 | import { previewEnvironment } from "../state/preview"; | |
| 11 | import { useEnvironment } from "../state/environments"; | |
| 1 | 12 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | 13 | import { Spinner } from "~/components/ui/spinner"; |
| 3 | 14 | import { useShortcutModifierState } from "~/shortcutModifierState"; |
| ... | ... | @@ -153,6 +164,7 @@ import { |
| 153 | 164 | selectThreadRightPanelState, |
| 154 | 165 | useRightPanelStore, |
| 155 | 166 | type PullRequestSurface, |
| 167 | type RightPanelSurface, | |
| 156 | 168 | } from "../rightPanelStore"; |
| 157 | 169 | import { useDebouncedValue } from "../state/queries"; |
| 158 | 170 | import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities"; |
| ... | ... | @@ -185,6 +197,7 @@ function getShortcutContext() { |
| 185 | 197 | } |
| 186 | 198 | |
| 187 | 199 | export interface PullRequestsSearch extends PullRequestListPreferences { |
| 200 | readonly mergebot?: EnvironmentId; | |
| 188 | 201 | /** |
| 189 | 202 | * Narrows the list to one server. Absent means every connected one, which is the default the |
| 190 | 203 | * 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<Pick<PullRequestsSearch, |
| 306 | 319 | |
| 307 | 320 | export const Route = createFileRoute("/_chat/pull-requests")({ |
| 308 | 321 | validateSearch: (raw: Record<string, unknown>): PullRequestsSearch => ({ |
| 322 | ...(typeof raw.mergebot === "string" && raw.mergebot | |
| 323 | ? { mergebot: raw.mergebot as EnvironmentId } | |
| 324 | : {}), | |
| 309 | 325 | involvement: |
| 310 | 326 | raw.involvement === "reviewing" || raw.involvement === "authored" ? raw.involvement : "all", |
| 311 | 327 | state: |
| ... | ... | @@ -356,6 +372,50 @@ export const Route = createFileRoute("/_chat/pull-requests")({ |
| 356 | 372 | |
| 357 | 373 | function PullRequestsRouteView() { |
| 358 | 374 | useEscapeToGoBack(); |
| 375 | const search = Route.useSearch(); | |
| 376 | const environment = useEnvironment(search.mergebot ?? null); | |
| 377 | const navigate = useNavigate({ from: Route.fullPath }); | |
| 378 | if (!search.mergebot) return <NativePullRequestsRouteView />; | |
| 379 | const configuration = environment?.serverConfig?.mergebot; | |
| 380 | const url = | |
| 381 | configuration && search.repository && search.number | |
| 382 | ? mergebotPullRequestUrl(configuration, { | |
| 383 | repository: search.repository, | |
| 384 | number: search.number, | |
| 385 | }) | |
| 386 | : configuration | |
| 387 | ? `${configuration.origin}/` | |
| 388 | : undefined; | |
| 389 | return ( | |
| 390 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none"> | |
| 391 | <WorkspacePageHeader electron={isElectron} reserveNativeControls> | |
| 392 | <Button | |
| 393 | variant="ghost" | |
| 394 | size="sm" | |
| 395 | onClick={() => void navigate({ search: { involvement: "all", state: "open" } })} | |
| 396 | > | |
| 397 | Pull requests | |
| 398 | </Button> | |
| 399 | <h1 className="truncate text-sm">Mergebot</h1> | |
| 400 | </WorkspacePageHeader> | |
| 401 | {url ? ( | |
| 402 | <MergebotBrowserPanel | |
| 403 | key={`${search.mergebot}:${url}`} | |
| 404 | threadRef={scopeThreadRef(search.mergebot, ThreadId.make(MERGEBOT_BROWSER_THREAD_ID))} | |
| 405 | url={url} | |
| 406 | /> | |
| 407 | ) : ( | |
| 408 | <PullRequestsUnavailableState | |
| 409 | title="Mergebot unavailable" | |
| 410 | error="Connect to the configured environment to open Mergebot." | |
| 411 | /> | |
| 412 | )} | |
| 413 | </SidebarInset> | |
| 414 | ); | |
| 415 | } | |
| 416 | ||
| 417 | function NativePullRequestsRouteView() { | |
| 418 | const closeMergebotPreview = useAtomCommand(previewEnvironment.close); | |
| 359 | 419 | const modifiers = useShortcutModifierState(true); |
| 360 | 420 | const speedMode = |
| 361 | 421 | modifiers.shiftKey && !modifiers.metaKey && !modifiers.ctrlKey && !modifiers.altKey; |
| ... | ... | @@ -2118,6 +2178,28 @@ function PullRequestsRouteView() { |
| 2118 | 2178 | searchInput, |
| 2119 | 2179 | sortMenu, |
| 2120 | 2180 | filtersMenu, |
| 2181 | mergebotControl: environments | |
| 2182 | .filter( | |
| 2183 | (environment) => | |
| 2184 | environment.serverConfig?.mergebot && | |
| 2185 | (scopedEnvironmentId === null || environment.environmentId === scopedEnvironmentId), | |
| 2186 | ) | |
| 2187 | .map((environment) => ( | |
| 2188 | <Button | |
| 2189 | key={environment.environmentId} | |
| 2190 | variant="outline" | |
| 2191 | size="sm" | |
| 2192 | onClick={() => | |
| 2193 | void navigate({ | |
| 2194 | search: { involvement: "all", state: "open", mergebot: environment.environmentId }, | |
| 2195 | }) | |
| 2196 | } | |
| 2197 | > | |
| 2198 | {environments.filter((entry) => entry.serverConfig?.mergebot).length > 1 | |
| 2199 | ? `Mergebot · ${environment.label}` | |
| 2200 | : "Mergebot"} | |
| 2201 | </Button> | |
| 2202 | )), | |
| 2121 | 2203 | rightPanelControl: |
| 2122 | 2204 | // Footprint reserve while the panel is closed: the toggle itself stays |
| 2123 | 2205 | // mounted at the fixed titlebar inset in both states so it cannot move |
| ... | ... | @@ -2165,34 +2247,51 @@ function PullRequestsRouteView() { |
| 2165 | 2247 | useRightPanelStore.getState().activateSurface(rightPanelRef, surface.id); |
| 2166 | 2248 | selectSurfaceInUrl(surface); |
| 2167 | 2249 | }; |
| 2168 | const closeSurface = (surface: PullRequestSurface) => { | |
| 2250 | const closeSurfaces = async (surfaces: readonly RightPanelSurface[]) => { | |
| 2169 | 2251 | if (rightPanelRef === null) return; |
| 2170 | useRightPanelStore.getState().closeSurface(rightPanelRef, surface.id); | |
| 2252 | for (const surface of surfaces) | |
| 2253 | useRightPanelStore.getState().closeSurface(rightPanelRef, surface.id); | |
| 2254 | const closeRevision = useRightPanelStore.getState().getUserActionRevision(rightPanelRef); | |
| 2171 | 2255 | const next = selectActiveRightPanelSurface( |
| 2172 | 2256 | useRightPanelStore.getState().byThreadKey, |
| 2173 | 2257 | rightPanelRef, |
| 2174 | 2258 | ); |
| 2175 | 2259 | selectSurfaceInUrl(next?.kind === "pull-request" ? next : null); |
| 2260 | ||
| 2261 | for (const surface of surfaces) { | |
| 2262 | if (surface.kind !== "pull-request") continue; | |
| 2263 | const environmentId = | |
| 2264 | (surface.environmentId as EnvironmentId | undefined) ?? panelEnvironmentId; | |
| 2265 | if (!environmentId) continue; | |
| 2266 | const configuration = environments.find( | |
| 2267 | (environment) => environment.environmentId === environmentId, | |
| 2268 | )?.serverConfig?.mergebot; | |
| 2269 | if (!mergebotPullRequestUrl(configuration, surface)) continue; | |
| 2270 | const owner = scopeThreadRef( | |
| 2271 | environmentId, | |
| 2272 | ThreadId.make(mergebotPullRequestBrowserThreadId(surface)), | |
| 2273 | ); | |
| 2274 | const result = await closeMergebotPreview({ | |
| 2275 | environmentId, | |
| 2276 | input: { threadId: owner.threadId }, | |
| 2277 | }); | |
| 2278 | if (result._tag === "Failure") { | |
| 2279 | const panels = useRightPanelStore.getState(); | |
| 2280 | if (panels.openProactive(rightPanelRef, surface, closeRevision)) | |
| 2281 | selectSurfaceInUrl(surface); | |
| 2282 | } else { | |
| 2283 | clearThreadPreviewState(owner); | |
| 2284 | } | |
| 2285 | } | |
| 2176 | 2286 | }; |
| 2177 | const closeOtherSurfaces = (surface: PullRequestSurface) => { | |
| 2178 | if (rightPanelRef === null) return; | |
| 2179 | useRightPanelStore.getState().closeOtherSurfaces(rightPanelRef, surface.id); | |
| 2180 | selectSurfaceInUrl(surface); | |
| 2181 | }; | |
| 2287 | const closeSurface = (surface: PullRequestSurface) => void closeSurfaces([surface]); | |
| 2288 | const closeOtherSurfaces = (surface: PullRequestSurface) => | |
| 2289 | void closeSurfaces(rightPanelState.surfaces.filter((entry) => entry.id !== surface.id)); | |
| 2182 | 2290 | const closeSurfacesToRight = (surface: PullRequestSurface) => { |
| 2183 | if (rightPanelRef === null) return; | |
| 2184 | useRightPanelStore.getState().closeSurfacesToRight(rightPanelRef, surface.id); | |
| 2185 | const next = selectActiveRightPanelSurface( | |
| 2186 | useRightPanelStore.getState().byThreadKey, | |
| 2187 | rightPanelRef, | |
| 2188 | ); | |
| 2189 | selectSurfaceInUrl(next?.kind === "pull-request" ? next : null); | |
| 2190 | }; | |
| 2191 | const closeAllSurfaces = () => { | |
| 2192 | if (rightPanelRef === null) return; | |
| 2193 | useRightPanelStore.getState().closeAllSurfaces(rightPanelRef); | |
| 2194 | selectSurfaceInUrl(null); | |
| 2291 | const index = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id); | |
| 2292 | if (index >= 0) void closeSurfaces(rightPanelState.surfaces.slice(index + 1)); | |
| 2195 | 2293 | }; |
| 2294 | const closeAllSurfaces = () => void closeSurfaces(rightPanelState.surfaces); | |
| 2196 | 2295 | |
| 2197 | 2296 | // This page has no ChatView, so it handles the shared panel shortcuts itself. |
| 2198 | 2297 | const copyPullRequestFromShortcut = useEffectEvent((event: KeyboardEvent) => { |
| ... | ... | @@ -2314,6 +2413,13 @@ function PullRequestsRouteView() { |
| 2314 | 2413 | shortcutsEnabled={activePullRequestSurface?.id === renderedPullRequestSurface.id} |
| 2315 | 2414 | key={renderedPullRequestSurface.id} |
| 2316 | 2415 | environmentId={panelEnvironmentId} |
| 2416 | onMergebotReady={() => | |
| 2417 | rightPanelRef !== null && | |
| 2418 | selectThreadRightPanelState( | |
| 2419 | useRightPanelStore.getState().byThreadKey, | |
| 2420 | rightPanelRef, | |
| 2421 | ).surfaces.some((surface) => surface.id === renderedPullRequestSurface.id) | |
| 2422 | } | |
| 2317 | 2423 | onGuideShown={onGuideShown} |
| 2318 | 2424 | onSelectPullRequest={(reference) => { |
| 2319 | 2425 | if (rightPanelRef === null) return; |
| ... | ... | @@ -2574,6 +2680,7 @@ function PullRequestsColumn({ |
| 2574 | 2680 | searchInput, |
| 2575 | 2681 | sortMenu, |
| 2576 | 2682 | filtersMenu, |
| 2683 | mergebotControl, | |
| 2577 | 2684 | rightPanelControl, |
| 2578 | 2685 | titlebarControls, |
| 2579 | 2686 | rightPanelOpen, |
| ... | ... | @@ -2594,6 +2701,7 @@ function PullRequestsColumn({ |
| 2594 | 2701 | searchInput: ReactNode; |
| 2595 | 2702 | sortMenu: ReactNode; |
| 2596 | 2703 | filtersMenu: ReactNode; |
| 2704 | mergebotControl: ReactNode; | |
| 2597 | 2705 | rightPanelControl: ReactNode; |
| 2598 | 2706 | titlebarControls: ReactNode; |
| 2599 | 2707 | rightPanelOpen: boolean; |
| ... | ... | @@ -2752,6 +2860,7 @@ function PullRequestsColumn({ |
| 2752 | 2860 | </div> |
| 2753 | 2861 | {sortMenu} |
| 2754 | 2862 | {filtersMenu} |
| 2863 | {mergebotControl} | |
| 2755 | 2864 | <CompactFilterMenu |
| 2756 | 2865 | label="Filter by provider" |
| 2757 | 2866 | outlined |
apps/web/src/state/mergebot.test.tsx created+102| ... | ... | @@ -0,0 +1,102 @@ |
| 1 | import { RegistryContext } from "@effect/atom-react"; | |
| 2 | import { EnvironmentId, type MergebotRepoQueue } from "@t3tools/contracts"; | |
| 3 | import * as Cause from "effect/Cause"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | import { AsyncResult, AtomRegistry } from "effect/reactivity"; | |
| 6 | import { act, useLayoutEffect } from "react"; | |
| 7 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 8 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 9 | ||
| 10 | import { useMergebotQueue } from "./mergebot"; | |
| 11 | ||
| 12 | const state = await vi.hoisted(async () => { | |
| 13 | const { AsyncResult, Atom } = await import("effect/reactivity"); | |
| 14 | const snapshot: MergebotRepoQueue = { | |
| 15 | repo: "work/app", | |
| 16 | url: "https://mergebot.example/work/app", | |
| 17 | status: "ready", | |
| 18 | processing: true, | |
| 19 | pullRequests: [ | |
| 20 | { | |
| 21 | number: 123, | |
| 22 | headSha: "head", | |
| 23 | lifecycle: "open", | |
| 24 | queueStatus: "waiting_ci", | |
| 25 | position: 1, | |
| 26 | mergeEtaAt: null, | |
| 27 | url: "https://mergebot.example/work/app/pull/123", | |
| 28 | }, | |
| 29 | ], | |
| 30 | }; | |
| 31 | return { | |
| 32 | snapshot, | |
| 33 | queue: Atom.make<AsyncResult.AsyncResult<MergebotRepoQueue | null, Error>>( | |
| 34 | AsyncResult.success(snapshot), | |
| 35 | ), | |
| 36 | connection: Atom.make(AsyncResult.success({ phase: "connected" })), | |
| 37 | }; | |
| 38 | }); | |
| 39 | ||
| 40 | vi.mock("@t3tools/client-runtime/state/pull-requests", () => ({ | |
| 41 | createMergebotQueueAtomFamily: () => () => state.queue, | |
| 42 | })); | |
| 43 | vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: undefined })); | |
| 44 | vi.mock("../connection/catalog", () => ({ | |
| 45 | environmentCatalog: { stateAtom: () => state.connection }, | |
| 46 | })); | |
| 47 | ||
| 48 | let registry: AtomRegistry.AtomRegistry; | |
| 49 | let renderer: ReactTestRenderer; | |
| 50 | let observed: MergebotRepoQueue | null; | |
| 51 | ||
| 52 | function Probe() { | |
| 53 | const value = useMergebotQueue(EnvironmentId.make("local"), "work/app"); | |
| 54 | useLayoutEffect(() => { | |
| 55 | observed = value; | |
| 56 | }); | |
| 57 | return null; | |
| 58 | } | |
| 59 | ||
| 60 | beforeEach(() => { | |
| 61 | registry = AtomRegistry.make(); | |
| 62 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 63 | act(() => { | |
| 64 | renderer = create( | |
| 65 | <RegistryContext.Provider value={registry}> | |
| 66 | <Probe /> | |
| 67 | </RegistryContext.Provider>, | |
| 68 | ); | |
| 69 | }); | |
| 70 | }); | |
| 71 | ||
| 72 | afterEach(() => { | |
| 73 | act(() => renderer.unmount()); | |
| 74 | registry.dispose(); | |
| 75 | vi.unstubAllGlobals(); | |
| 76 | }); | |
| 77 | ||
| 78 | it("keeps a same-query success visible during a routine refresh", () => { | |
| 79 | expect(observed).toEqual(state.snapshot); | |
| 80 | act(() => registry.set(state.queue, AsyncResult.success(state.snapshot, { waiting: true }))); | |
| 81 | expect(observed).toEqual(state.snapshot); | |
| 82 | }); | |
| 83 | ||
| 84 | it("drops a failed refresh even when AsyncResult retains the prior successful snapshot", () => { | |
| 85 | const failure = AsyncResult.failureWithPrevious(Cause.fail(new Error("offline")), { | |
| 86 | previous: Option.some(AsyncResult.success(state.snapshot)), | |
| 87 | }); | |
| 88 | expect(Option.isSome(AsyncResult.value(failure))).toBe(true); | |
| 89 | act(() => registry.set(state.queue, failure)); | |
| 90 | expect(observed).toBeNull(); | |
| 91 | }); | |
| 92 | ||
| 93 | it("clears on disconnection and on a completed unavailable response", () => { | |
| 94 | act(() => registry.set(state.queue, AsyncResult.success(state.snapshot, { waiting: true }))); | |
| 95 | act(() => registry.set(state.connection, AsyncResult.success({ phase: "backoff" }))); | |
| 96 | expect(observed).toBeNull(); | |
| 97 | act(() => { | |
| 98 | registry.set(state.connection, AsyncResult.success({ phase: "connected" })); | |
| 99 | registry.set(state.queue, AsyncResult.success(null)); | |
| 100 | }); | |
| 101 | expect(observed).toBeNull(); | |
| 102 | }); |
apps/web/src/state/mergebot.ts created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { createMergebotQueueAtomFamily } from "@t3tools/client-runtime/state/pull-requests"; | |
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 4 | import { environmentCatalog } from "../connection/catalog"; | |
| 5 | import { connectionAtomRuntime } from "../connection/runtime"; | |
| 6 | ||
| 7 | const mergebotQueue = createMergebotQueueAtomFamily(connectionAtomRuntime); | |
| 8 | ||
| 9 | export function useMergebotQueue(environmentId: EnvironmentId, repository: string) { | |
| 10 | const result = useAtomValue(mergebotQueue({ environmentId, input: { repository } })); | |
| 11 | const connection = useAtomValue(environmentCatalog.stateAtom(environmentId)); | |
| 12 | if (connection._tag !== "Success" || connection.value.phase !== "connected") return null; | |
| 13 | return result._tag === "Success" ? result.value : null; | |
| 14 | } |
packages/client-runtime/src/rpc/client.ts+1| ... | ... | @@ -68,6 +68,7 @@ export type EnvironmentSubscriptionRpcTag = |
| 68 | 68 | | typeof WS_METHODS.subscribeDeviceState |
| 69 | 69 | | typeof WS_METHODS.subscribeResourceTelemetry |
| 70 | 70 | | typeof WS_METHODS.pullRequestsSubscribeRefreshes |
| 71 | | typeof WS_METHODS.mergebotSubscribe | |
| 71 | 72 | | typeof WS_METHODS.subscribeVcsStatus |
| 72 | 73 | | typeof WS_METHODS.subscribeWorktreeSetup |
| 73 | 74 | | typeof WS_METHODS.subscribeProjectClones |
packages/client-runtime/src/state/pullRequests.test.ts+44| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | PullRequestOperationError, |
| 10 | 10 | WS_METHODS, |
| 11 | 11 | type PullRequestStack, |
| 12 | type MergebotRepoQueue, | |
| 12 | 13 | type AuthSessionState, |
| 13 | 14 | } from "@t3tools/contracts"; |
| 14 | 15 | import { expect, it } from "@effect/vitest"; |
| ... | ... | @@ -43,6 +44,7 @@ import { |
| 43 | 44 | createLinkedPullRequestSummaryAtomFamily, |
| 44 | 45 | createPullRequestEnvironmentAtoms, |
| 45 | 46 | createPullRequestStackAtomFamily, |
| 47 | createMergebotQueueAtomFamily, | |
| 46 | 48 | } from "./pullRequests.ts"; |
| 47 | 49 | import * as PullRequestDiffLoader from "./pullRequestDiffHttp.ts"; |
| 48 | 50 | import { executeAtomQuery } from "./runtime.ts"; |
| ... | ... | @@ -690,6 +692,48 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* ( |
| 690 | 692 | return { runtime, atoms, registry, environmentRegistry, supervisor, localSupervisor }; |
| 691 | 693 | }); |
| 692 | 694 | |
| 695 | it.effect("updates Mergebot badges from the stream and clears disconnected snapshots", () => | |
| 696 | Effect.scoped( | |
| 697 | Effect.gen(function* () { | |
| 698 | const snapshots = yield* SubscriptionRef.make<MergebotRepoQueue | null>({ | |
| 699 | repo: "acme/web", | |
| 700 | url: "https://queue.example.test", | |
| 701 | status: "ready", | |
| 702 | processing: false, | |
| 703 | pullRequests: [], | |
| 704 | }); | |
| 705 | const client = { | |
| 706 | [WS_METHODS.mergebotSubscribe]: () => SubscriptionRef.changes(snapshots), | |
| 707 | [WS_METHODS.mergebotQueue]: () => Effect.die("live badges must not poll"), | |
| 708 | } as unknown as WsRpcProtocolClient; | |
| 709 | const { runtime, registry } = yield* makeTestRuntime(client); | |
| 710 | const queue = createMergebotQueueAtomFamily(runtime)({ | |
| 711 | environmentId: TARGET.environmentId, | |
| 712 | input: { repository: "acme/web" }, | |
| 713 | }); | |
| 714 | registry.mount(queue); | |
| 715 | expect(yield* AtomRegistry.getResult(registry, queue)).toMatchObject({ processing: false }); | |
| 716 | ||
| 717 | const changed = yield* Deferred.make<void>(); | |
| 718 | const disconnected = yield* Deferred.make<void>(); | |
| 719 | const unsubscribe = registry.subscribe(queue, (result) => { | |
| 720 | if (!AsyncResult.isSuccess(result)) return; | |
| 721 | if (result.value?.processing) Deferred.doneUnsafe(changed, Effect.void); | |
| 722 | if (result.value === null) Deferred.doneUnsafe(disconnected, Effect.void); | |
| 723 | }); | |
| 724 | yield* SubscriptionRef.update(snapshots, (snapshot) => | |
| 725 | snapshot === null ? null : { ...snapshot, processing: true }, | |
| 726 | ); | |
| 727 | yield* Deferred.await(changed); | |
| 728 | expect(yield* AtomRegistry.getResult(registry, queue)).toMatchObject({ processing: true }); | |
| 729 | yield* SubscriptionRef.set(snapshots, null); | |
| 730 | yield* Deferred.await(disconnected); | |
| 731 | expect(yield* AtomRegistry.getResult(registry, queue)).toBeNull(); | |
| 732 | unsubscribe(); | |
| 733 | }), | |
| 734 | ), | |
| 735 | ); | |
| 736 | ||
| 693 | 737 | it.effect.each(["default", "origin-off", "destination-off", "read-only"] as const)( |
| 694 | 738 | "does not probe another environment with %s routing permission", |
| 695 | 739 | (permission) => |
packages/client-runtime/src/state/pullRequests.ts+9| ... | ... | @@ -619,3 +619,12 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 619 | 619 | }), |
| 620 | 620 | }; |
| 621 | 621 | } |
| 622 | ||
| 623 | export function createMergebotQueueAtomFamily<R, E>( | |
| 624 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | |
| 625 | ) { | |
| 626 | return createEnvironmentRpcSubscriptionAtomFamily(runtime, { | |
| 627 | label: "mergebot:queue", | |
| 628 | tag: WS_METHODS.mergebotSubscribe, | |
| 629 | }); | |
| 630 | } |
packages/contracts/src/index.ts+2-1| ... | ... | @@ -65,8 +65,9 @@ export * from "./rpc.ts"; |
| 65 | 65 | export * from "./worktreeSetup.ts"; |
| 66 | 66 | export * from "./secretRequest.ts"; |
| 67 | 67 | export * from "./clientRpcPermissions.ts"; |
| 68 | export * from "./desktopTerminal.ts"; | |
| 68 | export * from "./mergebot.ts"; | |
| 69 | 69 | export * from "./scriptProgress.ts"; |
| 70 | export * from "./desktopTerminal.ts"; | |
| 70 | 71 | |
| 71 | 72 | export * from "./uiSettings.ts"; |
| 72 | 73 | export * from "./sharedAccounts.ts"; |
packages/contracts/src/mergebot.test.ts created+158| ... | ... | @@ -0,0 +1,158 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | import * as Struct from "effect/Struct"; | |
| 3 | import { describe, expect, it } from "vite-plus/test"; | |
| 4 | import { | |
| 5 | MergebotPullRequestDetails, | |
| 6 | isMergebotRepository, | |
| 7 | mergebotPullRequestStatus, | |
| 8 | mergebotPullRequestUrl, | |
| 9 | mergebotPullRequestBrowserThreadId, | |
| 10 | parseMergebotPullRequestUrl, | |
| 11 | type MergebotRepoQueue, | |
| 12 | } from "./mergebot.ts"; | |
| 13 | ||
| 14 | const decodeDetails = Schema.decodeSync(MergebotPullRequestDetails); | |
| 15 | ||
| 16 | const entry = { | |
| 17 | number: 123, | |
| 18 | headSha: "current-head", | |
| 19 | lifecycle: "open", | |
| 20 | queueStatus: "waiting_ci", | |
| 21 | position: 1, | |
| 22 | mergeEtaAt: null, | |
| 23 | url: "https://mergebot.example/work/app/pull/123", | |
| 24 | } as const; | |
| 25 | const queue: MergebotRepoQueue = { | |
| 26 | repo: "work/app", | |
| 27 | status: "ready", | |
| 28 | url: "https://mergebot.example/work/app", | |
| 29 | processing: true, | |
| 30 | pullRequests: [entry], | |
| 31 | }; | |
| 32 | ||
| 33 | describe("mergebotPullRequestStatus", () => { | |
| 34 | it("uses Mergebot's verified head unless a known T3 head disagrees", () => { | |
| 35 | expect(mergebotPullRequestStatus(queue, 123)).toEqual(entry); | |
| 36 | expect(mergebotPullRequestStatus(queue, 123, "current-head")).toEqual(entry); | |
| 37 | expect(mergebotPullRequestStatus(queue, 123, "new-head")).toBeNull(); | |
| 38 | }); | |
| 39 | ||
| 40 | it("keeps absent PRs, unknown queue heads and loading snapshots unknown", () => { | |
| 41 | expect(mergebotPullRequestStatus(queue, 456)).toBeNull(); | |
| 42 | expect(mergebotPullRequestStatus(null, 123)).toBeNull(); | |
| 43 | expect( | |
| 44 | mergebotPullRequestStatus({ ...queue, status: "loading", pullRequests: null }, 123), | |
| 45 | ).toBeNull(); | |
| 46 | expect( | |
| 47 | mergebotPullRequestStatus({ ...queue, pullRequests: [{ ...entry, queueStatus: null }] }, 123), | |
| 48 | ).toBeNull(); | |
| 49 | }); | |
| 50 | }); | |
| 51 | ||
| 52 | const configuration = { | |
| 53 | origin: "https://mergebot.example", | |
| 54 | repositories: ["work/app", "work/research.sh", "work/research"], | |
| 55 | }; | |
| 56 | ||
| 57 | it("gives each standalone PR its own browser owner with case-insensitive repository identity", () => { | |
| 58 | const owner = mergebotPullRequestBrowserThreadId({ repository: "Work/App", number: 12 }); | |
| 59 | expect(owner).toBe(mergebotPullRequestBrowserThreadId({ repository: "work/app", number: 12 })); | |
| 60 | expect(owner).not.toBe( | |
| 61 | mergebotPullRequestBrowserThreadId({ repository: "work/app", number: 13 }), | |
| 62 | ); | |
| 63 | expect(owner).not.toBe( | |
| 64 | mergebotPullRequestBrowserThreadId({ repository: "work/other", number: 12 }), | |
| 65 | ); | |
| 66 | }); | |
| 67 | ||
| 68 | describe("isMergebotRepository", () => { | |
| 69 | it("scopes native badges to configured work repositories", () => { | |
| 70 | expect(isMergebotRepository(undefined, "work/app")).toBe(false); | |
| 71 | expect(isMergebotRepository(configuration, "WORK/APP")).toBe(true); | |
| 72 | expect(isMergebotRepository(configuration, "work/app")).toBe(true); | |
| 73 | expect(isMergebotRepository(configuration, "work/research.sh")).toBe(true); | |
| 74 | expect(isMergebotRepository(configuration, "work/research")).toBe(true); | |
| 75 | expect(isMergebotRepository(configuration, "work/mergebot-test")).toBe(false); | |
| 76 | expect(isMergebotRepository(configuration, "another/app")).toBe(false); | |
| 77 | expect(isMergebotRepository(configuration, "work/app/extra")).toBe(false); | |
| 78 | }); | |
| 79 | }); | |
| 80 | ||
| 81 | describe("MergebotPullRequestDetails", () => { | |
| 82 | it("preserves check rerun identity and lifecycle timestamps without inventing absent dates", () => { | |
| 83 | const details = { | |
| 84 | ...entry, | |
| 85 | title: "Ship change", | |
| 86 | createdAt: "2026-10-14T10:00:00Z", | |
| 87 | author: null, | |
| 88 | additions: 3, | |
| 89 | deletions: 1, | |
| 90 | changedFilesCount: 2, | |
| 91 | labels: [], | |
| 92 | comments: [], | |
| 93 | observedAt: 1791972120000, | |
| 94 | body: null, | |
| 95 | updatedAt: "2026-10-14T10:02:00Z", | |
| 96 | mergedAt: "2026-10-14T10:01:00Z", | |
| 97 | closedAt: null, | |
| 98 | draft: false, | |
| 99 | baseBranch: "main", | |
| 100 | headBranch: "feature", | |
| 101 | ciStatus: "failed", | |
| 102 | hasConflicts: false, | |
| 103 | checks: [ | |
| 104 | { | |
| 105 | id: "check:42", | |
| 106 | name: "Tests", | |
| 107 | workflowName: "CI", | |
| 108 | status: "completed", | |
| 109 | conclusion: "failure", | |
| 110 | url: null, | |
| 111 | startedAt: null, | |
| 112 | completedAt: "2026-10-14T10:00:00Z", | |
| 113 | }, | |
| 114 | ], | |
| 115 | } as const; | |
| 116 | expect(decodeDetails(details)).toEqual(details); | |
| 117 | const withoutDates = Struct.omit(details, ["mergedAt", "closedAt"]); | |
| 118 | expect(decodeDetails(withoutDates)).not.toHaveProperty("mergedAt"); | |
| 119 | }); | |
| 120 | }); | |
| 121 | ||
| 122 | describe("mergebotPullRequestUrl", () => { | |
| 123 | const config = { origin: "https://mergebot.example", repositories: ["Work/App"] }; | |
| 124 | it("uses configured casing and matches GitHub and repositories case-insensitively", () => { | |
| 125 | expect( | |
| 126 | mergebotPullRequestUrl(config, { host: "GitHub.Com", repository: "work/APP", number: 12 }), | |
| 127 | ).toBe("https://mergebot.example/Work/App/pull/12"); | |
| 128 | }); | |
| 129 | it("rejects lookalike and enterprise hosts, unconfigured repositories and invalid numbers", () => { | |
| 130 | for (const host of ["github.com.example", "example.com", "github.com:443"]) { | |
| 131 | expect( | |
| 132 | mergebotPullRequestUrl(config, { host, repository: "work/app", number: 12 }), | |
| 133 | ).toBeNull(); | |
| 134 | } | |
| 135 | expect(mergebotPullRequestUrl(undefined, { repository: "work/app", number: 12 })).toBeNull(); | |
| 136 | expect(mergebotPullRequestUrl(config, { repository: "work/other", number: 12 })).toBeNull(); | |
| 137 | expect(mergebotPullRequestUrl(config, { repository: "work/app", number: 0 })).toBeNull(); | |
| 138 | }); | |
| 139 | }); | |
| 140 | ||
| 141 | describe("parseMergebotPullRequestUrl", () => { | |
| 142 | const config = { origin: "https://mergebot.example", repositories: ["Work/App"] }; | |
| 143 | it("recognizes configured Mergebot PR URLs as their GitHub repository", () => { | |
| 144 | expect( | |
| 145 | parseMergebotPullRequestUrl(config, "https://mergebot.example/work/APP/pull/12#checks"), | |
| 146 | ).toEqual({ host: "github.com", repository: "Work/App", number: 12 }); | |
| 147 | }); | |
| 148 | it("leaves lookalike origins, credentials and unconfigured repository paths alone", () => { | |
| 149 | for (const url of [ | |
| 150 | "https://mergebot.example.evil/work/app/pull/12", | |
| 151 | "http://mergebot.example/work/app/pull/12", | |
| 152 | "https://mergebot.example:444/work/app/pull/12", | |
| 153 | "https://user@mergebot.example/work/app/pull/12", | |
| 154 | "https://mergebot.example/work/other/pull/12", | |
| 155 | ]) | |
| 156 | expect(parseMergebotPullRequestUrl(config, url)).toBeNull(); | |
| 157 | }); | |
| 158 | }); |
packages/contracts/src/mergebot.ts created+156| ... | ... | @@ -0,0 +1,156 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | import { PullRequestActor, PullRequestComment, PullRequestLabel } from "./pullRequest.ts"; | |
| 3 | import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; | |
| 4 | ||
| 5 | export const MergebotConfiguration = Schema.Struct({ | |
| 6 | origin: TrimmedNonEmptyString.check(Schema.isPattern(/^https:\/\/[a-zA-Z0-9.-]+(?::[0-9]+)?$/)), | |
| 7 | repositories: Schema.Array( | |
| 8 | TrimmedNonEmptyString.check(Schema.isPattern(/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/)), | |
| 9 | ), | |
| 10 | }); | |
| 11 | export type MergebotConfiguration = typeof MergebotConfiguration.Type; | |
| 12 | ||
| 13 | export function isMergebotRepository( | |
| 14 | configuration: MergebotConfiguration | undefined, | |
| 15 | repository: string, | |
| 16 | ): boolean { | |
| 17 | return ( | |
| 18 | configuration?.repositories.some((repo) => repo.toLowerCase() === repository.toLowerCase()) ?? | |
| 19 | false | |
| 20 | ); | |
| 21 | } | |
| 22 | ||
| 23 | export const MergebotQueueInput = Schema.Struct({ repository: TrimmedNonEmptyString }); | |
| 24 | export type MergebotQueueInput = typeof MergebotQueueInput.Type; | |
| 25 | ||
| 26 | export const MergebotPullRequestStatus = Schema.Struct({ | |
| 27 | number: PositiveInt, | |
| 28 | headSha: TrimmedNonEmptyString, | |
| 29 | lifecycle: Schema.Literals(["open", "closed", "merged"]), | |
| 30 | queueStatus: Schema.NullOr( | |
| 31 | Schema.Literals([ | |
| 32 | "not_queued", | |
| 33 | "queued", | |
| 34 | "updating_branch", | |
| 35 | "waiting_ci", | |
| 36 | "merging", | |
| 37 | "ci_failed", | |
| 38 | ]), | |
| 39 | ), | |
| 40 | position: Schema.NullOr(PositiveInt), | |
| 41 | mergeEtaAt: Schema.NullOr(Schema.String), | |
| 42 | url: Schema.String, | |
| 43 | }); | |
| 44 | export type MergebotPullRequestStatus = typeof MergebotPullRequestStatus.Type; | |
| 45 | ||
| 46 | export const MergebotRepoQueue = Schema.Struct({ | |
| 47 | repo: TrimmedNonEmptyString, | |
| 48 | url: Schema.String, | |
| 49 | status: Schema.Literals(["loading", "ready"]), | |
| 50 | processing: Schema.Boolean, | |
| 51 | pullRequests: Schema.NullOr(Schema.Array(MergebotPullRequestStatus)), | |
| 52 | }); | |
| 53 | export type MergebotRepoQueue = typeof MergebotRepoQueue.Type; | |
| 54 | ||
| 55 | export function mergebotPullRequestStatus( | |
| 56 | queue: MergebotRepoQueue | null, | |
| 57 | number: number, | |
| 58 | headSha?: string, | |
| 59 | ): MergebotPullRequestStatus | null { | |
| 60 | if (queue?.status !== "ready") return null; | |
| 61 | const entry = queue.pullRequests?.find((pullRequest) => pullRequest.number === number); | |
| 62 | if (!entry || entry.queueStatus === null || (headSha !== undefined && entry.headSha !== headSha)) | |
| 63 | return null; | |
| 64 | return entry; | |
| 65 | } | |
| 66 | ||
| 67 | export const MergebotPullRequestInput = Schema.Struct({ | |
| 68 | repository: TrimmedNonEmptyString, | |
| 69 | number: PositiveInt, | |
| 70 | }); | |
| 71 | export type MergebotPullRequestInput = typeof MergebotPullRequestInput.Type; | |
| 72 | ||
| 73 | export const MergebotPullRequestCheck = Schema.Struct({ | |
| 74 | id: TrimmedNonEmptyString, | |
| 75 | name: Schema.String, | |
| 76 | workflowName: Schema.NullOr(Schema.String), | |
| 77 | status: Schema.String, | |
| 78 | conclusion: Schema.NullOr(Schema.String), | |
| 79 | url: Schema.NullOr(Schema.String), | |
| 80 | startedAt: Schema.NullOr(Schema.String), | |
| 81 | completedAt: Schema.NullOr(Schema.String), | |
| 82 | }); | |
| 83 | export type MergebotPullRequestCheck = typeof MergebotPullRequestCheck.Type; | |
| 84 | ||
| 85 | export const MergebotPullRequestDetails = Schema.Struct({ | |
| 86 | ...MergebotPullRequestStatus.fields, | |
| 87 | title: Schema.String, | |
| 88 | createdAt: Schema.String, | |
| 89 | author: Schema.NullOr(PullRequestActor), | |
| 90 | additions: Schema.NullOr(Schema.Number), | |
| 91 | deletions: Schema.NullOr(Schema.Number), | |
| 92 | changedFilesCount: Schema.NullOr(Schema.Number), | |
| 93 | labels: Schema.Array(PullRequestLabel), | |
| 94 | comments: Schema.Array(PullRequestComment), | |
| 95 | observedAt: Schema.Number, | |
| 96 | updatedAt: Schema.String, | |
| 97 | mergedAt: Schema.optionalKey(Schema.NullOr(Schema.String)), | |
| 98 | closedAt: Schema.optionalKey(Schema.NullOr(Schema.String)), | |
| 99 | body: Schema.NullOr(Schema.String), | |
| 100 | draft: Schema.Boolean, | |
| 101 | baseBranch: Schema.String, | |
| 102 | headBranch: Schema.String, | |
| 103 | ciStatus: Schema.NullOr(Schema.Literals(["passed", "pending", "failed", "unknown"])), | |
| 104 | hasConflicts: Schema.NullOr(Schema.Boolean), | |
| 105 | checks: Schema.Array(MergebotPullRequestCheck), | |
| 106 | }); | |
| 107 | export type MergebotPullRequestDetails = typeof MergebotPullRequestDetails.Type; | |
| 108 | ||
| 109 | export const MERGEBOT_BROWSER_THREAD_ID = "mergebot-browser"; | |
| 110 | ||
| 111 | export function mergebotPullRequestBrowserThreadId(reference: { | |
| 112 | readonly repository: string; | |
| 113 | readonly number: number; | |
| 114 | }): string { | |
| 115 | return `${MERGEBOT_BROWSER_THREAD_ID}:${reference.repository.toLowerCase()}:${reference.number}`; | |
| 116 | } | |
| 117 | ||
| 118 | export function mergebotPullRequestUrl( | |
| 119 | configuration: MergebotConfiguration | undefined, | |
| 120 | reference: { | |
| 121 | readonly host?: string | undefined; | |
| 122 | readonly repository: string; | |
| 123 | readonly number: number; | |
| 124 | }, | |
| 125 | ): string | null { | |
| 126 | if (!configuration || (reference.host ?? "github.com").toLowerCase() !== "github.com") | |
| 127 | return null; | |
| 128 | const repository = configuration.repositories.find( | |
| 129 | (candidate) => candidate.toLowerCase() === reference.repository.toLowerCase(), | |
| 130 | ); | |
| 131 | return repository && Number.isSafeInteger(reference.number) && reference.number > 0 | |
| 132 | ? `${configuration.origin}/${repository}/pull/${reference.number}` | |
| 133 | : null; | |
| 134 | } | |
| 135 | ||
| 136 | export function parseMergebotPullRequestUrl( | |
| 137 | configuration: MergebotConfiguration | undefined, | |
| 138 | targetUrl: string, | |
| 139 | ): { host: "github.com"; repository: string; number: number } | null { | |
| 140 | if (!configuration) return null; | |
| 141 | let url: URL; | |
| 142 | try { | |
| 143 | url = new URL(targetUrl); | |
| 144 | } catch { | |
| 145 | return null; | |
| 146 | } | |
| 147 | if (url.origin !== configuration.origin || url.username || url.password) return null; | |
| 148 | const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); | |
| 149 | const repository = configuration.repositories.find( | |
| 150 | (candidate) => candidate.toLowerCase() === match?.[1]?.toLowerCase(), | |
| 151 | ); | |
| 152 | const number = Number(match?.[2]); | |
| 153 | return repository && Number.isSafeInteger(number) && number > 0 | |
| 154 | ? { host: "github.com", repository, number } | |
| 155 | : null; | |
| 156 | } |
packages/contracts/src/rpc.ts+18| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { MergebotQueueInput, MergebotRepoQueue } from "./mergebot.ts"; | |
| 1 | 2 | import { |
| 2 | 3 | UiSettingsProfile, |
| 3 | 4 | UiSettingsPatchInput, |
| ... | ... | @@ -544,6 +545,8 @@ export const WS_METHODS = { |
| 544 | 545 | cloudInstallRelayClient: "cloud.installRelayClient", |
| 545 | 546 | |
| 546 | 547 | // Pull request methods |
| 548 | mergebotQueue: "mergebot.queue", | |
| 549 | mergebotSubscribe: "mergebot.subscribe", | |
| 547 | 550 | pullRequestsList: "pullRequests.list", |
| 548 | 551 | pullRequestsListStats: "pullRequests.listStats", |
| 549 | 552 | pullRequestsSummary: "pullRequests.summary", |
| ... | ... | @@ -990,6 +993,19 @@ const PullRequestRpcError = Schema.Union([ |
| 990 | 993 | EnvironmentAuthorizationError, |
| 991 | 994 | ]); |
| 992 | 995 | |
| 996 | const WsMergebotQueueRpc = Rpc.make(WS_METHODS.mergebotQueue, { | |
| 997 | payload: MergebotQueueInput, | |
| 998 | success: Schema.NullOr(MergebotRepoQueue), | |
| 999 | error: EnvironmentAuthorizationError, | |
| 1000 | }); | |
| 1001 | ||
| 1002 | const WsMergebotSubscribeRpc = Rpc.make(WS_METHODS.mergebotSubscribe, { | |
| 1003 | payload: MergebotQueueInput, | |
| 1004 | success: Schema.NullOr(MergebotRepoQueue), | |
| 1005 | error: EnvironmentAuthorizationError, | |
| 1006 | stream: true, | |
| 1007 | }); | |
| 1008 | ||
| 993 | 1009 | const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, { |
| 994 | 1010 | payload: PullRequestListInput, |
| 995 | 1011 | success: PullRequestListResult, |
| ... | ... | @@ -2004,6 +2020,8 @@ export const WsRpcGroup = RpcGroup.make( |
| 2004 | 2020 | WsServerGetBackgroundPolicyRpc, |
| 2005 | 2021 | WsCloudGetRelayClientStatusRpc, |
| 2006 | 2022 | WsCloudInstallRelayClientRpc, |
| 2023 | WsMergebotQueueRpc, | |
| 2024 | WsMergebotSubscribeRpc, | |
| 2007 | 2025 | WsPullRequestsListRpc, |
| 2008 | 2026 | WsPullRequestsListStatsRpc, |
| 2009 | 2027 | WsPullRequestsSummaryRpc, |
packages/contracts/src/server.ts+2| ... | ... | @@ -28,6 +28,7 @@ import { RuntimeMode } from "./providerPolicy.ts"; |
| 28 | 28 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; |
| 29 | 29 | import { ServerProviderUsageLimits, UsageLimitSourceSnapshots } from "./providerUsageLimits.ts"; |
| 30 | 30 | import { ServerSettings } from "./settings.ts"; |
| 31 | import { MergebotConfiguration } from "./mergebot.ts"; | |
| 31 | 32 | |
| 32 | 33 | const KeybindingsMalformedConfigIssue = Schema.Struct({ |
| 33 | 34 | kind: Schema.Literal("keybindings.malformed-config"), |
| ... | ... | @@ -673,6 +674,7 @@ export const ServerDirectEndpoint = Schema.Struct({ |
| 673 | 674 | export type ServerDirectEndpoint = typeof ServerDirectEndpoint.Type; |
| 674 | 675 | |
| 675 | 676 | export const ServerConfig = Schema.Struct({ |
| 677 | mergebot: Schema.optionalKey(MergebotConfiguration), | |
| 676 | 678 | environment: ExecutionEnvironmentDescriptor, |
| 677 | 679 | auth: ServerAuthDescriptor, |
| 678 | 680 | cwd: TrimmedNonEmptyString, |