| 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,11 +1,30 @@ |
| 1 | import { useNavigation, useRoute, StackActions } from "@react-navigation/native"; | 1 | import { useNavigation, useRoute, StackActions } from "@react-navigation/native"; |
| 2 | import { nativePullRequestDestination } from "../lib/pullRequestNavigation"; | 2 | import { nativePullRequestDestination } from "../lib/pullRequestNavigation"; |
| 3 | import { useCallback } from "react"; | 3 | import { useCallback, useRef } from "react"; |
| 4 | import { Platform } from "react-native"; | 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 | import { CommonActions } from "@react-navigation/native"; | 11 | import { CommonActions } from "@react-navigation/native"; |
| 6 | import { androidWorkspaceNavigationState } from "../lib/android-workspace-navigation"; | 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 | import { useThreadShell } from "../state/entities"; | 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 | import { tryOpenExternalUrl } from "../lib/openExternalUrl"; | 28 | import { tryOpenExternalUrl } from "../lib/openExternalUrl"; |
| 10 | 29 | ||
| 11 | /** Use the thread's environment; the shared router resolves the repository's project there. */ | 30 | /** Use the thread's environment; the shared router resolves the repository's project there. */ |
| ... | @@ -36,8 +55,83 @@ export function useOpenPullRequest(options?: { | ... | @@ -36,8 +55,83 @@ export function useOpenPullRequest(options?: { |
| 36 | const projectId = | 55 | const projectId = |
| 37 | options?.context?.projectId ?? first(routeContext?.projectId) ?? thread?.projectId; | 56 | options?.context?.projectId ?? first(routeContext?.projectId) ?? thread?.projectId; |
| 38 | const replace = options?.replace; | 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 | return useCallback( | 65 | return useCallback( |
| 40 | async (url: string) => { | 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 | const params = nativePullRequestDestination( | 135 | const params = nativePullRequestDestination( |
| 42 | url, | 136 | url, |
| 43 | environmentId && projectId | 137 | environmentId && projectId |
| ... | @@ -55,6 +149,15 @@ export function useOpenPullRequest(options?: { | ... | @@ -55,6 +149,15 @@ export function useOpenPullRequest(options?: { |
| 55 | else navigation.navigate("PullRequest", params); | 149 | else navigation.navigate("PullRequest", params); |
| 56 | return true; | 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,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 | it("separates preview control from observation", () => { | 174 | it("separates preview control from observation", () => { |
| 169 | for (const method of [ | 175 | for (const method of [ |
| 170 | WS_METHODS.previewOpen, | 176 | WS_METHODS.previewOpen, |
apps/server/src/auth/RpcAuthorization.ts+2| ... | @@ -123,6 +123,8 @@ export const RPC_REQUIRED_SCOPES = { | ... | @@ -123,6 +123,8 @@ export const RPC_REQUIRED_SCOPES = { |
| 123 | [WS_METHODS.scheduledTasksGetWebhookDelivery]: AuthOrchestrationOperateScope, | 123 | [WS_METHODS.scheduledTasksGetWebhookDelivery]: AuthOrchestrationOperateScope, |
| 124 | [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, | 124 | [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, |
| 125 | [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, | 125 | [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, |
| 126 | [WS_METHODS.mergebotQueue]: AuthOrchestrationReadScope, | ||
| 127 | [WS_METHODS.mergebotSubscribe]: AuthOrchestrationReadScope, | ||
| 126 | [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, | 128 | [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, |
| 127 | [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, | 129 | [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, |
| 128 | [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope, | 130 | [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope, |
apps/server/src/git/GitManager.test.ts+79| ... | @@ -27,6 +27,7 @@ import { ChildProcessSpawner } from "effect/process"; | ... | @@ -27,6 +27,7 @@ import { ChildProcessSpawner } from "effect/process"; |
| 27 | import { expect } from "vite-plus/test"; | 27 | import { expect } from "vite-plus/test"; |
| 28 | import type { | 28 | import type { |
| 29 | ChangeRequest, | 29 | ChangeRequest, |
| 30 | MergebotPullRequestDetails, | ||
| 30 | GitActionProgressEvent, | 31 | GitActionProgressEvent, |
| 31 | GitManagerServiceError, | 32 | GitManagerServiceError, |
| 32 | GitPreparePullRequestThreadInput, | 33 | GitPreparePullRequestThreadInput, |
| ... | @@ -73,6 +74,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; | ... | @@ -73,6 +74,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 73 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; | 74 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; |
| 74 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; | 75 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 75 | import * as ServerSettings from "../serverSettings.ts"; | 76 | import * as ServerSettings from "../serverSettings.ts"; |
| 77 | import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; | ||
| 76 | import * as GitManager from "./GitManager.ts"; | 78 | import * as GitManager from "./GitManager.ts"; |
| 77 | 79 | ||
| 78 | const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | 80 | const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| ... | @@ -723,6 +725,7 @@ function preparePullRequestThread( | ... | @@ -723,6 +725,7 @@ function preparePullRequestThread( |
| 723 | } | 725 | } |
| 724 | 726 | ||
| 725 | function makeManager(input?: { | 727 | function makeManager(input?: { |
| 728 | mergebot?: MergebotQueue.MergebotQueue["Service"]; | ||
| 726 | ghScenario?: FakeGhScenario; | 729 | ghScenario?: FakeGhScenario; |
| 727 | sourceControlProvider?: SourceControlProvider["Service"]; | 730 | sourceControlProvider?: SourceControlProvider["Service"]; |
| 728 | textGeneration?: Partial<FakeGitTextGeneration>; | 731 | textGeneration?: Partial<FakeGitTextGeneration>; |
| ... | @@ -837,6 +840,9 @@ function makeManager(input?: { | ... | @@ -837,6 +840,9 @@ function makeManager(input?: { |
| 837 | ); | 840 | ); |
| 838 | 841 | ||
| 839 | const layerManager = Layer.mergeAll( | 842 | const layerManager = Layer.mergeAll( |
| 843 | input?.mergebot | ||
| 844 | ? Layer.succeed(MergebotQueue.MergebotQueue, input.mergebot) | ||
| 845 | : Layer.mock(MergebotQueue.MergebotQueue)({ configuration: null }), | ||
| 840 | Layer.succeed(TextGeneration.TextGeneration, textGeneration), | 846 | Layer.succeed(TextGeneration.TextGeneration, textGeneration), |
| 841 | Layer.mock(ProviderRegistry.ProviderRegistry)({ | 847 | Layer.mock(ProviderRegistry.ProviderRegistry)({ |
| 842 | getProviders: Effect.succeed([]), | 848 | getProviders: Effect.succeed([]), |
| ... | @@ -880,6 +886,79 @@ const layerGitManagerTest = GitVcsDriver.layer.pipe( | ... | @@ -880,6 +886,79 @@ const layerGitManagerTest = GitVcsDriver.layer.pipe( |
| 880 | ); | 886 | ); |
| 881 | 887 | ||
| 882 | it.layer(layerGitManagerTest)("GitManager", (it) => { | 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 | it.effect("passive worktree status streams do not start remote refreshes", () => | 962 | it.effect("passive worktree status streams do not start remote refreshes", () => |
| 884 | Effect.gen(function* () { | 963 | Effect.gen(function* () { |
| 885 | const repoDir = yield* makeTempDir("t3code-passive-vcs-"); | 964 | const repoDir = yield* makeTempDir("t3code-passive-vcs-"); |
apps/server/src/git/GitManager.ts+125-5| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; | 1 | import { SourceControlProviderKind, type MergebotPullRequestDetails } from "@t3tools/contracts"; |
| 2 | import * as Arr from "effect/Array"; | 2 | import * as Arr from "effect/Array"; |
| 3 | import * as Cache from "effect/Cache"; | 3 | import * as Cache from "effect/Cache"; |
| 4 | import * as Clock from "effect/Clock"; | 4 | import * as Clock from "effect/Clock"; |
| ... | @@ -68,6 +68,7 @@ import { | ... | @@ -68,6 +68,7 @@ import { |
| 68 | } from "@t3tools/shared/threadPullRequests"; | 68 | } from "@t3tools/shared/threadPullRequests"; |
| 69 | 69 | ||
| 70 | import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; | 70 | import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; |
| 71 | import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; | ||
| 71 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; | 72 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 72 | import { | 73 | import { |
| 73 | conventionalCommitsTextGenerationPolicy, | 74 | conventionalCommitsTextGenerationPolicy, |
| ... | @@ -722,6 +723,7 @@ function toPullRequestHeadRemoteInfo(pr: { | ... | @@ -722,6 +723,7 @@ function toPullRequestHeadRemoteInfo(pr: { |
| 722 | 723 | ||
| 723 | export const make = Effect.gen(function* () { | 724 | export const make = Effect.gen(function* () { |
| 724 | const gitCore = yield* GitVcsDriver.GitVcsDriver; | 725 | const gitCore = yield* GitVcsDriver.GitVcsDriver; |
| 726 | const mergebot = yield* MergebotQueue.MergebotQueue; | ||
| 725 | const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; | 727 | const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; |
| 726 | const textGeneration = yield* TextGeneration.TextGeneration; | 728 | const textGeneration = yield* TextGeneration.TextGeneration; |
| 727 | const providerRegistry = yield* ProviderRegistry.ProviderRegistry; | 729 | const providerRegistry = yield* ProviderRegistry.ProviderRegistry; |
| ... | @@ -730,6 +732,28 @@ export const make = Effect.gen(function* () { | ... | @@ -730,6 +732,28 @@ export const make = Effect.gen(function* () { |
| 730 | const fileSystem = yield* FileSystem.FileSystem; | 732 | const fileSystem = yield* FileSystem.FileSystem; |
| 731 | const path = yield* Path.Path; | 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 | const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); | 757 | const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); |
| 734 | const serverSettingsService = yield* ServerSettings.ServerSettingsService; | 758 | const serverSettingsService = yield* ServerSettings.ServerSettingsService; |
| 735 | const threads = yield* ProjectionStore.ProjectionStoreV2; | 759 | const threads = yield* ProjectionStore.ProjectionStoreV2; |
| ... | @@ -1179,9 +1203,9 @@ export const make = Effect.gen(function* () { | ... | @@ -1179,9 +1203,9 @@ export const make = Effect.gen(function* () { |
| 1179 | // this lookup, so a merge it sees must reach the thread's link and settlement right away. | 1203 | // this lookup, so a merge it sees must reach the thread's link and settlement right away. |
| 1180 | const pullRequestStateChanges = yield* PubSub.sliding<ThreadPullRequestKey>(64); | 1204 | const pullRequestStateChanges = yield* PubSub.sliding<ThreadPullRequestKey>(64); |
| 1181 | const lookupStates = new Map<string, PullRequestInfo["state"]>(); | 1205 | const lookupStates = new Map<string, PullRequestInfo["state"]>(); |
| 1182 | const noteLookupState = (pr: PullRequestInfo) => | 1206 | const noteLookupState = (pr: PullRequestInfo, reference?: ThreadPullRequestKey) => |
| 1183 | Effect.suspend(() => { | 1207 | Effect.suspend(() => { |
| 1184 | const parsed = parseChangeRequestUrl(pr.url); | 1208 | const parsed = reference ?? parseChangeRequestUrl(pr.url); |
| 1185 | if (parsed === null || parsed.number !== pr.number) return Effect.void; | 1209 | if (parsed === null || parsed.number !== pr.number) return Effect.void; |
| 1186 | const key = normalizeThreadPullRequestKey(parsed); | 1210 | const key = normalizeThreadPullRequestKey(parsed); |
| 1187 | const id = threadPullRequestKeyOf(key); | 1211 | const id = threadPullRequestKeyOf(key); |
| ... | @@ -1244,7 +1268,27 @@ export const make = Effect.gen(function* () { | ... | @@ -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 | // A transient lookup failure (rate limit, network blip) must not clear an | 1292 | // A transient lookup failure (rate limit, network blip) must not clear an |
| 1249 | // already-known PR badge, so the last successful answer per branch sticks | 1293 | // already-known PR badge, so the last successful answer per branch sticks |
| 1250 | // around as the fallback. Keep the resolved head context with it so a | 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,6 +1871,18 @@ export const make = Effect.gen(function* () { |
| 1827 | | "isCrossRepository" | 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 | const provider = yield* sourceControlProvider(cwd); | 1886 | const provider = yield* sourceControlProvider(cwd); |
| 1831 | const probe = headBranchProbe(provider, headContext.headSelectors, "open"); | 1887 | const probe = headBranchProbe(provider, headContext.headSelectors, "open"); |
| 1832 | for (const headSelector of probe.headSelectors) { | 1888 | for (const headSelector of probe.headSelectors) { |
| ... | @@ -1857,6 +1913,15 @@ export const make = Effect.gen(function* () { | ... | @@ -1857,6 +1913,15 @@ export const make = Effect.gen(function* () { |
| 1857 | cwd: string, | 1913 | cwd: string, |
| 1858 | headContext: BranchHeadContext, | 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 | const parsedByNumber = new Map<number, PullRequestInfo>(); | 1925 | const parsedByNumber = new Map<number, PullRequestInfo>(); |
| 1861 | 1926 | ||
| 1862 | const provider = yield* sourceControlProvider(cwd); | 1927 | const provider = yield* sourceControlProvider(cwd); |
| ... | @@ -2604,6 +2669,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2604,6 +2669,7 @@ export const make = Effect.gen(function* () { |
| 2604 | } | 2669 | } |
| 2605 | } | 2670 | } |
| 2606 | const { latest } = cached; | 2671 | const { latest } = cached; |
| 2672 | const workRepository = mergebotRepository(cached.headContext.targetRemoteUrlKey); | ||
| 2607 | if (latest === null) return null; | 2673 | if (latest === null) return null; |
| 2608 | if ( | 2674 | if ( |
| 2609 | (branch === defaultBranch || | 2675 | (branch === defaultBranch || |
| ... | @@ -2619,7 +2685,10 @@ export const make = Effect.gen(function* () { | ... | @@ -2619,7 +2685,10 @@ export const make = Effect.gen(function* () { |
| 2619 | headSha: latest.headSha ?? null, | 2685 | headSha: latest.headSha ?? null, |
| 2620 | // Hosting CLIs can select an upstream repository instead of origin. | 2686 | // Hosting CLIs can select an upstream repository instead of origin. |
| 2621 | // The returned PR URL names the repository that actually owns it. | 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 | const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( | 2694 | const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( |
| ... | @@ -2663,6 +2732,48 @@ export const make = Effect.gen(function* () { | ... | @@ -2663,6 +2732,48 @@ export const make = Effect.gen(function* () { |
| 2663 | const resolvePullRequest: GitManager["Service"]["resolvePullRequest"] = Effect.fn( | 2732 | const resolvePullRequest: GitManager["Service"]["resolvePullRequest"] = Effect.fn( |
| 2664 | "resolvePullRequest", | 2733 | "resolvePullRequest", |
| 2665 | )(function* (input) { | 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 | const pullRequest = yield* (yield* sourceControlProvider(input.cwd)) | 2777 | const pullRequest = yield* (yield* sourceControlProvider(input.cwd)) |
| 2667 | .getChangeRequest({ | 2778 | .getChangeRequest({ |
| 2668 | cwd: input.cwd, | 2779 | cwd: input.cwd, |
| ... | @@ -2676,6 +2787,15 @@ export const make = Effect.gen(function* () { | ... | @@ -2676,6 +2787,15 @@ export const make = Effect.gen(function* () { |
| 2676 | const preparePullRequestThread: GitManager["Service"]["preparePullRequestThread"] = Effect.fn( | 2787 | const preparePullRequestThread: GitManager["Service"]["preparePullRequestThread"] = Effect.fn( |
| 2677 | "preparePullRequestThread", | 2788 | "preparePullRequestThread", |
| 2678 | )(function* (input) { | 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 | const maybeRunSetupScript = (worktreePath: string) => { | 2799 | const maybeRunSetupScript = (worktreePath: string) => { |
| 2680 | if (!input.threadId) { | 2800 | if (!input.threadId) { |
| 2681 | return Effect.void; | 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,6 +99,8 @@ const RPC_AGGREGATES = { |
| 99 | [WS_METHODS.secretsAnswerRequest]: "secrets", | 99 | [WS_METHODS.secretsAnswerRequest]: "secrets", |
| 100 | [WS_METHODS.cloudGetRelayClientStatus]: "cloud", | 100 | [WS_METHODS.cloudGetRelayClientStatus]: "cloud", |
| 101 | [WS_METHODS.cloudInstallRelayClient]: "cloud", | 101 | [WS_METHODS.cloudInstallRelayClient]: "cloud", |
| 102 | [WS_METHODS.mergebotQueue]: "mergebot", | ||
| 103 | [WS_METHODS.mergebotSubscribe]: "mergebot", | ||
| 102 | [WS_METHODS.pullRequestsList]: "pull-requests", | 104 | [WS_METHODS.pullRequestsList]: "pull-requests", |
| 103 | [WS_METHODS.pullRequestsListStats]: "pull-requests", | 105 | [WS_METHODS.pullRequestsListStats]: "pull-requests", |
| 104 | [WS_METHODS.pullRequestsSummary]: "pull-requests", | 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,6 +33,7 @@ import * as Ref from "effect/Ref"; |
| 33 | import { TestClock } from "effect/testing"; | 33 | import { TestClock } from "effect/testing"; |
| 34 | 34 | ||
| 35 | import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; | 35 | import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; |
| 36 | import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; | ||
| 36 | import * as GitManager from "../git/GitManager.ts"; | 37 | import * as GitManager from "../git/GitManager.ts"; |
| 37 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; | 38 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; |
| 38 | import * as ServerActivation from "../serverActivation.ts"; | 39 | import * as ServerActivation from "../serverActivation.ts"; |
| ... | @@ -164,6 +165,7 @@ function makeSummary( | ... | @@ -164,6 +165,7 @@ function makeSummary( |
| 164 | } | 165 | } |
| 165 | 166 | ||
| 166 | interface HarnessOptions { | 167 | interface HarnessOptions { |
| 168 | readonly mergebot?: MergebotQueue.MergebotQueue["Service"]["configuration"]; | ||
| 167 | readonly invalidate?: PullRequestService.PullRequestService["Service"]["invalidate"]; | 169 | readonly invalidate?: PullRequestService.PullRequestService["Service"]["invalidate"]; |
| 168 | readonly snapshot: TestShellSnapshot; | 170 | readonly snapshot: TestShellSnapshot; |
| 169 | readonly summary?: ( | 171 | readonly summary?: ( |
| ... | @@ -218,6 +220,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: | ... | @@ -218,6 +220,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: |
| 218 | }; | 220 | }; |
| 219 | 221 | ||
| 220 | const layerDependencies = Layer.mergeAll( | 222 | const layerDependencies = Layer.mergeAll( |
| 223 | Layer.mock(MergebotQueue.MergebotQueue)({ configuration: options.mergebot ?? null }), | ||
| 221 | Layer.mock(PullRequestService.PullRequestService)({ | 224 | Layer.mock(PullRequestService.PullRequestService)({ |
| 222 | summary, | 225 | summary, |
| 223 | stack, | 226 | stack, |
| ... | @@ -387,6 +390,38 @@ function applySync( | ... | @@ -387,6 +390,38 @@ function applySync( |
| 387 | } | 390 | } |
| 388 | 391 | ||
| 389 | describe("PullRequestSyncReactor", () => { | 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 | it.effect("retries a failed stack read after the summary becomes terminal", () => | 425 | it.effect("retries a failed stack read after the summary becomes terminal", () => |
| 391 | Effect.scoped( | 426 | Effect.scoped( |
| 392 | Effect.gen(function* () { | 427 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/PullRequestSyncReactor.ts+16-9| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { isMergebotRepository } from "@t3tools/contracts"; | ||
| 2 | import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; | ||
| 1 | import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; | 3 | import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; |
| 2 | import { | 4 | import { |
| 3 | CommandId, | 5 | CommandId, |
| ... | @@ -156,6 +158,7 @@ export const make = Effect.gen(function* () { | ... | @@ -156,6 +158,7 @@ export const make = Effect.gen(function* () { |
| 156 | const engine = yield* Orchestrator.OrchestratorV2; | 158 | const engine = yield* Orchestrator.OrchestratorV2; |
| 157 | const projections = yield* ProjectionStore.ProjectionStoreV2; | 159 | const projections = yield* ProjectionStore.ProjectionStoreV2; |
| 158 | const pullRequests = yield* PullRequestService.PullRequestService; | 160 | const pullRequests = yield* PullRequestService.PullRequestService; |
| 161 | const mergebot = yield* MergebotQueue.MergebotQueue; | ||
| 159 | const git = yield* GitManager.GitManager; | 162 | const git = yield* GitManager.GitManager; |
| 160 | const crypto = yield* Crypto.Crypto; | 163 | const crypto = yield* Crypto.Crypto; |
| 161 | 164 | ||
| ... | @@ -288,16 +291,20 @@ export const make = Effect.gen(function* () { | ... | @@ -288,16 +291,20 @@ export const make = Effect.gen(function* () { |
| 288 | if (generation !== undefined) yield* pullRequests.invalidate({ reference: ref }); | 291 | if (generation !== undefined) yield* pullRequests.invalidate({ reference: ref }); |
| 289 | const summary = yield* pullRequests.summary(ref, { recoverTransientFailure: false }); | 292 | const summary = yield* pullRequests.summary(ref, { recoverTransientFailure: false }); |
| 290 | const fields = snapshotFieldsOf(summary); | 293 | const fields = snapshotFieldsOf(summary); |
| 294 | const work = | ||
| 295 | ref.host === "github.com" && | ||
| 296 | isMergebotRepository(mergebot.configuration ?? undefined, ref.repository); | ||
| 291 | const needsStack = | 297 | const needsStack = |
| 292 | generation !== undefined || | 298 | !work && |
| 293 | retryStacks.has(key) || | 299 | (generation !== undefined || |
| 294 | entries.some( | 300 | retryStacks.has(key) || |
| 295 | (entry) => | 301 | entries.some( |
| 296 | entry.link.snapshot === null || | 302 | (entry) => |
| 297 | !snapshotFieldsEqual(entry.link.snapshot, fields) || | 303 | entry.link.snapshot === null || |
| 298 | (summary.stack !== undefined && | 304 | !snapshotFieldsEqual(entry.link.snapshot, fields) || |
| 299 | (entry.link.stack?.number ?? null) !== (summary.stack?.number ?? null)), | 305 | (summary.stack !== undefined && |
| 300 | ); | 306 | (entry.link.stack?.number ?? null) !== (summary.stack?.number ?? null)), |
| 307 | )); | ||
| 301 | // A summary that says the pull request is in no stack, for links that hold none, already | 308 | // A summary that says the pull request is in no stack, for links that hold none, already |
| 302 | // answers what the stack read would. | 309 | // answers what the stack read would. |
| 303 | const knownUnstacked = | 310 | const knownUnstacked = |
apps/server/src/orchestration-v2/runtimeLayer.test.ts+194-159| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; |
| 2 | import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; | ||
| 2 | import { SourceControlProviderKind } from "@t3tools/contracts"; | 3 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 3 | import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; | 4 | import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; |
| 4 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; | 5 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| ... | @@ -2505,12 +2506,18 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { | ... | @@ -2505,12 +2506,18 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 2505 | }); | 2506 | }); |
| 2506 | const reactor = yield* PullRequestWatchReactor.make.pipe( | 2507 | const reactor = yield* PullRequestWatchReactor.make.pipe( |
| 2507 | Effect.provide( | 2508 | Effect.provide( |
| 2508 | Layer.mergeAll( | 2509 | Layer.merge( |
| 2509 | NodeServices.layer, | 2510 | Layer.mock(MergebotQueue.MergebotQueue)({ |
| 2510 | Layer.mock(PullRequestService.PullRequestService)({ | 2511 | configuration: null, |
| 2511 | detail: () => Effect.die("host unreachable"), | 2512 | streamChanges: Stream.empty, |
| 2512 | activity: () => Effect.die("host unreachable"), | ||
| 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,37 +2591,43 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 2584 | let reads = 0; | 2591 | let reads = 0; |
| 2585 | const reactor = yield* PullRequestWatchReactor.make.pipe( | 2592 | const reactor = yield* PullRequestWatchReactor.make.pipe( |
| 2586 | Effect.provide( | 2593 | Effect.provide( |
| 2587 | Layer.mergeAll( | 2594 | Layer.merge( |
| 2588 | NodeServices.layer, | 2595 | Layer.mock(MergebotQueue.MergebotQueue)({ |
| 2589 | Layer.mock(PullRequestService.PullRequestService)({ | 2596 | configuration: null, |
| 2590 | detail: () => | 2597 | streamChanges: Stream.empty, |
| 2591 | Effect.suspend(() => { | 2598 | }), |
| 2592 | reads += 1; | 2599 | Layer.mergeAll( |
| 2593 | return host === "rate-limited" | 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 | ? Effect.fail(rateLimited) | 2621 | ? Effect.fail(rateLimited) |
| 2595 | : Effect.succeed({ | 2622 | : Effect.succeed({ |
| 2596 | ...watchedPullRequestDetail({ | 2623 | comments: [], |
| 2597 | projectId, | 2624 | commentCount: 0, |
| 2598 | number: 9, | 2625 | commentsTruncated: false, |
| 2599 | at: "2026-10-02T12:00:00.000Z", | 2626 | reviewThreads: [], |
| 2600 | }), | 2627 | commits: [], |
| 2601 | state: host, | 2628 | }), |
| 2602 | checks: [ | 2629 | }), |
| 2603 | { name: "lint", status: "success", description: null, url: null }, | 2630 | ), |
| 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 | }), | ||
| 2618 | ), | 2631 | ), |
| 2619 | ), | 2632 | ), |
| 2620 | ); | 2633 | ); |
| ... | @@ -2720,24 +2733,34 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { | ... | @@ -2720,24 +2733,34 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 2720 | }); | 2733 | }); |
| 2721 | const reactor = yield* PullRequestWatchReactor.make.pipe( | 2734 | const reactor = yield* PullRequestWatchReactor.make.pipe( |
| 2722 | Effect.provide( | 2735 | Effect.provide( |
| 2723 | Layer.mergeAll( | 2736 | Layer.merge( |
| 2724 | NodeServices.layer, | 2737 | Layer.mock(MergebotQueue.MergebotQueue)({ |
| 2725 | Layer.mock(PullRequestService.PullRequestService)({ | 2738 | configuration: null, |
| 2726 | detail: () => | 2739 | streamChanges: Stream.empty, |
| 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 | }), | ||
| 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,61 +2845,67 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 2822 | let activityReads = 0; | 2845 | let activityReads = 0; |
| 2823 | const reactor = yield* PullRequestWatchReactor.make.pipe( | 2846 | const reactor = yield* PullRequestWatchReactor.make.pipe( |
| 2824 | Effect.provide( | 2847 | Effect.provide( |
| 2825 | Layer.mergeAll( | 2848 | Layer.merge( |
| 2826 | NodeServices.layer, | 2849 | Layer.mock(MergebotQueue.MergebotQueue)({ |
| 2827 | Layer.mock(PullRequestService.PullRequestService)({ | 2850 | configuration: null, |
| 2828 | watchFingerprint: () => | 2851 | streamChanges: Stream.empty, |
| 2829 | Effect.suspend(() => | 2852 | }), |
| 2830 | fingerprint === "rate-limited" | 2853 | Layer.mergeAll( |
| 2831 | ? Effect.fail( | 2854 | NodeServices.layer, |
| 2832 | new PullRequestOperationError({ | 2855 | Layer.mock(PullRequestService.PullRequestService)({ |
| 2833 | operation: "watchFingerprint", | 2856 | watchFingerprint: () => |
| 2834 | detail: "paused", | 2857 | Effect.suspend(() => |
| 2835 | cause: new PullRequestProviderError({ | 2858 | fingerprint === "rate-limited" |
| 2836 | provider: SourceControlProviderKind.make("github"), | 2859 | ? Effect.fail( |
| 2837 | operation: "getChangeRequestWatchFingerprint", | 2860 | new PullRequestOperationError({ |
| 2838 | reason: "rate-limited", | 2861 | operation: "watchFingerprint", |
| 2839 | detail: "paused", | 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 | }), | 2870 | ) |
| 2842 | ) | 2871 | : Effect.succeed(fingerprint), |
| 2843 | : Effect.succeed(fingerprint), | 2872 | ), |
| 2844 | ), | 2873 | invalidate: () => |
| 2845 | invalidate: () => | 2874 | Effect.sync(() => { |
| 2846 | Effect.sync(() => { | 2875 | cachedChecks = null; |
| 2847 | cachedChecks = null; | 2876 | }), |
| 2848 | }), | 2877 | detail: () => |
| 2849 | detail: () => | 2878 | Effect.sync(() => { |
| 2850 | Effect.sync(() => { | 2879 | detailReads += 1; |
| 2851 | detailReads += 1; | 2880 | const answer = cachedChecks ?? checks; |
| 2852 | const answer = cachedChecks ?? checks; | 2881 | cachedChecks = null; |
| 2853 | cachedChecks = null; | 2882 | return { |
| 2854 | return { | 2883 | ...watchedPullRequestDetail({ |
| 2855 | ...watchedPullRequestDetail({ | 2884 | projectId, |
| 2856 | projectId, | 2885 | number: 11, |
| 2857 | number: 11, | 2886 | at: "2026-10-02T12:00:00.000Z", |
| 2858 | at: "2026-10-02T12:00:00.000Z", | 2887 | }), |
| 2859 | }), | 2888 | checks: Object.entries(answer).map(([name, status]) => ({ |
| 2860 | checks: Object.entries(answer).map(([name, status]) => ({ | 2889 | name, |
| 2861 | name, | 2890 | status, |
| 2862 | status, | 2891 | description: null, |
| 2863 | description: null, | 2892 | url: null, |
| 2864 | url: null, | 2893 | })), |
| 2865 | })), | 2894 | }; |
| 2866 | }; | 2895 | }), |
| 2867 | }), | 2896 | activity: () => |
| 2868 | activity: () => | 2897 | Effect.sync(() => { |
| 2869 | Effect.sync(() => { | 2898 | activityReads += 1; |
| 2870 | activityReads += 1; | 2899 | return { |
| 2871 | return { | 2900 | comments, |
| 2872 | comments, | 2901 | commentCount: comments.length, |
| 2873 | commentCount: comments.length, | 2902 | commentsTruncated: false, |
| 2874 | commentsTruncated: false, | 2903 | reviewThreads: [], |
| 2875 | reviewThreads: [], | 2904 | commits: [], |
| 2876 | commits: [], | 2905 | }; |
| 2877 | }; | 2906 | }), |
| 2878 | }), | 2907 | }), |
| 2879 | }), | 2908 | ), |
| 2880 | ), | 2909 | ), |
| 2881 | ), | 2910 | ), |
| 2882 | ); | 2911 | ); |
| ... | @@ -3031,62 +3060,68 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { | ... | @@ -3031,62 +3060,68 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 3031 | let pagesRead = 0; | 3060 | let pagesRead = 0; |
| 3032 | const reactor = yield* PullRequestWatchReactor.make.pipe( | 3061 | const reactor = yield* PullRequestWatchReactor.make.pipe( |
| 3033 | Effect.provide( | 3062 | Effect.provide( |
| 3034 | Layer.mergeAll( | 3063 | Layer.merge( |
| 3035 | NodeServices.layer, | 3064 | Layer.mock(MergebotQueue.MergebotQueue)({ |
| 3036 | Layer.mock(PullRequestService.PullRequestService)({ | 3065 | configuration: null, |
| 3037 | detail: () => Effect.succeed(detail), | 3066 | streamChanges: Stream.empty, |
| 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 | }, | ||
| 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,6 +28,7 @@ import type { ServerSettings } from "@t3tools/contracts"; |
| 28 | import * as ServerSettingsService from "../serverSettings.ts"; | 28 | import * as ServerSettingsService from "../serverSettings.ts"; |
| 29 | import { PullRequestOperationError } from "@t3tools/contracts"; | 29 | import { PullRequestOperationError } from "@t3tools/contracts"; |
| 30 | 30 | ||
| 31 | import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; | ||
| 31 | import * as ProjectService from "../project/ProjectService.ts"; | 32 | import * as ProjectService from "../project/ProjectService.ts"; |
| 32 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | 33 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 33 | import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; | 34 | import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; |
| ... | @@ -534,6 +535,7 @@ function fakeProvider( | ... | @@ -534,6 +535,7 @@ function fakeProvider( |
| 534 | } | 535 | } |
| 535 | 536 | ||
| 536 | function makeService(input: { | 537 | function makeService(input: { |
| 538 | readonly mergebot?: MergebotQueue.MergebotQueue["Service"]; | ||
| 537 | readonly settings?: ServerSettings; | 539 | readonly settings?: ServerSettings; |
| 538 | readonly projects: ReadonlyArray<OrchestrationProjectShell>; | 540 | readonly projects: ReadonlyArray<OrchestrationProjectShell>; |
| 539 | readonly providers: ReadonlyArray<PullRequestProviderApi>; | 541 | readonly providers: ReadonlyArray<PullRequestProviderApi>; |
| ... | @@ -545,6 +547,12 @@ function makeService(input: { | ... | @@ -545,6 +547,12 @@ function makeService(input: { |
| 545 | return Effect.flatMap( | 547 | return Effect.flatMap( |
| 546 | Layer.build( | 548 | Layer.build( |
| 547 | Layer.mergeAll( | 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 | Layer.mock(ServerSettingsService.ServerSettingsService)({ | 556 | Layer.mock(ServerSettingsService.ServerSettingsService)({ |
| 549 | getSettings: Effect.succeed(input.settings ?? DEFAULT_SERVER_SETTINGS), | 557 | getSettings: Effect.succeed(input.settings ?? DEFAULT_SERVER_SETTINGS), |
| 550 | }), | 558 | }), |
| ... | @@ -7903,3 +7911,96 @@ it.effect.each([ | ... | @@ -7903,3 +7911,96 @@ it.effect.each([ |
| 7903 | assert.deepStrictEqual(calls, [expected]); | 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,6 +21,7 @@ import * as Stream from "effect/Stream"; |
| 21 | import * as SubscriptionRef from "effect/SubscriptionRef"; | 21 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 22 | import { | 22 | import { |
| 23 | PullRequestOperationError, | 23 | PullRequestOperationError, |
| 24 | isMergebotRepository, | ||
| 24 | PullRequestUnavailableError, | 25 | PullRequestUnavailableError, |
| 25 | pullRequestHostOf, | 26 | pullRequestHostOf, |
| 26 | pullRequestProviderRequirement, | 27 | pullRequestProviderRequirement, |
| ... | @@ -78,6 +79,12 @@ import { | ... | @@ -78,6 +79,12 @@ import { |
| 78 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | 79 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 79 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; | 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 | import * as ProjectService from "../project/ProjectService.ts"; | 88 | import * as ProjectService from "../project/ProjectService.ts"; |
| 82 | import * as ServerSettings from "../serverSettings.ts"; | 89 | import * as ServerSettings from "../serverSettings.ts"; |
| 83 | import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; | 90 | import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; |
| ... | @@ -711,6 +718,7 @@ export const make = Effect.gen(function* () { | ... | @@ -711,6 +718,7 @@ export const make = Effect.gen(function* () { |
| 711 | const stateChanges = yield* PubSub.sliding<ThreadPullRequestKey>(64); | 718 | const stateChanges = yield* PubSub.sliding<ThreadPullRequestKey>(64); |
| 712 | const pullRequestRefreshes = yield* SubscriptionRef.make(0); | 719 | const pullRequestRefreshes = yield* SubscriptionRef.make(0); |
| 713 | const registry = yield* PullRequestProviderRegistry.PullRequestProviderRegistry; | 720 | const registry = yield* PullRequestProviderRegistry.PullRequestProviderRegistry; |
| 721 | const mergebot = yield* MergebotQueue.MergebotQueue; | ||
| 714 | const serverSettings = yield* ServerSettings.ServerSettingsService; | 722 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 715 | const projects = yield* ProjectService.ProjectService; | 723 | const projects = yield* ProjectService.ProjectService; |
| 716 | const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | 724 | const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; |
| ... | @@ -1002,6 +1010,11 @@ export const make = Effect.gen(function* () { | ... | @@ -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 | const canonicalRef = Effect.fn("PullRequestService.canonicalRef")(function* < | 1018 | const canonicalRef = Effect.fn("PullRequestService.canonicalRef")(function* < |
| 1006 | I extends PullRequestRef, | 1019 | I extends PullRequestRef, |
| 1007 | >(input: I) { | 1020 | >(input: I) { |
| ... | @@ -1267,11 +1280,14 @@ export const make = Effect.gen(function* () { | ... | @@ -1267,11 +1280,14 @@ export const make = Effect.gen(function* () { |
| 1267 | // issued, so one that does not read as one means the page is sending something it made up, | 1280 | // issued, so one that does not read as one means the page is sending something it made up, |
| 1268 | // and reading part of the listing under that assumption would quietly lose rows. | 1281 | // and reading part of the listing under that assumption would quietly lose rows. |
| 1269 | const continuation = yield* decodeCursors(input.cursors); | 1282 | const continuation = yield* decodeCursors(input.cursors); |
| 1270 | const { | 1283 | const { supported, unimplemented, viewerRoots } = yield* listWorkspaceProjects(input); |
| 1271 | supported: projects, | 1284 | const workProjects = supported.filter(isWorkProject); |
| 1272 | unimplemented, | 1285 | const projects = supported.filter((project) => !isWorkProject(project)); |
| 1273 | viewerRoots, | 1286 | const workErrors = workProjects.map(({ project }) => ({ |
| 1274 | } = yield* listWorkspaceProjects(input); | 1287 | projectId: project.id, |
| 1288 | projectTitle: project.title, | ||
| 1289 | message: "Open this repository in Mergebot to browse its pull requests.", | ||
| 1290 | })); | ||
| 1275 | const projectCounts = new Map<string, number>(); | 1291 | const projectCounts = new Map<string, number>(); |
| 1276 | for (const { host } of projects) { | 1292 | for (const { host } of projects) { |
| 1277 | projectCounts.set(host, (projectCounts.get(host) ?? 0) + 1); | 1293 | projectCounts.set(host, (projectCounts.get(host) ?? 0) + 1); |
| ... | @@ -1351,7 +1367,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1351,7 +1367,7 @@ export const make = Effect.gen(function* () { |
| 1351 | viewers: viewers as PullRequestListResult["viewers"], | 1367 | viewers: viewers as PullRequestListResult["viewers"], |
| 1352 | providers, | 1368 | providers, |
| 1353 | entries: [], | 1369 | entries: [], |
| 1354 | errors: [], | 1370 | errors: workErrors, |
| 1355 | truncated: false, | 1371 | truncated: false, |
| 1356 | nextCursors: {}, | 1372 | nextCursors: {}, |
| 1357 | }; | 1373 | }; |
| ... | @@ -1585,7 +1601,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1585,7 +1601,7 @@ export const make = Effect.gen(function* () { |
| 1585 | entries: batches | 1601 | entries: batches |
| 1586 | .flatMap((batch) => batch.entries) | 1602 | .flatMap((batch) => batch.entries) |
| 1587 | .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)), | 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 | truncated: batches.some((batch) => batch.truncated), | 1605 | truncated: batches.some((batch) => batch.truncated), |
| 1590 | nextCursors, | 1606 | nextCursors, |
| 1591 | }; | 1607 | }; |
| ... | @@ -3575,17 +3591,139 @@ export const make = Effect.gen(function* () { | ... | @@ -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 | return PullRequestService.of({ | 3688 | return PullRequestService.of({ |
| 3579 | routing, | 3689 | routing, |
| 3580 | routingIdentity, | 3690 | routingIdentity, |
| 3581 | withRoutingCredential, | 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 | listStats: (input) => | 3700 | listStats: (input) => |
| 3584 | Effect.forEach(input.refs, (ref) => canonicalRef(ref).pipe(Effect.option)).pipe( | 3701 | Effect.gen(function* () { |
| 3585 | Effect.flatMap((refs) => listStats({ ...input, refs: refs.flatMap(Option.toArray) })), | 3702 | const refs = (yield* Effect.forEach(input.refs, (ref) => |
| 3586 | ), | 3703 | canonicalRef(ref).pipe(Effect.option), |
| 3587 | summary: credentialCached(summary), | 3704 | )).flatMap(Option.toArray); |
| 3588 | stack: credentialCached(stack), | 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 | subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( | 3727 | subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( |
| 3590 | Effect.map((subscription) => Stream.fromSubscription(subscription)), | 3728 | Effect.map((subscription) => Stream.fromSubscription(subscription)), |
| 3591 | ), | 3729 | ), |
| ... | @@ -3597,31 +3735,58 @@ export const make = Effect.gen(function* () { | ... | @@ -3597,31 +3735,58 @@ export const make = Effect.gen(function* () { |
| 3597 | Stream.filter((revision) => revision > 0), | 3735 | Stream.filter((revision) => revision > 0), |
| 3598 | ), | 3736 | ), |
| 3599 | refreshAfterTurn, | 3737 | refreshAfterTurn, |
| 3600 | detail: credentialCached(detail), | 3738 | detail: unsupportedWorkRead("detail", credentialCached(detail)), |
| 3601 | checks: credentialCached((input) => Cache.get(checksCache, refCacheKey(input))), | 3739 | checks: withWorkRead( |
| 3602 | watchFingerprint: credentialCached((input) => | 3740 | credentialCached((input) => Cache.get(checksCache, refCacheKey(input))), |
| 3603 | Cache.get(watchFingerprintCache, 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), | 3746 | watchFingerprint: withWorkRead( |
| 3606 | preview: credentialCached(preview), | 3747 | credentialCached((input) => Cache.get(watchFingerprintCache, refCacheKey(input))), |
| 3607 | threadComments, | 3748 | (input) => workDetails(input).pipe(Effect.map(mergebotWatchFingerprint)), |
| 3608 | diff: credentialCached(diff), | 3749 | ), |
| 3609 | diffFileContents, | 3750 | activity: unsupportedWorkRead("activity", credentialCached(activity)), |
| 3610 | filesViewed: credentialCached(filesViewed), | 3751 | preview: withWorkRead(credentialCached(preview), (input) => |
| 3611 | setFilesViewed, | 3752 | workDetails(input).pipe( |
| 3612 | runAction: runActionAndInvalidate, | 3753 | Effect.map((value) => ({ |
| 3613 | update: invalidatedByMutation(update), | 3754 | projectId: input.projectId, |
| 3614 | comment: invalidatedByMutation(comment), | 3755 | repository: input.repository, |
| 3615 | updateComment: invalidatedByMutation(updateComment), | 3756 | number: value.number, |
| 3616 | submitReview: invalidatedByMutation(submitReview), | 3757 | title: value.title, |
| 3617 | replyToThread: invalidatedByMutation(replyToThread), | 3758 | url: value.url, |
| 3618 | setThreadResolution: invalidatedByMutation(setThreadResolution), | 3759 | author: value.author, |
| 3619 | setReaction: invalidatedByMutation(setReaction), | 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 | // The candidate list is deliberately read fresh per menu-open, so it stays uncached. | 3782 | // The candidate list is deliberately read fresh per menu-open, so it stays uncached. |
| 3621 | reviewerCandidates, | 3783 | reviewerCandidates: unsupportedWorkRead("reviewerCandidates", reviewerCandidates), |
| 3622 | requestReviewers: invalidatedByMutation(requestReviewers), | 3784 | requestReviewers: unsupportedWorkRead( |
| 3623 | labelCandidates, | 3785 | "requestReviewers", |
| 3624 | setLabels: invalidatedByMutation(setLabels), | 3786 | invalidatedByMutation(requestReviewers), |
| 3787 | ), | ||
| 3788 | labelCandidates: unsupportedWorkRead("labelCandidates", labelCandidates), | ||
| 3789 | setLabels: unsupportedWorkRead("setLabels", invalidatedByMutation(setLabels)), | ||
| 3625 | invalidate, | 3790 | invalidate, |
| 3626 | }); | 3791 | }); |
| 3627 | }); | 3792 | }); |
apps/server/src/server.ts+4| ... | @@ -117,6 +117,7 @@ import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; | ... | @@ -117,6 +117,7 @@ import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; |
| 117 | import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; | 117 | import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; |
| 118 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; | 118 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; |
| 119 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; | 119 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 120 | import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; | ||
| 120 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; | 121 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; |
| 121 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; | 122 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; |
| 122 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | 123 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| ... | @@ -363,11 +364,13 @@ const layerPullRequestService = PullRequestService.layer.pipe( | ... | @@ -363,11 +364,13 @@ const layerPullRequestService = PullRequestService.layer.pipe( |
| 363 | // Where the viewed-file marks live for a host that keeps none of its own. | 364 | // Where the viewed-file marks live for a host that keeps none of its own. |
| 364 | Layer.provide(PullRequestFilesViewed.layer), | 365 | Layer.provide(PullRequestFilesViewed.layer), |
| 365 | Layer.provide(PullRequestReadCache.layer), | 366 | Layer.provide(PullRequestReadCache.layer), |
| 367 | Layer.provide(MergebotQueue.layer), | ||
| 366 | Layer.provide(layerSourceControlProviderRegistry), | 368 | Layer.provide(layerSourceControlProviderRegistry), |
| 367 | Layer.provide(SourceControlRateLimit.layer), | 369 | Layer.provide(SourceControlRateLimit.layer), |
| 368 | ); | 370 | ); |
| 369 | 371 | ||
| 370 | const layerGitManager = GitManager.layer.pipe( | 372 | const layerGitManager = GitManager.layer.pipe( |
| 373 | Layer.provide(MergebotQueue.layer), | ||
| 371 | // Per-project git settings resolve the acting thread's project. | 374 | // Per-project git settings resolve the acting thread's project. |
| 372 | Layer.provide(Layer.merge(ProjectionStoreV2.layer, ProjectStore.layer)), | 375 | Layer.provide(Layer.merge(ProjectionStoreV2.layer, ProjectStore.layer)), |
| 373 | Layer.provideMerge(RuntimeLayer.layerProjectSetupScriptRunner), | 376 | Layer.provideMerge(RuntimeLayer.layerProjectSetupScriptRunner), |
| ... | @@ -646,6 +649,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( | ... | @@ -646,6 +649,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( |
| 646 | Layer.provideMerge(RuntimeLayer.layerEventInfrastructure), | 649 | Layer.provideMerge(RuntimeLayer.layerEventInfrastructure), |
| 647 | Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), | 650 | Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), |
| 648 | Layer.provideMerge(layerServerSettings), | 651 | Layer.provideMerge(layerServerSettings), |
| 652 | Layer.provideMerge(MergebotQueue.layer), | ||
| 649 | // The asset route uses the registry's GitHub credential for private PR media, which the | 653 | // The asset route uses the registry's GitHub credential for private PR media, which the |
| 650 | // built-in drivers' layer provides alongside the registry. | 654 | // built-in drivers' layer provides alongside the registry. |
| 651 | Layer.provideMerge(layerSourceControlProviderRegistry), | 655 | Layer.provideMerge(layerSourceControlProviderRegistry), |
apps/server/src/ws.ts+5| ... | @@ -189,6 +189,7 @@ import { parseBase64DataUrl } from "./imageMime.ts"; | ... | @@ -189,6 +189,7 @@ import { parseBase64DataUrl } from "./imageMime.ts"; |
| 189 | import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; | 189 | import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; |
| 190 | import * as PortScanner from "./preview/PortScanner.ts"; | 190 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 191 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; | 191 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 192 | import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; | ||
| 192 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; | 193 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; |
| 193 | import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; | 194 | import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; |
| 194 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; | 195 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; |
| ... | @@ -1169,6 +1170,7 @@ const layerWsRpc = ( | ... | @@ -1169,6 +1170,7 @@ const layerWsRpc = ( |
| 1169 | const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; | 1170 | const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; |
| 1170 | const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; | 1171 | const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; |
| 1171 | const secretRequests = yield* SecretRequests.SecretRequests; | 1172 | const secretRequests = yield* SecretRequests.SecretRequests; |
| 1173 | const mergebotQueue = yield* MergebotQueue.MergebotQueue; | ||
| 1172 | const scriptProgress = yield* ScriptProgress.ScriptProgress; | 1174 | const scriptProgress = yield* ScriptProgress.ScriptProgress; |
| 1173 | const pullRequests = yield* PullRequestService.PullRequestService; | 1175 | const pullRequests = yield* PullRequestService.PullRequestService; |
| 1174 | const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; | 1176 | const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; |
| ... | @@ -1673,6 +1675,7 @@ const layerWsRpc = ( | ... | @@ -1673,6 +1675,7 @@ const layerWsRpc = ( |
| 1673 | otlpLogsEnabled: config.otlpLogsUrl !== undefined, | 1675 | otlpLogsEnabled: config.otlpLogsUrl !== undefined, |
| 1674 | }, | 1676 | }, |
| 1675 | settings, | 1677 | settings, |
| 1678 | ...(mergebotQueue.configuration ? { mergebot: mergebotQueue.configuration } : {}), | ||
| 1676 | shellResumeCompletionMarker: true, | 1679 | shellResumeCompletionMarker: true, |
| 1677 | threadResumeCompletionMarker: true, | 1680 | threadResumeCompletionMarker: true, |
| 1678 | threadSnapshotPagination: true, | 1681 | threadSnapshotPagination: true, |
| ... | @@ -2409,6 +2412,8 @@ const layerWsRpc = ( | ... | @@ -2409,6 +2412,8 @@ const layerWsRpc = ( |
| 2409 | Effect.forkScoped, | 2412 | Effect.forkScoped, |
| 2410 | ), | 2413 | ), |
| 2411 | ), | 2414 | ), |
| 2415 | [WS_METHODS.mergebotQueue]: (input) => mergebotQueue.read(input), | ||
| 2416 | [WS_METHODS.mergebotSubscribe]: (input) => mergebotQueue.watchQueue(input), | ||
| 2412 | [WS_METHODS.pullRequestsList]: (input) => pullRequests.list(input), | 2417 | [WS_METHODS.pullRequestsList]: (input) => pullRequests.list(input), |
| 2413 | [WS_METHODS.pullRequestsListStats]: (input) => pullRequests.listStats(input), | 2418 | [WS_METHODS.pullRequestsListStats]: (input) => pullRequests.listStats(input), |
| 2414 | [WS_METHODS.pullRequestsRoutingIdentity]: (input) => pullRequests.routingIdentity(input), | 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,6 +11237,12 @@ export default function ChatView(props: ChatViewProps) { |
| 11237 | } | 11237 | } |
| 11238 | key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`} | 11238 | key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`} |
| 11239 | environmentId={activeThread.environmentId} | 11239 | environmentId={activeThread.environmentId} |
| 11240 | onMergebotReady={(tabId) => | ||
| 11241 | activeThreadRef !== null && | ||
| 11242 | useRightPanelStore | ||
| 11243 | .getState() | ||
| 11244 | .replacePullRequestWithBrowser(activeThreadRef, renderedRightPanelSurface.id, tabId) | ||
| 11245 | } | ||
| 11240 | onSelectPullRequest={(reference) => { | 11246 | onSelectPullRequest={(reference) => { |
| 11241 | if (activeThreadRef) | 11247 | if (activeThreadRef) |
| 11242 | useRightPanelStore.getState().openPullRequest(activeThreadRef, { | 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,10 +13,11 @@ import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 13 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 13 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 14 | import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; | 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 | newThread: vi.fn(), | 17 | newThread: vi.fn(), |
| 18 | prepareThread: vi.fn(), | 18 | prepareThread: vi.fn(), |
| 19 | refresh: vi.fn(), | 19 | refresh: vi.fn(), |
| 20 | browserState: { configured: false }, | ||
| 20 | Wrapper: ({ children }: { children?: ReactNode }) => children, | 21 | Wrapper: ({ children }: { children?: ReactNode }) => children, |
| 21 | Trigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => ( | 22 | Trigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => ( |
| 22 | <> | 23 | <> |
| ... | @@ -32,8 +33,25 @@ vi.mock("~/state/session", async (original) => ({ | ... | @@ -32,8 +33,25 @@ vi.mock("~/state/session", async (original) => ({ |
| 32 | })); | 33 | })); |
| 33 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); | 34 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); |
| 34 | vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: {} })); | 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 | vi.mock("~/state/environments", () => ({ | 53 | vi.mock("~/state/environments", () => ({ |
| 54 | useEnvironment: () => null, | ||
| 37 | useEnvironments: () => ({ environments: [] }), | 55 | useEnvironments: () => ({ environments: [] }), |
| 38 | usePrimaryEnvironmentId: () => EnvironmentId.make("env-1"), | 56 | usePrimaryEnvironmentId: () => EnvironmentId.make("env-1"), |
| 39 | })); | 57 | })); |
| ... | @@ -226,6 +244,7 @@ const newDraftId = DraftId.make("new-draft"); | ... | @@ -226,6 +244,7 @@ const newDraftId = DraftId.make("new-draft"); |
| 226 | let renderer: ReactTestRenderer; | 244 | let renderer: ReactTestRenderer; |
| 227 | 245 | ||
| 228 | beforeEach(() => { | 246 | beforeEach(() => { |
| 247 | browserState.configured = false; | ||
| 229 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | 248 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 230 | vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); | 249 | vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); |
| 231 | useComposerDraftStore.setState({ draftsByThreadKey: {} }); | 250 | useComposerDraftStore.setState({ draftsByThreadKey: {} }); |
| ... | @@ -436,3 +455,82 @@ it("changes the title only for the active tab's main scroller", async () => { | ... | @@ -436,3 +455,82 @@ it("changes the title only for the active tab's main scroller", async () => { |
| 436 | await act(async () => mainScroll.props.onScrollCapture({ target: code })); | 455 | await act(async () => mainScroll.props.onScrollCapture({ target: code })); |
| 437 | expect(titleFold().props.inert).toBe(false); | 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,3 +1,9 @@ |
| 1 | import { | ||
| 2 | mergebotPullRequestUrl, | ||
| 3 | mergebotPullRequestBrowserThreadId, | ||
| 4 | ThreadId, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { MergebotQueueBadge } from "./MergebotQueueBadge"; | ||
| 1 | import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; | 7 | import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | 8 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import { usePullRequestStack } from "~/state/usePullRequestStack"; | 9 | import { usePullRequestStack } from "~/state/usePullRequestStack"; |
| ... | @@ -412,7 +418,68 @@ function PullRequestBaseFreshnessWarning({ | ... | @@ -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 | environmentId, | 483 | environmentId, |
| 417 | shortcutsEnabled, | 484 | shortcutsEnabled, |
| 418 | getShortcutContext, | 485 | getShortcutContext, |
| ... | @@ -432,6 +499,7 @@ export function PullRequestDetailPanel({ | ... | @@ -432,6 +499,7 @@ export function PullRequestDetailPanel({ |
| 432 | shortcutsEnabled: boolean; | 499 | shortcutsEnabled: boolean; |
| 433 | getShortcutContext: () => ShortcutMatchContext; | 500 | getShortcutContext: () => ShortcutMatchContext; |
| 434 | onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined; | 501 | onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined; |
| 502 | onMergebotReady?: ((tabId: string) => boolean) | undefined; | ||
| 435 | /** | 503 | /** |
| 436 | * The thread this panel sits beside, if any. Links that are not the pull | 504 | * The thread this panel sits beside, if any. Links that are not the pull |
| 437 | * request itself (check details, host permalinks) can open in that thread's | 505 | * request itself (check details, host permalinks) can open in that thread's |
| ... | @@ -2775,6 +2843,16 @@ export function PullRequestDetailPanel({ | ... | @@ -2775,6 +2843,16 @@ export function PullRequestDetailPanel({ |
| 2775 | } | 2843 | } |
| 2776 | /> | 2844 | /> |
| 2777 | ) : null} | 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 | </div> | 2856 | </div> |
| 2779 | </div> | 2857 | </div> |
| 2780 | 2858 |
apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx+8| ... | @@ -48,6 +48,7 @@ import { | ... | @@ -48,6 +48,7 @@ import { |
| 48 | } from "./pullRequestPresentation"; | 48 | } from "./pullRequestPresentation"; |
| 49 | import { PullRequestGlyph } from "./pullRequestIcons"; | 49 | import { PullRequestGlyph } from "./pullRequestIcons"; |
| 50 | import { pullRequestQuickActions } from "./pullRequestList.logic"; | 50 | import { pullRequestQuickActions } from "./pullRequestList.logic"; |
| 51 | import { MergebotQueueBadge } from "./MergebotQueueBadge"; | ||
| 51 | import { PullRequestSpeedActions } from "./PullRequestSpeedActions"; | 52 | import { PullRequestSpeedActions } from "./PullRequestSpeedActions"; |
| 52 | 53 | ||
| 53 | const SOURCE_LABELS: Record<ThreadPullRequestLink["source"], string> = { | 54 | const SOURCE_LABELS: Record<ThreadPullRequestLink["source"], string> = { |
| ... | @@ -206,6 +207,13 @@ function LinkRow({ | ... | @@ -206,6 +207,13 @@ function LinkRow({ |
| 206 | } | 207 | } |
| 207 | meta={ | 208 | meta={ |
| 208 | <> | 209 | <> |
| 210 | {open ? ( | ||
| 211 | <MergebotQueueBadge | ||
| 212 | environmentId={threadRef.environmentId} | ||
| 213 | repository={link.repository} | ||
| 214 | number={link.number} | ||
| 215 | /> | ||
| 216 | ) : null} | ||
| 209 | {stack ? ( | 217 | {stack ? ( |
| 210 | <Tooltip> | 218 | <Tooltip> |
| 211 | <TooltipTrigger | 219 | <TooltipTrigger |
apps/web/src/components/sidebar/SidebarChrome.tsx+3-49| ... | @@ -1,11 +1,9 @@ | ... | @@ -1,11 +1,9 @@ |
| 1 | import { ArrowLeftIcon } from "lucide-react"; | 1 | import { ArrowLeftIcon } from "lucide-react"; |
| 2 | import type { ReactNode } from "react"; | ||
| 3 | import { memo, useCallback } from "react"; | 2 | import { memo, useCallback } from "react"; |
| 4 | import { useLocation, useNavigate } from "@tanstack/react-router"; | 3 | import { useLocation } from "@tanstack/react-router"; |
| 5 | 4 | ||
| 6 | import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; | 5 | import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; |
| 7 | import { cn } from "../../lib/utils"; | 6 | import { cn } from "../../lib/utils"; |
| 8 | import { usePullRequestsSupported } from "../../state/environments"; | ||
| 9 | import { | 7 | import { |
| 10 | resolveEnvironmentIdentificationPillLabel, | 8 | resolveEnvironmentIdentificationPillLabel, |
| 11 | useEnvironmentStageLabel, | 9 | useEnvironmentStageLabel, |
| ... | @@ -19,12 +17,10 @@ import { | ... | @@ -19,12 +17,10 @@ import { |
| 19 | SidebarTrigger, | 17 | SidebarTrigger, |
| 20 | useSidebar, | 18 | useSidebar, |
| 21 | } from "../ui/sidebar"; | 19 | } from "../ui/sidebar"; |
| 22 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 23 | import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences"; | ||
| 24 | import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation"; | 20 | import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation"; |
| 25 | import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; | 21 | import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; |
| 26 | import { AppMenu } from "./AppMenu"; | 22 | import { AppMenu } from "./AppMenu"; |
| 27 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; | 23 | import { SidebarMergebotButton } from "./SidebarMergebotButton"; |
| 28 | import { SidebarUsageButton } from "./SidebarUsageButton"; | 24 | import { SidebarUsageButton } from "./SidebarUsageButton"; |
| 29 | 25 | ||
| 30 | export const SidebarChromeHeader = memo(function SidebarChromeHeader({ | 26 | export const SidebarChromeHeader = memo(function SidebarChromeHeader({ |
| ... | @@ -65,51 +61,17 @@ 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 | export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { | 64 | export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { |
| 94 | const navigate = useNavigate(); | ||
| 95 | const navigateToMainApp = useNavigateToMainApp(); | 65 | const navigateToMainApp = useNavigateToMainApp(); |
| 96 | const { isMobile, setOpenMobile } = useSidebar(); | 66 | const { isMobile, setOpenMobile } = useSidebar(); |
| 97 | const isOnUtilityPage = useLocation({ | 67 | const isOnUtilityPage = useLocation({ |
| 98 | select: (location) => isSidebarUtilityPage(location.pathname), | 68 | select: (location) => isSidebarUtilityPage(location.pathname), |
| 99 | }); | 69 | }); |
| 100 | const pullRequestsSupported = usePullRequestsSupported(); | ||
| 101 | const closeMobileSidebar = useCallback(() => { | 70 | const closeMobileSidebar = useCallback(() => { |
| 102 | if (isMobile) { | 71 | if (isMobile) { |
| 103 | setOpenMobile(false); | 72 | setOpenMobile(false); |
| 104 | } | 73 | } |
| 105 | }, [isMobile, setOpenMobile]); | 74 | }, [isMobile, setOpenMobile]); |
| 106 | const handlePullRequestsClick = useCallback(() => { | ||
| 107 | closeMobileSidebar(); | ||
| 108 | void navigate({ | ||
| 109 | to: "/pull-requests", | ||
| 110 | search: readPullRequestListPreferences(), | ||
| 111 | }); | ||
| 112 | }, [closeMobileSidebar, navigate]); | ||
| 113 | const handleBackClick = useCallback(() => { | 75 | const handleBackClick = useCallback(() => { |
| 114 | closeMobileSidebar(); | 76 | closeMobileSidebar(); |
| 115 | void navigateToMainApp(); | 77 | void navigateToMainApp(); |
| ... | @@ -125,15 +87,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { | ... | @@ -125,15 +87,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { |
| 125 | </SidebarMenuButton> | 87 | </SidebarMenuButton> |
| 126 | </SidebarMenuItem> | 88 | </SidebarMenuItem> |
| 127 | ) : ( | 89 | ) : ( |
| 128 | <> | 90 | <SidebarMergebotButton /> |
| 129 | {pullRequestsSupported ? ( | ||
| 130 | <SidebarUtilityItem | ||
| 131 | icon={<PullRequestGlyph.pullRequest />} | ||
| 132 | label="Pull Requests" | ||
| 133 | onClick={handlePullRequestsClick} | ||
| 134 | /> | ||
| 135 | ) : null} | ||
| 136 | </> | ||
| 137 | )} | 91 | )} |
| 138 | </SidebarMenu> | 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,5 +1,10 @@ |
| 1 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; | 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 | import { useMemo } from "react"; | 8 | import { useMemo } from "react"; |
| 4 | 9 | ||
| 5 | import { | 10 | import { |
| ... | @@ -9,7 +14,7 @@ import { | ... | @@ -9,7 +14,7 @@ import { |
| 9 | } from "../components/pullRequest/pullRequestDetail.logic"; | 14 | } from "../components/pullRequest/pullRequestDetail.logic"; |
| 10 | import { gitHubPullRequestBrowserUrl } from "../lib/openPullRequestLink"; | 15 | import { gitHubPullRequestBrowserUrl } from "../lib/openPullRequestLink"; |
| 11 | import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanelStore"; | 16 | import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanelStore"; |
| 12 | import { useProject } from "../state/entities"; | 17 | import { useProject, useServerConfigs } from "../state/entities"; |
| 13 | import { pullRequestEnvironment } from "../state/pullRequests"; | 18 | import { pullRequestEnvironment } from "../state/pullRequests"; |
| 14 | import { useEnvironmentQuery } from "../state/query"; | 19 | import { useEnvironmentQuery } from "../state/query"; |
| 15 | import { useSupportsMultiplePullRequests } from "./useSupportsMultiplePullRequests"; | 20 | import { useSupportsMultiplePullRequests } from "./useSupportsMultiplePullRequests"; |
| ... | @@ -45,8 +50,13 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { | ... | @@ -45,8 +50,13 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { |
| 45 | ) | 50 | ) |
| 46 | : input; | 51 | : input; |
| 47 | }, [requestedReference, supportsMultiplePullRequests, project?.repositoryIdentity]); | 52 | }, [requestedReference, supportsMultiplePullRequests, project?.repositoryIdentity]); |
| 48 | const detail = useEnvironmentQuery( | 53 | const configurations = useServerConfigs(); |
| 54 | const mergebotUrl = | ||
| 49 | reference && environmentId | 55 | reference && environmentId |
| 56 | ? mergebotPullRequestUrl(configurations.get(environmentId)?.mergebot, reference) | ||
| 57 | : null; | ||
| 58 | const detail = useEnvironmentQuery( | ||
| 59 | reference && environmentId && !mergebotUrl | ||
| 50 | ? pullRequestEnvironment.detail({ | 60 | ? pullRequestEnvironment.detail({ |
| 51 | environmentId, | 61 | environmentId, |
| 52 | input: reference, | 62 | input: reference, |
| ... | @@ -64,13 +74,17 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { | ... | @@ -64,13 +74,17 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { |
| 64 | : null, | 74 | : null, |
| 65 | [environmentId, reference], | 75 | [environmentId, reference], |
| 66 | ); | 76 | ); |
| 67 | return reference | 77 | return ( |
| 68 | ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference })?.url ?? | 78 | mergebotUrl ?? |
| 79 | (reference | ||
| 80 | ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference }) | ||
| 81 | ?.url ?? | ||
| 69 | requestedReference?.url ?? | 82 | requestedReference?.url ?? |
| 70 | gitHubPullRequestBrowserUrl( | 83 | gitHubPullRequestBrowserUrl( |
| 71 | project?.repositoryIdentity, | 84 | project?.repositoryIdentity, |
| 72 | reference.repository, | 85 | reference.repository, |
| 73 | reference.number, | 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,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 | import { useAtomValue } from "@effect/atom-react"; | 8 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import { useNavigate } from "@tanstack/react-router"; | 9 | import { useNavigate } from "@tanstack/react-router"; |
| 4 | import { type MouseEvent, useCallback, useMemo } from "react"; | 10 | import { type MouseEvent, useCallback, useMemo } from "react"; |
| ... | @@ -12,7 +18,7 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast"; | ... | @@ -12,7 +18,7 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast"; |
| 12 | import { useRightPanelStore } from "../rightPanelStore"; | 18 | import { useRightPanelStore } from "../rightPanelStore"; |
| 13 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | 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 | import { serverEnvironment } from "../state/server"; | 22 | import { serverEnvironment } from "../state/server"; |
| 17 | import { usePrimaryEnvironmentId } from "../state/environments"; | 23 | import { usePrimaryEnvironmentId } from "../state/environments"; |
| 18 | 24 | ||
| ... | @@ -150,7 +156,18 @@ export function useOpenChangeRequestLink( | ... | @@ -150,7 +156,18 @@ export function useOpenChangeRequestLink( |
| 150 | if (shouldOpenPullRequestExternally(event)) return false; | 156 | if (shouldOpenPullRequestExternally(event)) return false; |
| 151 | const resolvedThreadRef = targetThreadRef ?? threadRef; | 157 | const resolvedThreadRef = targetThreadRef ?? threadRef; |
| 152 | const resolvedPanelRef = panelRef ?? resolvedThreadRef; | 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 | if (parsed === null) return false; | 171 | if (parsed === null) return false; |
| 155 | const reads = (environmentId: string) => | 172 | const reads = (environmentId: string) => |
| 156 | serverConfigs.get(environmentId as EnvironmentId)?.environment.capabilities.pullRequests === | 173 | serverConfigs.get(environmentId as EnvironmentId)?.environment.capabilities.pullRequests === |
| ... | @@ -173,6 +190,9 @@ export function useOpenChangeRequestLink( | ... | @@ -173,6 +190,9 @@ export function useOpenChangeRequestLink( |
| 173 | Number(left.environmentId === primaryEnvironmentId), | 190 | Number(left.environmentId === primaryEnvironmentId), |
| 174 | ); | 191 | ); |
| 175 | const exactProject = findProjectForChangeRequest(projects, parsed); | 192 | const exactProject = findProjectForChangeRequest(projects, parsed); |
| 193 | const threadProjectId = resolvedThreadRef | ||
| 194 | ? readThreadShell(resolvedThreadRef)?.projectId | ||
| 195 | : undefined; | ||
| 176 | const project = | 196 | const project = |
| 177 | exactProject ?? | 197 | exactProject ?? |
| 178 | (resolvedPanelRef | 198 | (resolvedPanelRef |
| ... | @@ -184,8 +204,30 @@ export function useOpenChangeRequestLink( | ... | @@ -184,8 +204,30 @@ export function useOpenChangeRequestLink( |
| 184 | ), | 204 | ), |
| 185 | parsed, | 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 | : undefined); | 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 | const repository = | 231 | const repository = |
| 190 | serverConfigs.get(project.environmentId)?.environment.capabilities.threadPullRequests === | 232 | serverConfigs.get(project.environmentId)?.environment.capabilities.threadPullRequests === |
| 191 | true | 233 | true |
apps/web/src/rightPanelStore.test.ts+51| ... | @@ -1240,3 +1240,54 @@ describe("rightPanelStore", () => { | ... | @@ -1240,3 +1240,54 @@ describe("rightPanelStore", () => { |
| 1240 | expect(store.getUserActionRevision(refA)).toBe(revision); | 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,6 +151,11 @@ interface RightPanelStoreState { |
| 151 | openDevice: (ref: ScopedThreadRef, target: DeviceTabTarget, automatic?: boolean) => void; | 151 | openDevice: (ref: ScopedThreadRef, target: DeviceTabTarget, automatic?: boolean) => void; |
| 152 | renameDevice: (ref: ScopedThreadRef, surfaceId: string, title: string) => void; | 152 | renameDevice: (ref: ScopedThreadRef, surfaceId: string, title: string) => void; |
| 153 | openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; | 153 | openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; |
| 154 | replacePullRequestWithBrowser: ( | ||
| 155 | ref: ScopedThreadRef, | ||
| 156 | surfaceId: string, | ||
| 157 | tabId: string, | ||
| 158 | ) => boolean; | ||
| 154 | openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void; | 159 | openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void; |
| 155 | openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void; | 160 | openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void; |
| 156 | openPullRequest: ( | 161 | openPullRequest: ( |
| ... | @@ -692,6 +697,30 @@ export const useRightPanelStore = create<RightPanelStoreState>()( | ... | @@ -692,6 +697,30 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 692 | return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); | 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 | openPullRequest: (ref, target) => | 724 | openPullRequest: (ref, target) => |
| 696 | set((state) => | 725 | set((state) => |
| 697 | userAction(state, scopedThreadKey(ref), (current) => { | 726 | userAction(state, scopedThreadKey(ref), (current) => { |
apps/web/src/routes/_chat.pull-requests.tsx+128-19| ... | @@ -1,3 +1,14 @@ | ... | @@ -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 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | 12 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | import { Spinner } from "~/components/ui/spinner"; | 13 | import { Spinner } from "~/components/ui/spinner"; |
| 3 | import { useShortcutModifierState } from "~/shortcutModifierState"; | 14 | import { useShortcutModifierState } from "~/shortcutModifierState"; |
| ... | @@ -153,6 +164,7 @@ import { | ... | @@ -153,6 +164,7 @@ import { |
| 153 | selectThreadRightPanelState, | 164 | selectThreadRightPanelState, |
| 154 | useRightPanelStore, | 165 | useRightPanelStore, |
| 155 | type PullRequestSurface, | 166 | type PullRequestSurface, |
| 167 | type RightPanelSurface, | ||
| 156 | } from "../rightPanelStore"; | 168 | } from "../rightPanelStore"; |
| 157 | import { useDebouncedValue } from "../state/queries"; | 169 | import { useDebouncedValue } from "../state/queries"; |
| 158 | import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities"; | 170 | import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities"; |
| ... | @@ -185,6 +197,7 @@ function getShortcutContext() { | ... | @@ -185,6 +197,7 @@ function getShortcutContext() { |
| 185 | } | 197 | } |
| 186 | 198 | ||
| 187 | export interface PullRequestsSearch extends PullRequestListPreferences { | 199 | export interface PullRequestsSearch extends PullRequestListPreferences { |
| 200 | readonly mergebot?: EnvironmentId; | ||
| 188 | /** | 201 | /** |
| 189 | * Narrows the list to one server. Absent means every connected one, which is the default the | 202 | * Narrows the list to one server. Absent means every connected one, which is the default the |
| 190 | * page has now — so a link written before servers could be chosen still opens the whole list. | 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,6 +319,9 @@ function pullRequestSearchLabels(raw: unknown): Partial<Pick<PullRequestsSearch, |
| 306 | 319 | ||
| 307 | export const Route = createFileRoute("/_chat/pull-requests")({ | 320 | export const Route = createFileRoute("/_chat/pull-requests")({ |
| 308 | validateSearch: (raw: Record<string, unknown>): PullRequestsSearch => ({ | 321 | validateSearch: (raw: Record<string, unknown>): PullRequestsSearch => ({ |
| 322 | ...(typeof raw.mergebot === "string" && raw.mergebot | ||
| 323 | ? { mergebot: raw.mergebot as EnvironmentId } | ||
| 324 | : {}), | ||
| 309 | involvement: | 325 | involvement: |
| 310 | raw.involvement === "reviewing" || raw.involvement === "authored" ? raw.involvement : "all", | 326 | raw.involvement === "reviewing" || raw.involvement === "authored" ? raw.involvement : "all", |
| 311 | state: | 327 | state: |
| ... | @@ -356,6 +372,50 @@ export const Route = createFileRoute("/_chat/pull-requests")({ | ... | @@ -356,6 +372,50 @@ export const Route = createFileRoute("/_chat/pull-requests")({ |
| 356 | 372 | ||
| 357 | function PullRequestsRouteView() { | 373 | function PullRequestsRouteView() { |
| 358 | useEscapeToGoBack(); | 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 | const modifiers = useShortcutModifierState(true); | 419 | const modifiers = useShortcutModifierState(true); |
| 360 | const speedMode = | 420 | const speedMode = |
| 361 | modifiers.shiftKey && !modifiers.metaKey && !modifiers.ctrlKey && !modifiers.altKey; | 421 | modifiers.shiftKey && !modifiers.metaKey && !modifiers.ctrlKey && !modifiers.altKey; |
| ... | @@ -2118,6 +2178,28 @@ function PullRequestsRouteView() { | ... | @@ -2118,6 +2178,28 @@ function PullRequestsRouteView() { |
| 2118 | searchInput, | 2178 | searchInput, |
| 2119 | sortMenu, | 2179 | sortMenu, |
| 2120 | filtersMenu, | 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 | rightPanelControl: | 2203 | rightPanelControl: |
| 2122 | // Footprint reserve while the panel is closed: the toggle itself stays | 2204 | // Footprint reserve while the panel is closed: the toggle itself stays |
| 2123 | // mounted at the fixed titlebar inset in both states so it cannot move | 2205 | // mounted at the fixed titlebar inset in both states so it cannot move |
| ... | @@ -2165,34 +2247,51 @@ function PullRequestsRouteView() { | ... | @@ -2165,34 +2247,51 @@ function PullRequestsRouteView() { |
| 2165 | useRightPanelStore.getState().activateSurface(rightPanelRef, surface.id); | 2247 | useRightPanelStore.getState().activateSurface(rightPanelRef, surface.id); |
| 2166 | selectSurfaceInUrl(surface); | 2248 | selectSurfaceInUrl(surface); |
| 2167 | }; | 2249 | }; |
| 2168 | const closeSurface = (surface: PullRequestSurface) => { | 2250 | const closeSurfaces = async (surfaces: readonly RightPanelSurface[]) => { |
| 2169 | if (rightPanelRef === null) return; | 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 | const next = selectActiveRightPanelSurface( | 2255 | const next = selectActiveRightPanelSurface( |
| 2172 | useRightPanelStore.getState().byThreadKey, | 2256 | useRightPanelStore.getState().byThreadKey, |
| 2173 | rightPanelRef, | 2257 | rightPanelRef, |
| 2174 | ); | 2258 | ); |
| 2175 | selectSurfaceInUrl(next?.kind === "pull-request" ? next : null); | 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) => { | 2287 | const closeSurface = (surface: PullRequestSurface) => void closeSurfaces([surface]); |
| 2178 | if (rightPanelRef === null) return; | 2288 | const closeOtherSurfaces = (surface: PullRequestSurface) => |
| 2179 | useRightPanelStore.getState().closeOtherSurfaces(rightPanelRef, surface.id); | 2289 | void closeSurfaces(rightPanelState.surfaces.filter((entry) => entry.id !== surface.id)); |
| 2180 | selectSurfaceInUrl(surface); | ||
| 2181 | }; | ||
| 2182 | const closeSurfacesToRight = (surface: PullRequestSurface) => { | 2290 | const closeSurfacesToRight = (surface: PullRequestSurface) => { |
| 2183 | if (rightPanelRef === null) return; | 2291 | const index = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id); |
| 2184 | useRightPanelStore.getState().closeSurfacesToRight(rightPanelRef, surface.id); | 2292 | if (index >= 0) void closeSurfaces(rightPanelState.surfaces.slice(index + 1)); |
| 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); | ||
| 2195 | }; | 2293 | }; |
| 2294 | const closeAllSurfaces = () => void closeSurfaces(rightPanelState.surfaces); | ||
| 2196 | 2295 | ||
| 2197 | // This page has no ChatView, so it handles the shared panel shortcuts itself. | 2296 | // This page has no ChatView, so it handles the shared panel shortcuts itself. |
| 2198 | const copyPullRequestFromShortcut = useEffectEvent((event: KeyboardEvent) => { | 2297 | const copyPullRequestFromShortcut = useEffectEvent((event: KeyboardEvent) => { |
| ... | @@ -2314,6 +2413,13 @@ function PullRequestsRouteView() { | ... | @@ -2314,6 +2413,13 @@ function PullRequestsRouteView() { |
| 2314 | shortcutsEnabled={activePullRequestSurface?.id === renderedPullRequestSurface.id} | 2413 | shortcutsEnabled={activePullRequestSurface?.id === renderedPullRequestSurface.id} |
| 2315 | key={renderedPullRequestSurface.id} | 2414 | key={renderedPullRequestSurface.id} |
| 2316 | environmentId={panelEnvironmentId} | 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 | onGuideShown={onGuideShown} | 2423 | onGuideShown={onGuideShown} |
| 2318 | onSelectPullRequest={(reference) => { | 2424 | onSelectPullRequest={(reference) => { |
| 2319 | if (rightPanelRef === null) return; | 2425 | if (rightPanelRef === null) return; |
| ... | @@ -2574,6 +2680,7 @@ function PullRequestsColumn({ | ... | @@ -2574,6 +2680,7 @@ function PullRequestsColumn({ |
| 2574 | searchInput, | 2680 | searchInput, |
| 2575 | sortMenu, | 2681 | sortMenu, |
| 2576 | filtersMenu, | 2682 | filtersMenu, |
| 2683 | mergebotControl, | ||
| 2577 | rightPanelControl, | 2684 | rightPanelControl, |
| 2578 | titlebarControls, | 2685 | titlebarControls, |
| 2579 | rightPanelOpen, | 2686 | rightPanelOpen, |
| ... | @@ -2594,6 +2701,7 @@ function PullRequestsColumn({ | ... | @@ -2594,6 +2701,7 @@ function PullRequestsColumn({ |
| 2594 | searchInput: ReactNode; | 2701 | searchInput: ReactNode; |
| 2595 | sortMenu: ReactNode; | 2702 | sortMenu: ReactNode; |
| 2596 | filtersMenu: ReactNode; | 2703 | filtersMenu: ReactNode; |
| 2704 | mergebotControl: ReactNode; | ||
| 2597 | rightPanelControl: ReactNode; | 2705 | rightPanelControl: ReactNode; |
| 2598 | titlebarControls: ReactNode; | 2706 | titlebarControls: ReactNode; |
| 2599 | rightPanelOpen: boolean; | 2707 | rightPanelOpen: boolean; |
| ... | @@ -2752,6 +2860,7 @@ function PullRequestsColumn({ | ... | @@ -2752,6 +2860,7 @@ function PullRequestsColumn({ |
| 2752 | </div> | 2860 | </div> |
| 2753 | {sortMenu} | 2861 | {sortMenu} |
| 2754 | {filtersMenu} | 2862 | {filtersMenu} |
| 2863 | {mergebotControl} | ||
| 2755 | <CompactFilterMenu | 2864 | <CompactFilterMenu |
| 2756 | label="Filter by provider" | 2865 | label="Filter by provider" |
| 2757 | outlined | 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,6 +68,7 @@ export type EnvironmentSubscriptionRpcTag = |
| 68 | | typeof WS_METHODS.subscribeDeviceState | 68 | | typeof WS_METHODS.subscribeDeviceState |
| 69 | | typeof WS_METHODS.subscribeResourceTelemetry | 69 | | typeof WS_METHODS.subscribeResourceTelemetry |
| 70 | | typeof WS_METHODS.pullRequestsSubscribeRefreshes | 70 | | typeof WS_METHODS.pullRequestsSubscribeRefreshes |
| 71 | | typeof WS_METHODS.mergebotSubscribe | ||
| 71 | | typeof WS_METHODS.subscribeVcsStatus | 72 | | typeof WS_METHODS.subscribeVcsStatus |
| 72 | | typeof WS_METHODS.subscribeWorktreeSetup | 73 | | typeof WS_METHODS.subscribeWorktreeSetup |
| 73 | | typeof WS_METHODS.subscribeProjectClones | 74 | | typeof WS_METHODS.subscribeProjectClones |
packages/client-runtime/src/state/pullRequests.test.ts+44| ... | @@ -9,6 +9,7 @@ import { | ... | @@ -9,6 +9,7 @@ import { |
| 9 | PullRequestOperationError, | 9 | PullRequestOperationError, |
| 10 | WS_METHODS, | 10 | WS_METHODS, |
| 11 | type PullRequestStack, | 11 | type PullRequestStack, |
| 12 | type MergebotRepoQueue, | ||
| 12 | type AuthSessionState, | 13 | type AuthSessionState, |
| 13 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 14 | import { expect, it } from "@effect/vitest"; | 15 | import { expect, it } from "@effect/vitest"; |
| ... | @@ -43,6 +44,7 @@ import { | ... | @@ -43,6 +44,7 @@ import { |
| 43 | createLinkedPullRequestSummaryAtomFamily, | 44 | createLinkedPullRequestSummaryAtomFamily, |
| 44 | createPullRequestEnvironmentAtoms, | 45 | createPullRequestEnvironmentAtoms, |
| 45 | createPullRequestStackAtomFamily, | 46 | createPullRequestStackAtomFamily, |
| 47 | createMergebotQueueAtomFamily, | ||
| 46 | } from "./pullRequests.ts"; | 48 | } from "./pullRequests.ts"; |
| 47 | import * as PullRequestDiffLoader from "./pullRequestDiffHttp.ts"; | 49 | import * as PullRequestDiffLoader from "./pullRequestDiffHttp.ts"; |
| 48 | import { executeAtomQuery } from "./runtime.ts"; | 50 | import { executeAtomQuery } from "./runtime.ts"; |
| ... | @@ -690,6 +692,48 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* ( | ... | @@ -690,6 +692,48 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* ( |
| 690 | return { runtime, atoms, registry, environmentRegistry, supervisor, localSupervisor }; | 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 | it.effect.each(["default", "origin-off", "destination-off", "read-only"] as const)( | 737 | it.effect.each(["default", "origin-off", "destination-off", "read-only"] as const)( |
| 694 | "does not probe another environment with %s routing permission", | 738 | "does not probe another environment with %s routing permission", |
| 695 | (permission) => | 739 | (permission) => |
packages/client-runtime/src/state/pullRequests.ts+9| ... | @@ -619,3 +619,12 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -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,8 +65,9 @@ export * from "./rpc.ts"; |
| 65 | export * from "./worktreeSetup.ts"; | 65 | export * from "./worktreeSetup.ts"; |
| 66 | export * from "./secretRequest.ts"; | 66 | export * from "./secretRequest.ts"; |
| 67 | export * from "./clientRpcPermissions.ts"; | 67 | export * from "./clientRpcPermissions.ts"; |
| 68 | export * from "./desktopTerminal.ts"; | 68 | export * from "./mergebot.ts"; |
| 69 | export * from "./scriptProgress.ts"; | 69 | export * from "./scriptProgress.ts"; |
| 70 | export * from "./desktopTerminal.ts"; | ||
| 70 | 71 | ||
| 71 | export * from "./uiSettings.ts"; | 72 | export * from "./uiSettings.ts"; |
| 72 | export * from "./sharedAccounts.ts"; | 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,3 +1,4 @@ |
| 1 | import { MergebotQueueInput, MergebotRepoQueue } from "./mergebot.ts"; | ||
| 1 | import { | 2 | import { |
| 2 | UiSettingsProfile, | 3 | UiSettingsProfile, |
| 3 | UiSettingsPatchInput, | 4 | UiSettingsPatchInput, |
| ... | @@ -544,6 +545,8 @@ export const WS_METHODS = { | ... | @@ -544,6 +545,8 @@ export const WS_METHODS = { |
| 544 | cloudInstallRelayClient: "cloud.installRelayClient", | 545 | cloudInstallRelayClient: "cloud.installRelayClient", |
| 545 | 546 | ||
| 546 | // Pull request methods | 547 | // Pull request methods |
| 548 | mergebotQueue: "mergebot.queue", | ||
| 549 | mergebotSubscribe: "mergebot.subscribe", | ||
| 547 | pullRequestsList: "pullRequests.list", | 550 | pullRequestsList: "pullRequests.list", |
| 548 | pullRequestsListStats: "pullRequests.listStats", | 551 | pullRequestsListStats: "pullRequests.listStats", |
| 549 | pullRequestsSummary: "pullRequests.summary", | 552 | pullRequestsSummary: "pullRequests.summary", |
| ... | @@ -990,6 +993,19 @@ const PullRequestRpcError = Schema.Union([ | ... | @@ -990,6 +993,19 @@ const PullRequestRpcError = Schema.Union([ |
| 990 | EnvironmentAuthorizationError, | 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 | const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, { | 1009 | const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, { |
| 994 | payload: PullRequestListInput, | 1010 | payload: PullRequestListInput, |
| 995 | success: PullRequestListResult, | 1011 | success: PullRequestListResult, |
| ... | @@ -2004,6 +2020,8 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -2004,6 +2020,8 @@ export const WsRpcGroup = RpcGroup.make( |
| 2004 | WsServerGetBackgroundPolicyRpc, | 2020 | WsServerGetBackgroundPolicyRpc, |
| 2005 | WsCloudGetRelayClientStatusRpc, | 2021 | WsCloudGetRelayClientStatusRpc, |
| 2006 | WsCloudInstallRelayClientRpc, | 2022 | WsCloudInstallRelayClientRpc, |
| 2023 | WsMergebotQueueRpc, | ||
| 2024 | WsMergebotSubscribeRpc, | ||
| 2007 | WsPullRequestsListRpc, | 2025 | WsPullRequestsListRpc, |
| 2008 | WsPullRequestsListStatsRpc, | 2026 | WsPullRequestsListStatsRpc, |
| 2009 | WsPullRequestsSummaryRpc, | 2027 | WsPullRequestsSummaryRpc, |
packages/contracts/src/server.ts+2| ... | @@ -28,6 +28,7 @@ import { RuntimeMode } from "./providerPolicy.ts"; | ... | @@ -28,6 +28,7 @@ import { RuntimeMode } from "./providerPolicy.ts"; |
| 28 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; | 28 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; |
| 29 | import { ServerProviderUsageLimits, UsageLimitSourceSnapshots } from "./providerUsageLimits.ts"; | 29 | import { ServerProviderUsageLimits, UsageLimitSourceSnapshots } from "./providerUsageLimits.ts"; |
| 30 | import { ServerSettings } from "./settings.ts"; | 30 | import { ServerSettings } from "./settings.ts"; |
| 31 | import { MergebotConfiguration } from "./mergebot.ts"; | ||
| 31 | 32 | ||
| 32 | const KeybindingsMalformedConfigIssue = Schema.Struct({ | 33 | const KeybindingsMalformedConfigIssue = Schema.Struct({ |
| 33 | kind: Schema.Literal("keybindings.malformed-config"), | 34 | kind: Schema.Literal("keybindings.malformed-config"), |
| ... | @@ -673,6 +674,7 @@ export const ServerDirectEndpoint = Schema.Struct({ | ... | @@ -673,6 +674,7 @@ export const ServerDirectEndpoint = Schema.Struct({ |
| 673 | export type ServerDirectEndpoint = typeof ServerDirectEndpoint.Type; | 674 | export type ServerDirectEndpoint = typeof ServerDirectEndpoint.Type; |
| 674 | 675 | ||
| 675 | export const ServerConfig = Schema.Struct({ | 676 | export const ServerConfig = Schema.Struct({ |
| 677 | mergebot: Schema.optionalKey(MergebotConfiguration), | ||
| 676 | environment: ExecutionEnvironmentDescriptor, | 678 | environment: ExecutionEnvironmentDescriptor, |
| 677 | auth: ServerAuthDescriptor, | 679 | auth: ServerAuthDescriptor, |
| 678 | cwd: TrimmedNonEmptyString, | 680 | cwd: TrimmedNonEmptyString, |