authorclover caruso <git@paperclover.net> 2026-10-13 16:16:28-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
log3ab8402d3db4645647a187fd218bbe385cc6a123
tree9cd26d29fa9424b2a43a57dc8e4a8ce7d495d6a3
parent70f13f54049d9837fe63d489015fd50cd55b98cb
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

feat(at-work): integrate live Mergebot PRs and queue

Clover wants work PRs, queue status, and lifecycle decisions to share Mergebot's live data. Read repository scope and origins from private host configuration; keep workplace identifiers and credentials outside the patch. Exchange the environment's GitHub credential for a Mergebot session and share one authenticated WebSocket. Accept verified snapshots, invalidate disconnected or superseded heads, distinguish check reruns, and release unused detail subscriptions. Configured work reads never fall back to GitHub or its cached PR data. Stream queue badges to connected clients. Open Mergebot's own PR and queue interface inside T3's Browser on web, desktop, and mobile, preserving chat ownership, tab reuse, and normal close behavior. Standalone views do not create conversations. Native operations Mergebot does not expose direct the reader to its integrated interface. Assisted-by: gpt-6.1-sol

44 files changed, 4228 insertions(+), 295 deletions(-)

apps/mobile/src/native/useOpenPullRequest.test.ts created+126
......@@ -0,0 +1,126 @@
1import { AsyncResult } from "effect/reactivity";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4const 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}));
18vi.mock("react", () => ({
19 useCallback: (callback: unknown) => callback,
20 useRef: (current: unknown) => ({ current }),
21}));
22vi.mock("@react-navigation/native", () => ({
23 useNavigation: () => ({ navigate: state.navigate, dispatch: state.dispatch }),
24 useRoute: () => ({ params: state.params }),
25 StackActions: {},
26 CommonActions: {},
27}));
28vi.mock("react-native", () => ({ Platform: { OS: "ios" }, Alert: { alert: state.alert } }));
29vi.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}));
41vi.mock("../state/entities", () => ({ useThreadShell: () => null }));
42vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {} }));
43vi.mock("../state/session", () => ({ readEnvironmentScope: () => state.allowed }));
44vi.mock("../state/preview", () => ({ previewEnvironment: { open: {}, list: {} } }));
45vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => state.open }));
46vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.list }));
47vi.mock("../lib/openExternalUrl", () => ({ tryOpenExternalUrl: state.external }));
48import { useOpenPullRequest } from "./useOpenPullRequest";
49
50beforeEach(() => {
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});
61describe("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 @@
11import { useNavigation, useRoute, StackActions } from "@react-navigation/native";
22import { nativePullRequestDestination } from "../lib/pullRequestNavigation";
3import { useCallback } from "react";
4import { Platform } from "react-native";
3import { useCallback, useRef } from "react";
4import { useAtomValue } from "@effect/atom-react";
5import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";
6import {
7 isAtomCommandInterrupted,
8 squashAtomCommandFailure,
9} from "@t3tools/client-runtime/state/runtime";
10import { Alert, Platform } from "react-native";
511import { CommonActions } from "@react-navigation/native";
612import { androidWorkspaceNavigationState } from "../lib/android-workspace-navigation";
7import { EnvironmentId, ThreadId } from "@t3tools/contracts";
13import {
14 AuthPreviewOperateScope,
15 EnvironmentId,
16 FILL_PREVIEW_VIEWPORT,
17 MERGEBOT_BROWSER_THREAD_ID,
18 ThreadId,
19 mergebotPullRequestUrl,
20 parseMergebotPullRequestUrl,
21} from "@t3tools/contracts";
822import { useThreadShell } from "../state/entities";
23import { previewEnvironment } from "../state/preview";
24import { environmentServerConfigsAtom } from "../state/server";
25import { readEnvironmentScope } from "../state/session";
26import { useAtomCommand } from "../state/use-atom-command";
27import { useAtomQueryRunner } from "../state/use-atom-query-runner";
928import { tryOpenExternalUrl } from "../lib/openExternalUrl";
1029
1130/** Use the thread's environment; the shared router resolves the repository's project there. */
......@@ -36,8 +55,83 @@ export function useOpenPullRequest(options?: {
3655 const projectId =
3756 options?.context?.projectId ?? first(routeContext?.projectId) ?? thread?.projectId;
3857 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);
3965 return useCallback(
4066 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 }
41135 const params = nativePullRequestDestination(
42136 url,
43137 environmentId && projectId
......@@ -55,6 +149,15 @@ export function useOpenPullRequest(options?: {
55149 else navigation.navigate("PullRequest", params);
56150 return true;
57151 },
58 [environmentId, projectId, navigation, replace],
152 [
153 configurations,
154 environmentId,
155 listPreviews,
156 navigation,
157 openPreview,
158 projectId,
159 replace,
160 threadId,
161 ],
59162 );
60163}
apps/server/src/auth/RpcAuthorization.test.ts+6
......@@ -165,6 +165,12 @@ describe("RPC authorization scopes", () => {
165165 );
166166 });
167167
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
168174 it("separates preview control from observation", () => {
169175 for (const method of [
170176 WS_METHODS.previewOpen,
apps/server/src/auth/RpcAuthorization.ts+2
......@@ -123,6 +123,8 @@ export const RPC_REQUIRED_SCOPES = {
123123 [WS_METHODS.scheduledTasksGetWebhookDelivery]: AuthOrchestrationOperateScope,
124124 [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope,
125125 [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope,
126 [WS_METHODS.mergebotQueue]: AuthOrchestrationReadScope,
127 [WS_METHODS.mergebotSubscribe]: AuthOrchestrationReadScope,
126128 [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,
127129 [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope,
128130 [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope,
apps/server/src/git/GitManager.test.ts+79
......@@ -27,6 +27,7 @@ import { ChildProcessSpawner } from "effect/process";
2727import { expect } from "vite-plus/test";
2828import type {
2929 ChangeRequest,
30 MergebotPullRequestDetails,
3031 GitActionProgressEvent,
3132 GitManagerServiceError,
3233 GitPreparePullRequestThreadInput,
......@@ -73,6 +74,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts";
7374import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
7475import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
7576import * as ServerSettings from "../serverSettings.ts";
77import * as MergebotQueue from "../mergebot/MergebotQueue.ts";
7678import * as GitManager from "./GitManager.ts";
7779
7880const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
......@@ -723,6 +725,7 @@ function preparePullRequestThread(
723725}
724726
725727function makeManager(input?: {
728 mergebot?: MergebotQueue.MergebotQueue["Service"];
726729 ghScenario?: FakeGhScenario;
727730 sourceControlProvider?: SourceControlProvider["Service"];
728731 textGeneration?: Partial<FakeGitTextGeneration>;
......@@ -837,6 +840,9 @@ function makeManager(input?: {
837840 );
838841
839842 const layerManager = Layer.mergeAll(
843 input?.mergebot
844 ? Layer.succeed(MergebotQueue.MergebotQueue, input.mergebot)
845 : Layer.mock(MergebotQueue.MergebotQueue)({ configuration: null }),
840846 Layer.succeed(TextGeneration.TextGeneration, textGeneration),
841847 Layer.mock(ProviderRegistry.ProviderRegistry)({
842848 getProviders: Effect.succeed([]),
......@@ -880,6 +886,79 @@ const layerGitManagerTest = GitVcsDriver.layer.pipe(
880886);
881887
882888it.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
883962 it.effect("passive worktree status streams do not start remote refreshes", () =>
884963 Effect.gen(function* () {
885964 const repoDir = yield* makeTempDir("t3code-passive-vcs-");
apps/server/src/git/GitManager.ts+125-5
......@@ -1,4 +1,4 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
1import { SourceControlProviderKind, type MergebotPullRequestDetails } from "@t3tools/contracts";
22import * as Arr from "effect/Array";
33import * as Cache from "effect/Cache";
44import * as Clock from "effect/Clock";
......@@ -68,6 +68,7 @@ import {
6868} from "@t3tools/shared/threadPullRequests";
6969
7070import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts";
71import * as MergebotQueue from "../mergebot/MergebotQueue.ts";
7172import * as TextGeneration from "../textGeneration/TextGeneration.ts";
7273import {
7374 conventionalCommitsTextGenerationPolicy,
......@@ -722,6 +723,7 @@ function toPullRequestHeadRemoteInfo(pr: {
722723
723724export const make = Effect.gen(function* () {
724725 const gitCore = yield* GitVcsDriver.GitVcsDriver;
726 const mergebot = yield* MergebotQueue.MergebotQueue;
725727 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
726728 const textGeneration = yield* TextGeneration.TextGeneration;
727729 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
......@@ -730,6 +732,28 @@ export const make = Effect.gen(function* () {
730732 const fileSystem = yield* FileSystem.FileSystem;
731733 const path = yield* Path.Path;
732734
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
733757 const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });
734758 const serverSettingsService = yield* ServerSettings.ServerSettingsService;
735759 const threads = yield* ProjectionStore.ProjectionStoreV2;
......@@ -1179,9 +1203,9 @@ export const make = Effect.gen(function* () {
11791203 // this lookup, so a merge it sees must reach the thread's link and settlement right away.
11801204 const pullRequestStateChanges = yield* PubSub.sliding<ThreadPullRequestKey>(64);
11811205 const lookupStates = new Map<string, PullRequestInfo["state"]>();
1182 const noteLookupState = (pr: PullRequestInfo) =>
1206 const noteLookupState = (pr: PullRequestInfo, reference?: ThreadPullRequestKey) =>
11831207 Effect.suspend(() => {
1184 const parsed = parseChangeRequestUrl(pr.url);
1208 const parsed = reference ?? parseChangeRequestUrl(pr.url);
11851209 if (parsed === null || parsed.number !== pr.number) return Effect.void;
11861210 const key = normalizeThreadPullRequestKey(parsed);
11871211 const id = threadPullRequestKeyOf(key);
......@@ -1244,7 +1268,27 @@ export const make = Effect.gen(function* () {
12441268 },
12451269 },
12461270 );
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 });
12481292 // A transient lookup failure (rate limit, network blip) must not clear an
12491293 // already-known PR badge, so the last successful answer per branch sticks
12501294 // around as the fallback. Keep the resolved head context with it so a
......@@ -1827,6 +1871,18 @@ export const make = Effect.gen(function* () {
18271871 | "isCrossRepository"
18281872 >,
18291873 ) {
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 }
18301886 const provider = yield* sourceControlProvider(cwd);
18311887 const probe = headBranchProbe(provider, headContext.headSelectors, "open");
18321888 for (const headSelector of probe.headSelectors) {
......@@ -1857,6 +1913,15 @@ export const make = Effect.gen(function* () {
18571913 cwd: string,
18581914 headContext: BranchHeadContext,
18591915 ) {
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 }
18601925 const parsedByNumber = new Map<number, PullRequestInfo>();
18611926
18621927 const provider = yield* sourceControlProvider(cwd);
......@@ -2604,6 +2669,7 @@ export const make = Effect.gen(function* () {
26042669 }
26052670 }
26062671 const { latest } = cached;
2672 const workRepository = mergebotRepository(cached.headContext.targetRemoteUrlKey);
26072673 if (latest === null) return null;
26082674 if (
26092675 (branch === defaultBranch ||
......@@ -2619,7 +2685,10 @@ export const make = Effect.gen(function* () {
26192685 headSha: latest.headSha ?? null,
26202686 // Hosting CLIs can select an upstream repository instead of origin.
26212687 // 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}`),
26232692 };
26242693 });
26252694 const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn(
......@@ -2663,6 +2732,48 @@ export const make = Effect.gen(function* () {
26632732 const resolvePullRequest: GitManager["Service"]["resolvePullRequest"] = Effect.fn(
26642733 "resolvePullRequest",
26652734 )(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 }
26662777 const pullRequest = yield* (yield* sourceControlProvider(input.cwd))
26672778 .getChangeRequest({
26682779 cwd: input.cwd,
......@@ -2676,6 +2787,15 @@ export const make = Effect.gen(function* () {
26762787 const preparePullRequestThread: GitManager["Service"]["preparePullRequestThread"] = Effect.fn(
26772788 "preparePullRequestThread",
26782789 )(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 }
26792799 const maybeRunSetupScript = (worktreePath: string) => {
26802800 if (!input.threadId) {
26812801 return Effect.void;
apps/server/src/mergebot/MergebotQueue.test.ts created+667
......@@ -0,0 +1,667 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as HostProcess from "@t3tools/shared/HostProcess";
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import * as Layer from "effect/Layer";
8import * as Redacted from "effect/Redacted";
9import * as Stream from "effect/Stream";
10import * as Fiber from "effect/Fiber";
11import * as Deferred from "effect/Deferred";
12import * as TestClock from "effect/testing/TestClock";
13import * as Socket from "effect/socket/Socket";
14import { HttpClient, HttpClientResponse } from "effect/http";
15import * as MergebotQueue from "./MergebotQueue.ts";
16
17const repository = "work/app";
18const url = `https://mergebot.example/${repository}`;
19const configuration = { origin: "https://mergebot.example", repositories: [repository] };
20const 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};
44const 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};
54const repoChannel = { kind: "repo_overview", repoId: repository };
55const detailChannel = { kind: "pull_request_detail", repoId: repository, prNumber: 123 };
56const 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};
66const 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
83class 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
117function 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
190const nextChange = (service: MergebotQueue.MergebotQueue["Service"]) =>
191 Stream.runHead(service.streamChanges).pipe(Effect.forkScoped({ startImmediately: true }));
192
193describe("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 @@
1import {
2 MergebotConfiguration,
3 MergebotPullRequestCheck,
4 MergebotPullRequestDetails,
5 MergebotPullRequestStatus,
6 type MergebotPullRequestInput,
7 type MergebotQueueInput,
8 MergebotRepoQueue,
9 PullRequestActor,
10} from "@t3tools/contracts";
11import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials";
12import * as HostProcess from "@t3tools/shared/HostProcess";
13import * as NodeSocket from "@effect/platform-node/NodeSocket";
14import * as Struct from "effect/Struct";
15import * as Clock from "effect/Clock";
16import * as Context from "effect/Context";
17import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";
19import * as Layer from "effect/Layer";
20import * as Path from "effect/Path";
21import * as PubSub from "effect/PubSub";
22import * as Redacted from "effect/Redacted";
23import * as Schedule from "effect/Schedule";
24import * as Schema from "effect/Schema";
25import * as Stream from "effect/Stream";
26import * as Socket from "effect/socket/Socket";
27import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
28
29const 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});
41const 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});
52const RepoChannel = Schema.Struct({ kind: Schema.Literal("repo_overview"), repoId: Schema.String });
53const DetailChannel = Schema.Struct({
54 kind: Schema.Literal("pull_request_detail"),
55 repoId: Schema.String,
56 prNumber: Schema.Number,
57});
58const 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});
65const 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]);
98const decodeMessage = Schema.decodeEffect(Schema.fromJsonString(Message));
99const encodeStatus = Schema.encodeSync(MergebotPullRequestStatus);
100const queueEquivalence = Schema.toEquivalence(Schema.NullOr(MergebotRepoQueue));
101const decodeSession = Schema.decodeUnknownEffect(
102 Schema.Struct({
103 token: Schema.String.check(Schema.isMinLength(1)),
104 }),
105);
106
107class MergebotAuthenticationError extends Schema.TaggedError<MergebotAuthenticationError>()(
108 "MergebotAuthenticationError",
109 { status: Schema.Number },
110) {}
111
112type Channel = typeof RepoChannel.Type | typeof DetailChannel.Type;
113type Change = { readonly repository: string; readonly number?: number };
114
115export 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
132const encodeSubscribe = Schema.encodeSync(
133 Schema.fromJsonString(
134 Schema.Struct({
135 type: Schema.Literal("subscribe"),
136 channel: Schema.Union([RepoChannel, DetailChannel]),
137 }),
138 ),
139);
140
141const encodeUnsubscribe = Schema.encodeSync(
142 Schema.fromJsonString(
143 Schema.Struct({
144 type: Schema.Literal("unsubscribe"),
145 channelKey: Schema.String,
146 }),
147 ),
148);
149
150const channelKey = (channel: Channel) =>
151 channel.kind === "repo_overview"
152 ? `repo_overview:${channel.repoId}`
153 : `pull_request_detail:${channel.repoId}#${channel.prNumber}`;
154
155const 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
579export const layerWithSocketConstructor = Layer.effect(MergebotQueue, make);
580export const layer = layerWithSocketConstructor.pipe(
581 Layer.provide(NodeSocket.layerWebSocketConstructorWS),
582);
apps/server/src/mergebot/pullRequestRead.ts created+60
......@@ -0,0 +1,60 @@
1import type {
2 MergebotPullRequestDetails,
3 PullRequestCheck,
4 PullRequestMergeability,
5} from "@t3tools/contracts";
6
7export 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
36export function mergebotMergeability(details: MergebotPullRequestDetails): PullRequestMergeability {
37 return details.hasConflicts === null
38 ? "unknown"
39 : details.hasConflicts
40 ? "conflicting"
41 : "mergeable";
42}
43
44export 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 = {
9999 [WS_METHODS.secretsAnswerRequest]: "secrets",
100100 [WS_METHODS.cloudGetRelayClientStatus]: "cloud",
101101 [WS_METHODS.cloudInstallRelayClient]: "cloud",
102 [WS_METHODS.mergebotQueue]: "mergebot",
103 [WS_METHODS.mergebotSubscribe]: "mergebot",
102104 [WS_METHODS.pullRequestsList]: "pull-requests",
103105 [WS_METHODS.pullRequestsListStats]: "pull-requests",
104106 [WS_METHODS.pullRequestsSummary]: "pull-requests",
apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts+35
......@@ -33,6 +33,7 @@ import * as Ref from "effect/Ref";
3333import { TestClock } from "effect/testing";
3434
3535import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider";
36import * as MergebotQueue from "../mergebot/MergebotQueue.ts";
3637import * as GitManager from "../git/GitManager.ts";
3738import * as PullRequestService from "../pullRequest/PullRequestService.ts";
3839import * as ServerActivation from "../serverActivation.ts";
......@@ -164,6 +165,7 @@ function makeSummary(
164165}
165166
166167interface HarnessOptions {
168 readonly mergebot?: MergebotQueue.MergebotQueue["Service"]["configuration"];
167169 readonly invalidate?: PullRequestService.PullRequestService["Service"]["invalidate"];
168170 readonly snapshot: TestShellSnapshot;
169171 readonly summary?: (
......@@ -218,6 +220,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options:
218220 };
219221
220222 const layerDependencies = Layer.mergeAll(
223 Layer.mock(MergebotQueue.MergebotQueue)({ configuration: options.mergebot ?? null }),
221224 Layer.mock(PullRequestService.PullRequestService)({
222225 summary,
223226 stack,
......@@ -387,6 +390,38 @@ function applySync(
387390}
388391
389392describe("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
390425 it.effect("retries a failed stack read after the summary becomes terminal", () =>
391426 Effect.scoped(
392427 Effect.gen(function* () {
apps/server/src/orchestration-v2/PullRequestSyncReactor.ts+16-9
......@@ -1,3 +1,5 @@
1import { isMergebotRepository } from "@t3tools/contracts";
2import * as MergebotQueue from "../mergebot/MergebotQueue.ts";
13import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl";
24import {
35 CommandId,
......@@ -156,6 +158,7 @@ export const make = Effect.gen(function* () {
156158 const engine = yield* Orchestrator.OrchestratorV2;
157159 const projections = yield* ProjectionStore.ProjectionStoreV2;
158160 const pullRequests = yield* PullRequestService.PullRequestService;
161 const mergebot = yield* MergebotQueue.MergebotQueue;
159162 const git = yield* GitManager.GitManager;
160163 const crypto = yield* Crypto.Crypto;
161164
......@@ -288,16 +291,20 @@ export const make = Effect.gen(function* () {
288291 if (generation !== undefined) yield* pullRequests.invalidate({ reference: ref });
289292 const summary = yield* pullRequests.summary(ref, { recoverTransientFailure: false });
290293 const fields = snapshotFieldsOf(summary);
294 const work =
295 ref.host === "github.com" &&
296 isMergebotRepository(mergebot.configuration ?? undefined, ref.repository);
291297 const needsStack =
292 generation !== undefined ||
293 retryStacks.has(key) ||
294 entries.some(
295 (entry) =>
296 entry.link.snapshot === null ||
297 !snapshotFieldsEqual(entry.link.snapshot, fields) ||
298 (summary.stack !== undefined &&
299 (entry.link.stack?.number ?? null) !== (summary.stack?.number ?? null)),
300 );
298 !work &&
299 (generation !== undefined ||
300 retryStacks.has(key) ||
301 entries.some(
302 (entry) =>
303 entry.link.snapshot === null ||
304 !snapshotFieldsEqual(entry.link.snapshot, fields) ||
305 (summary.stack !== undefined &&
306 (entry.link.stack?.number ?? null) !== (summary.stack?.number ?? null)),
307 ));
301308 // A summary that says the pull request is in no stack, for links that hold none, already
302309 // answers what the stack read would.
303310 const knownUnstacked =
apps/server/src/orchestration-v2/runtimeLayer.test.ts+194-159
......@@ -1,4 +1,5 @@
11import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2import * as MergebotQueue from "../mergebot/MergebotQueue.ts";
23import { SourceControlProviderKind } from "@t3tools/contracts";
34import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts";
45import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
......@@ -2505,12 +2506,18 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
25052506 });
25062507 const reactor = yield* PullRequestWatchReactor.make.pipe(
25072508 Effect.provide(
2508 Layer.mergeAll(
2509 NodeServices.layer,
2510 Layer.mock(PullRequestService.PullRequestService)({
2511 detail: () => Effect.die("host unreachable"),
2512 activity: () => Effect.die("host unreachable"),
2509 Layer.merge(
2510 Layer.mock(MergebotQueue.MergebotQueue)({
2511 configuration: null,
2512 streamChanges: Stream.empty,
25132513 }),
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 ),
25142521 ),
25152522 ),
25162523 );
......@@ -2584,37 +2591,43 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
25842591 let reads = 0;
25852592 const reactor = yield* PullRequestWatchReactor.make.pipe(
25862593 Effect.provide(
2587 Layer.mergeAll(
2588 NodeServices.layer,
2589 Layer.mock(PullRequestService.PullRequestService)({
2590 detail: () =>
2591 Effect.suspend(() => {
2592 reads += 1;
2593 return host === "rate-limited"
2594 Layer.merge(
2595 Layer.mock(MergebotQueue.MergebotQueue)({
2596 configuration: null,
2597 streamChanges: Stream.empty,
2598 }),
2599 Layer.mergeAll(
2600 NodeServices.layer,
2601 Layer.mock(PullRequestService.PullRequestService)({
2602 detail: () =>
2603 Effect.suspend(() => {
2604 reads += 1;
2605 return host === "rate-limited"
2606 ? Effect.fail(rateLimited)
2607 : Effect.succeed({
2608 ...watchedPullRequestDetail({
2609 projectId,
2610 number: 9,
2611 at: "2026-10-02T12:00:00.000Z",
2612 }),
2613 state: host,
2614 checks: [
2615 { name: "lint", status: "success", description: null, url: null },
2616 ],
2617 });
2618 }),
2619 activity: () =>
2620 host === "rate-limited"
25942621 ? Effect.fail(rateLimited)
25952622 : Effect.succeed({
2596 ...watchedPullRequestDetail({
2597 projectId,
2598 number: 9,
2599 at: "2026-10-02T12:00:00.000Z",
2600 }),
2601 state: host,
2602 checks: [
2603 { name: "lint", status: "success", description: null, url: null },
2604 ],
2605 });
2606 }),
2607 activity: () =>
2608 host === "rate-limited"
2609 ? Effect.fail(rateLimited)
2610 : Effect.succeed({
2611 comments: [],
2612 commentCount: 0,
2613 commentsTruncated: false,
2614 reviewThreads: [],
2615 commits: [],
2616 }),
2617 }),
2623 comments: [],
2624 commentCount: 0,
2625 commentsTruncated: false,
2626 reviewThreads: [],
2627 commits: [],
2628 }),
2629 }),
2630 ),
26182631 ),
26192632 ),
26202633 );
......@@ -2720,24 +2733,34 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
27202733 });
27212734 const reactor = yield* PullRequestWatchReactor.make.pipe(
27222735 Effect.provide(
2723 Layer.mergeAll(
2724 NodeServices.layer,
2725 Layer.mock(PullRequestService.PullRequestService)({
2726 detail: () =>
2727 Effect.sync(() => ({
2728 ...watchedPullRequestDetail({ projectId, number: key.number, at: "2026-10-02" }),
2729 headSha,
2730 checks: [],
2731 })),
2732 activity: () =>
2733 Effect.succeed({
2734 comments: [],
2735 commentCount: 0,
2736 commentsTruncated: false,
2737 reviewThreads: [],
2738 commits: [],
2739 }),
2736 Layer.merge(
2737 Layer.mock(MergebotQueue.MergebotQueue)({
2738 configuration: null,
2739 streamChanges: Stream.empty,
27402740 }),
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 ),
27412764 ),
27422765 ),
27432766 );
......@@ -2822,61 +2845,67 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
28222845 let activityReads = 0;
28232846 const reactor = yield* PullRequestWatchReactor.make.pipe(
28242847 Effect.provide(
2825 Layer.mergeAll(
2826 NodeServices.layer,
2827 Layer.mock(PullRequestService.PullRequestService)({
2828 watchFingerprint: () =>
2829 Effect.suspend(() =>
2830 fingerprint === "rate-limited"
2831 ? Effect.fail(
2832 new PullRequestOperationError({
2833 operation: "watchFingerprint",
2834 detail: "paused",
2835 cause: new PullRequestProviderError({
2836 provider: SourceControlProviderKind.make("github"),
2837 operation: "getChangeRequestWatchFingerprint",
2838 reason: "rate-limited",
2848 Layer.merge(
2849 Layer.mock(MergebotQueue.MergebotQueue)({
2850 configuration: null,
2851 streamChanges: Stream.empty,
2852 }),
2853 Layer.mergeAll(
2854 NodeServices.layer,
2855 Layer.mock(PullRequestService.PullRequestService)({
2856 watchFingerprint: () =>
2857 Effect.suspend(() =>
2858 fingerprint === "rate-limited"
2859 ? Effect.fail(
2860 new PullRequestOperationError({
2861 operation: "watchFingerprint",
28392862 detail: "paused",
2863 cause: new PullRequestProviderError({
2864 provider: SourceControlProviderKind.make("github"),
2865 operation: "getChangeRequestWatchFingerprint",
2866 reason: "rate-limited",
2867 detail: "paused",
2868 }),
28402869 }),
2841 }),
2842 )
2843 : Effect.succeed(fingerprint),
2844 ),
2845 invalidate: () =>
2846 Effect.sync(() => {
2847 cachedChecks = null;
2848 }),
2849 detail: () =>
2850 Effect.sync(() => {
2851 detailReads += 1;
2852 const answer = cachedChecks ?? checks;
2853 cachedChecks = null;
2854 return {
2855 ...watchedPullRequestDetail({
2856 projectId,
2857 number: 11,
2858 at: "2026-10-02T12:00:00.000Z",
2859 }),
2860 checks: Object.entries(answer).map(([name, status]) => ({
2861 name,
2862 status,
2863 description: null,
2864 url: null,
2865 })),
2866 };
2867 }),
2868 activity: () =>
2869 Effect.sync(() => {
2870 activityReads += 1;
2871 return {
2872 comments,
2873 commentCount: comments.length,
2874 commentsTruncated: false,
2875 reviewThreads: [],
2876 commits: [],
2877 };
2878 }),
2879 }),
2870 )
2871 : Effect.succeed(fingerprint),
2872 ),
2873 invalidate: () =>
2874 Effect.sync(() => {
2875 cachedChecks = null;
2876 }),
2877 detail: () =>
2878 Effect.sync(() => {
2879 detailReads += 1;
2880 const answer = cachedChecks ?? checks;
2881 cachedChecks = null;
2882 return {
2883 ...watchedPullRequestDetail({
2884 projectId,
2885 number: 11,
2886 at: "2026-10-02T12:00:00.000Z",
2887 }),
2888 checks: Object.entries(answer).map(([name, status]) => ({
2889 name,
2890 status,
2891 description: null,
2892 url: null,
2893 })),
2894 };
2895 }),
2896 activity: () =>
2897 Effect.sync(() => {
2898 activityReads += 1;
2899 return {
2900 comments,
2901 commentCount: comments.length,
2902 commentsTruncated: false,
2903 reviewThreads: [],
2904 commits: [],
2905 };
2906 }),
2907 }),
2908 ),
28802909 ),
28812910 ),
28822911 );
......@@ -3031,62 +3060,68 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
30313060 let pagesRead = 0;
30323061 const reactor = yield* PullRequestWatchReactor.make.pipe(
30333062 Effect.provide(
3034 Layer.mergeAll(
3035 NodeServices.layer,
3036 Layer.mock(PullRequestService.PullRequestService)({
3037 detail: () => Effect.succeed(detail),
3038 activity: () =>
3039 Effect.succeed({
3040 comments:
3041 mode === "single page"
3042 ? [remark]
3043 : [...firstTen, { ...remark, kind: "issue-comment" }],
3044 commentCount: mode === "single page" ? 1 : 13,
3045 commentsTruncated: mode !== "single page",
3046 reviewThreadsTruncated: mode === "thread list truncated" && !recovering,
3047 reviewThreads:
3048 mode === "single page"
3049 ? []
3050 : [
3051 {
3052 id: "review-thread",
3053 path: "src/index.ts",
3054 line: 1,
3055 side: "right",
3056 isResolved: false,
3057 isOutdated: false,
3058 comments: firstTen,
3059 commentCount: 12,
3060 nextCommentsCursor: "after-10",
3061 },
3062 ],
3063 commits: [],
3064 }),
3065 threadComments: (input) => {
3066 pagesRead += 1;
3067 assert.equal(input.threadId, "review-thread");
3068 if (input.cursor === "after-10") {
3069 return Effect.succeed({ comments: [eleventh], nextCursor: "after-11" });
3070 }
3071 assert.equal(input.cursor, "after-11");
3072 if (!recovering && mode === "page failure") {
3073 return Effect.fail(
3074 new PullRequestOperationError({
3075 operation: "threadComments",
3076 detail: "Page unavailable",
3077 }),
3078 );
3079 }
3080 if (!recovering && mode === "repeated cursor") {
3081 return Effect.succeed({ comments: [eleventh], nextCursor: "after-11" });
3082 }
3083 if (!recovering && mode === "missing comments") {
3084 return Effect.succeed({ comments: [], nextCursor: null });
3085 }
3086 // Overlapping pages must not report the same reply twice.
3087 return Effect.succeed({ comments: [eleventh, twelfth], nextCursor: null });
3088 },
3063 Layer.merge(
3064 Layer.mock(MergebotQueue.MergebotQueue)({
3065 configuration: null,
3066 streamChanges: Stream.empty,
30893067 }),
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 ),
30903125 ),
30913126 ),
30923127 );
apps/server/src/pullRequest/PullRequestService.test.ts+101
......@@ -28,6 +28,7 @@ import type { ServerSettings } from "@t3tools/contracts";
2828import * as ServerSettingsService from "../serverSettings.ts";
2929import { PullRequestOperationError } from "@t3tools/contracts";
3030
31import * as MergebotQueue from "../mergebot/MergebotQueue.ts";
3132import * as ProjectService from "../project/ProjectService.ts";
3233import * as SqlitePersistence from "../persistence/Sqlite.ts";
3334import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";
......@@ -534,6 +535,7 @@ function fakeProvider(
534535}
535536
536537function makeService(input: {
538 readonly mergebot?: MergebotQueue.MergebotQueue["Service"];
537539 readonly settings?: ServerSettings;
538540 readonly projects: ReadonlyArray<OrchestrationProjectShell>;
539541 readonly providers: ReadonlyArray<PullRequestProviderApi>;
......@@ -545,6 +547,12 @@ function makeService(input: {
545547 return Effect.flatMap(
546548 Layer.build(
547549 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 }),
548556 Layer.mock(ServerSettingsService.ServerSettingsService)({
549557 getSettings: Effect.succeed(input.settings ?? DEFAULT_SERVER_SETTINGS),
550558 }),
......@@ -7903,3 +7911,96 @@ it.effect.each([
79037911 assert.deepStrictEqual(calls, [expected]);
79047912 }),
79057913);
7914
7915it.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";
2121import * as SubscriptionRef from "effect/SubscriptionRef";
2222import {
2323 PullRequestOperationError,
24 isMergebotRepository,
2425 PullRequestUnavailableError,
2526 pullRequestHostOf,
2627 pullRequestProviderRequirement,
......@@ -78,6 +79,12 @@ import {
7879import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
7980import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
8081
82import * as MergebotQueue from "../mergebot/MergebotQueue.ts";
83import {
84 mergebotChecks,
85 mergebotMergeability,
86 mergebotWatchFingerprint,
87} from "../mergebot/pullRequestRead.ts";
8188import * as ProjectService from "../project/ProjectService.ts";
8289import * as ServerSettings from "../serverSettings.ts";
8390import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";
......@@ -711,6 +718,7 @@ export const make = Effect.gen(function* () {
711718 const stateChanges = yield* PubSub.sliding<ThreadPullRequestKey>(64);
712719 const pullRequestRefreshes = yield* SubscriptionRef.make(0);
713720 const registry = yield* PullRequestProviderRegistry.PullRequestProviderRegistry;
721 const mergebot = yield* MergebotQueue.MergebotQueue;
714722 const serverSettings = yield* ServerSettings.ServerSettingsService;
715723 const projects = yield* ProjectService.ProjectService;
716724 const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
......@@ -1002,6 +1010,11 @@ export const make = Effect.gen(function* () {
10021010 }),
10031011 );
10041012
1013 const isWorkProject = (project: SupportedProject) =>
1014 project.api.kind === "github" &&
1015 project.host === "github.com" &&
1016 isMergebotRepository(mergebot.configuration ?? undefined, project.repository);
1017
10051018 const canonicalRef = Effect.fn("PullRequestService.canonicalRef")(function* <
10061019 I extends PullRequestRef,
10071020 >(input: I) {
......@@ -1267,11 +1280,14 @@ export const make = Effect.gen(function* () {
12671280 // issued, so one that does not read as one means the page is sending something it made up,
12681281 // and reading part of the listing under that assumption would quietly lose rows.
12691282 const continuation = yield* decodeCursors(input.cursors);
1270 const {
1271 supported: projects,
1272 unimplemented,
1273 viewerRoots,
1274 } = yield* listWorkspaceProjects(input);
1283 const { supported, unimplemented, viewerRoots } = yield* listWorkspaceProjects(input);
1284 const workProjects = supported.filter(isWorkProject);
1285 const projects = supported.filter((project) => !isWorkProject(project));
1286 const workErrors = workProjects.map(({ project }) => ({
1287 projectId: project.id,
1288 projectTitle: project.title,
1289 message: "Open this repository in Mergebot to browse its pull requests.",
1290 }));
12751291 const projectCounts = new Map<string, number>();
12761292 for (const { host } of projects) {
12771293 projectCounts.set(host, (projectCounts.get(host) ?? 0) + 1);
......@@ -1351,7 +1367,7 @@ export const make = Effect.gen(function* () {
13511367 viewers: viewers as PullRequestListResult["viewers"],
13521368 providers,
13531369 entries: [],
1354 errors: [],
1370 errors: workErrors,
13551371 truncated: false,
13561372 nextCursors: {},
13571373 };
......@@ -1585,7 +1601,7 @@ export const make = Effect.gen(function* () {
15851601 entries: batches
15861602 .flatMap((batch) => batch.entries)
15871603 .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)],
15891605 truncated: batches.some((batch) => batch.truncated),
15901606 nextCursors,
15911607 };
......@@ -3575,17 +3591,139 @@ export const make = Effect.gen(function* () {
35753591 );
35763592 });
35773593
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
35783688 return PullRequestService.of({
35793689 routing,
35803690 routingIdentity,
35813691 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 ),
35833700 listStats: (input) =>
3584 Effect.forEach(input.refs, (ref) => canonicalRef(ref).pipe(Effect.option)).pipe(
3585 Effect.flatMap((refs) => listStats({ ...input, refs: refs.flatMap(Option.toArray) })),
3586 ),
3587 summary: credentialCached(summary),
3588 stack: credentialCached(stack),
3701 Effect.gen(function* () {
3702 const refs = (yield* Effect.forEach(input.refs, (ref) =>
3703 canonicalRef(ref).pipe(Effect.option),
3704 )).flatMap(Option.toArray);
3705 const ordinary: PullRequestRef[] = [];
3706 const workStats: PullRequestDiffStat[] = [];
3707 for (const ref of refs) {
3708 if (!isWorkProject(yield* requireProject(ref))) {
3709 ordinary.push(ref);
3710 continue;
3711 }
3712 const value = yield* workDetails(ref);
3713 if (value.additions !== null && value.deletions !== null)
3714 workStats.push({
3715 projectId: ref.projectId,
3716 repository: ref.repository,
3717 number: ref.number,
3718 additions: value.additions,
3719 deletions: value.deletions,
3720 });
3721 }
3722 const result = yield* listStats({ ...input, refs: ordinary });
3723 return { stats: [...workStats, ...result.stats] };
3724 }),
3725 summary: withWorkRead(credentialCached(summary), workSummary),
3726 stack: unsupportedWorkRead("stack", credentialCached(stack)),
35893727 subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
35903728 Effect.map((subscription) => Stream.fromSubscription(subscription)),
35913729 ),
......@@ -3597,31 +3735,58 @@ export const make = Effect.gen(function* () {
35973735 Stream.filter((revision) => revision > 0),
35983736 ),
35993737 refreshAfterTurn,
3600 detail: credentialCached(detail),
3601 checks: credentialCached((input) => Cache.get(checksCache, refCacheKey(input))),
3602 watchFingerprint: credentialCached((input) =>
3603 Cache.get(watchFingerprintCache, refCacheKey(input)),
3738 detail: unsupportedWorkRead("detail", credentialCached(detail)),
3739 checks: withWorkRead(
3740 credentialCached((input) => Cache.get(checksCache, refCacheKey(input))),
3741 (input) =>
3742 workDetails(input).pipe(
3743 Effect.map((value) => ({ state: value.lifecycle, checks: mergebotChecks(value) })),
3744 ),
36043745 ),
3605 activity: credentialCached(activity),
3606 preview: credentialCached(preview),
3607 threadComments,
3608 diff: credentialCached(diff),
3609 diffFileContents,
3610 filesViewed: credentialCached(filesViewed),
3611 setFilesViewed,
3612 runAction: runActionAndInvalidate,
3613 update: invalidatedByMutation(update),
3614 comment: invalidatedByMutation(comment),
3615 updateComment: invalidatedByMutation(updateComment),
3616 submitReview: invalidatedByMutation(submitReview),
3617 replyToThread: invalidatedByMutation(replyToThread),
3618 setThreadResolution: invalidatedByMutation(setThreadResolution),
3619 setReaction: invalidatedByMutation(setReaction),
3746 watchFingerprint: withWorkRead(
3747 credentialCached((input) => Cache.get(watchFingerprintCache, refCacheKey(input))),
3748 (input) => workDetails(input).pipe(Effect.map(mergebotWatchFingerprint)),
3749 ),
3750 activity: unsupportedWorkRead("activity", credentialCached(activity)),
3751 preview: withWorkRead(credentialCached(preview), (input) =>
3752 workDetails(input).pipe(
3753 Effect.map((value) => ({
3754 projectId: input.projectId,
3755 repository: input.repository,
3756 number: value.number,
3757 title: value.title,
3758 url: value.url,
3759 author: value.author,
3760 state: value.lifecycle,
3761 isDraft: value.draft,
3762 createdAt: value.createdAt,
3763 })),
3764 ),
3765 ),
3766 threadComments: unsupportedWorkRead("threadComments", threadComments),
3767 diff: unsupportedWorkRead("diff", credentialCached(diff)),
3768 diffFileContents: unsupportedWorkRead("diffFileContents", diffFileContents),
3769 filesViewed: unsupportedWorkRead("filesViewed", credentialCached(filesViewed)),
3770 setFilesViewed: unsupportedWorkRead("setFilesViewed", setFilesViewed),
3771 runAction: unsupportedWorkRead("runAction", runActionAndInvalidate),
3772 update: unsupportedWorkRead("update", invalidatedByMutation(update)),
3773 comment: unsupportedWorkRead("comment", invalidatedByMutation(comment)),
3774 updateComment: unsupportedWorkRead("updateComment", invalidatedByMutation(updateComment)),
3775 submitReview: unsupportedWorkRead("submitReview", invalidatedByMutation(submitReview)),
3776 replyToThread: unsupportedWorkRead("replyToThread", invalidatedByMutation(replyToThread)),
3777 setThreadResolution: unsupportedWorkRead(
3778 "setThreadResolution",
3779 invalidatedByMutation(setThreadResolution),
3780 ),
3781 setReaction: unsupportedWorkRead("setReaction", invalidatedByMutation(setReaction)),
36203782 // The candidate list is deliberately read fresh per menu-open, so it stays uncached.
3621 reviewerCandidates,
3622 requestReviewers: invalidatedByMutation(requestReviewers),
3623 labelCandidates,
3624 setLabels: invalidatedByMutation(setLabels),
3783 reviewerCandidates: unsupportedWorkRead("reviewerCandidates", reviewerCandidates),
3784 requestReviewers: unsupportedWorkRead(
3785 "requestReviewers",
3786 invalidatedByMutation(requestReviewers),
3787 ),
3788 labelCandidates: unsupportedWorkRead("labelCandidates", labelCandidates),
3789 setLabels: unsupportedWorkRead("setLabels", invalidatedByMutation(setLabels)),
36253790 invalidate,
36263791 });
36273792});
apps/server/src/server.ts+4
......@@ -117,6 +117,7 @@ import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
117117import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";
118118import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
119119import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
120import * as MergebotQueue from "./mergebot/MergebotQueue.ts";
120121import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
121122import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
122123import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
......@@ -363,11 +364,13 @@ const layerPullRequestService = PullRequestService.layer.pipe(
363364 // Where the viewed-file marks live for a host that keeps none of its own.
364365 Layer.provide(PullRequestFilesViewed.layer),
365366 Layer.provide(PullRequestReadCache.layer),
367 Layer.provide(MergebotQueue.layer),
366368 Layer.provide(layerSourceControlProviderRegistry),
367369 Layer.provide(SourceControlRateLimit.layer),
368370);
369371
370372const layerGitManager = GitManager.layer.pipe(
373 Layer.provide(MergebotQueue.layer),
371374 // Per-project git settings resolve the acting thread's project.
372375 Layer.provide(Layer.merge(ProjectionStoreV2.layer, ProjectStore.layer)),
373376 Layer.provideMerge(RuntimeLayer.layerProjectSetupScriptRunner),
......@@ -646,6 +649,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
646649 Layer.provideMerge(RuntimeLayer.layerEventInfrastructure),
647650 Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)),
648651 Layer.provideMerge(layerServerSettings),
652 Layer.provideMerge(MergebotQueue.layer),
649653 // The asset route uses the registry's GitHub credential for private PR media, which the
650654 // built-in drivers' layer provides alongside the registry.
651655 Layer.provideMerge(layerSourceControlProviderRegistry),
apps/server/src/ws.ts+5
......@@ -189,6 +189,7 @@ import { parseBase64DataUrl } from "./imageMime.ts";
189189import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts";
190190import * as PortScanner from "./preview/PortScanner.ts";
191191import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
192import * as MergebotQueue from "./mergebot/MergebotQueue.ts";
192193import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
193194import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts";
194195import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
......@@ -1169,6 +1170,7 @@ const layerWsRpc = (
11691170 const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2;
11701171 const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService;
11711172 const secretRequests = yield* SecretRequests.SecretRequests;
1173 const mergebotQueue = yield* MergebotQueue.MergebotQueue;
11721174 const scriptProgress = yield* ScriptProgress.ScriptProgress;
11731175 const pullRequests = yield* PullRequestService.PullRequestService;
11741176 const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService;
......@@ -1673,6 +1675,7 @@ const layerWsRpc = (
16731675 otlpLogsEnabled: config.otlpLogsUrl !== undefined,
16741676 },
16751677 settings,
1678 ...(mergebotQueue.configuration ? { mergebot: mergebotQueue.configuration } : {}),
16761679 shellResumeCompletionMarker: true,
16771680 threadResumeCompletionMarker: true,
16781681 threadSnapshotPagination: true,
......@@ -2409,6 +2412,8 @@ const layerWsRpc = (
24092412 Effect.forkScoped,
24102413 ),
24112414 ),
2415 [WS_METHODS.mergebotQueue]: (input) => mergebotQueue.read(input),
2416 [WS_METHODS.mergebotSubscribe]: (input) => mergebotQueue.watchQueue(input),
24122417 [WS_METHODS.pullRequestsList]: (input) => pullRequests.list(input),
24132418 [WS_METHODS.pullRequestsListStats]: (input) => pullRequests.listStats(input),
24142419 [WS_METHODS.pullRequestsRoutingIdentity]: (input) => pullRequests.routingIdentity(input),
apps/web/src/components/ChatView.tsx+6
......@@ -11237,6 +11237,12 @@ export default function ChatView(props: ChatViewProps) {
1123711237 }
1123811238 key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}
1123911239 environmentId={activeThread.environmentId}
11240 onMergebotReady={(tabId) =>
11241 activeThreadRef !== null &&
11242 useRightPanelStore
11243 .getState()
11244 .replacePullRequestWithBrowser(activeThreadRef, renderedRightPanelSurface.id, tabId)
11245 }
1124011246 onSelectPullRequest={(reference) => {
1124111247 if (activeThreadRef)
1124211248 useRightPanelStore.getState().openPullRequest(activeThreadRef, {
apps/web/src/components/pullRequest/MergebotBrowserPanel.test.tsx created+255
......@@ -0,0 +1,255 @@
1import { AsyncResult } from "effect/reactivity";
2import * as Cause from "effect/Cause";
3import { EnvironmentId, ThreadId } from "@t3tools/contracts";
4import { act } from "react";
5import { create, type ReactTestRenderer } from "react-test-renderer";
6import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
7
8const 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}));
23vi.mock("~/browser/previewRuntime", () => ({ usePreviewAvailable: () => state.available }));
24vi.mock("~/previewStateStore", () => ({
25 useThreadPreviewState: () => state.preview,
26 readThreadPreviewState: () => state.preview,
27}));
28vi.mock("~/state/entities", () => ({ useThreadShell: () => state.thread }));
29vi.mock("~/state/session", () => ({ useEnvironmentScope: () => state.canOperate }));
30vi.mock("~/state/preview", () => ({
31 previewEnvironment: { open: {}, close: {}, list: () => ({}) },
32}));
33vi.mock("~/state/query", () => ({
34 useEnvironmentQuery: () => ({ error: state.listError, refresh: state.refresh }),
35}));
36vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
37vi.mock("../preview/usePreviewSession", () => ({ usePreviewSession: () => {} }));
38vi.mock("../preview/closePreviewSession", () => ({ closePreviewSession: state.close }));
39vi.mock("../preview/openPreviewSession", () => ({ openPreviewSession: state.open }));
40vi.mock("../preview/PreviewPanel", () => ({
41 PreviewPanel: ({ tabId }: { tabId: string }) => <div data-browser-tab={tabId} />,
42}));
43vi.mock("../ui/button", () => ({ Button: "button" }));
44import { MergebotBrowserPanel } from "./MergebotBrowserPanel";
45
46const threadRef = {
47 environmentId: EnvironmentId.make("environment"),
48 threadId: ThreadId.make("thread"),
49};
50const url = "https://mergebot.example/Work/App/pull/12";
51let renderer: ReactTestRenderer | null = null;
52const 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};
62beforeEach(() => {
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});
78afterEach(async () => {
79 await act(() => renderer?.unmount());
80 renderer = null;
81 vi.unstubAllGlobals();
82});
83
84it("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});
92it("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});
102it("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});
113it("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});
121it("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});
129it("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
137it("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
148it("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
158it("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});
177it("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
188it("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
218it("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
226it("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
235it("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
244it("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 @@
1import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2import {
3 AuthPreviewOperateScope,
4 MERGEBOT_BROWSER_THREAD_ID,
5 type ScopedThreadRef,
6} from "@t3tools/contracts";
7import {
8 isAtomCommandInterrupted,
9 squashAtomCommandFailure,
10} from "@t3tools/client-runtime/state/runtime";
11
12import { usePreviewAvailable } from "~/browser/previewRuntime";
13import { readThreadPreviewState, useThreadPreviewState } from "~/previewStateStore";
14import { useEnvironmentQuery } from "~/state/query";
15import { useThreadShell } from "~/state/entities";
16import { previewEnvironment } from "~/state/preview";
17import { useEnvironmentScope } from "~/state/session";
18import { useAtomCommand } from "~/state/use-atom-command";
19import { Button } from "../ui/button";
20import { PreviewPanel } from "../preview/PreviewPanel";
21import { closePreviewSession } from "../preview/closePreviewSession";
22import { openPreviewSession } from "../preview/openPreviewSession";
23import { usePreviewSession } from "../preview/usePreviewSession";
24
25export 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 @@
1import type { EnvironmentId, MergebotRepoQueue } from "@t3tools/contracts";
2import { act, createElement } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";
4import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
5
6const state = vi.hoisted(() => ({ queue: null as MergebotRepoQueue | null, read: vi.fn() }));
7vi.mock("../../state/environments", () => ({
8 useEnvironment: () => ({
9 serverConfig: { mergebot: { origin: "https://mergebot.example", repositories: ["work/app"] } },
10 }),
11}));
12vi.mock("../../state/mergebot", () => ({
13 useMergebotQueue: (...args: unknown[]) => {
14 state.read(...args);
15 return state.queue;
16 },
17}));
18vi.mock("../ui/badge", () => ({ Badge: (props: object) => createElement("span", props) }));
19vi.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
31import { MergebotQueueBadge } from "./MergebotQueueBadge";
32const environmentId = "local" as EnvironmentId;
33const repository = "work/app";
34let renderer: ReactTestRenderer | undefined;
35function 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}
54async 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}
68function label() {
69 return renderer!.root.findByType("span").children.join("");
70}
71beforeEach(() => {
72 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
73 state.queue = ready();
74 state.read.mockReset();
75});
76afterEach(async () => {
77 await act(() => renderer?.unmount());
78 renderer = undefined;
79 vi.unstubAllGlobals();
80});
81
82it("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});
90it("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});
102it("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 @@
1import {
2 isMergebotRepository,
3 mergebotPullRequestStatus,
4 type EnvironmentId,
5} from "@t3tools/contracts";
6
7import { useMergebotQueue } from "../../state/mergebot";
8import { useEnvironment } from "../../state/environments";
9import { Badge } from "../ui/badge";
10import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
11
12const 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
21interface Props {
22 environmentId: EnvironmentId;
23 repository: string;
24 number: number;
25 headSha?: string | undefined;
26}
27
28export 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
35function 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";
1313import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
1414import { DraftId, useComposerDraftStore } from "~/composerDraftStore";
1515
16const { newThread, prepareThread, refresh, Wrapper, Trigger } = vi.hoisted(() => ({
16const { newThread, prepareThread, refresh, browserState, Wrapper, Trigger } = vi.hoisted(() => ({
1717 newThread: vi.fn(),
1818 prepareThread: vi.fn(),
1919 refresh: vi.fn(),
20 browserState: { configured: false },
2021 Wrapper: ({ children }: { children?: ReactNode }) => children,
2122 Trigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => (
2223 <>
......@@ -32,8 +33,25 @@ vi.mock("~/state/session", async (original) => ({
3233}));
3334vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] }));
3435vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: {} }));
35vi.mock("~/state/entities", () => ({ useProjects: () => [], useServerConfigs: () => new Map() }));
36vi.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}));
48vi.mock("./MergebotBrowserPanel", () => ({
49 MergebotBrowserPanel: ({ threadRef, url }: { threadRef: ScopedThreadRef; url: string }) => (
50 <div data-mergebot-owner={threadRef.threadId} data-mergebot-url={url} />
51 ),
52}));
3653vi.mock("~/state/environments", () => ({
54 useEnvironment: () => null,
3755 useEnvironments: () => ({ environments: [] }),
3856 usePrimaryEnvironmentId: () => EnvironmentId.make("env-1"),
3957}));
......@@ -226,6 +244,7 @@ const newDraftId = DraftId.make("new-draft");
226244let renderer: ReactTestRenderer;
227245
228246beforeEach(() => {
247 browserState.configured = false;
229248 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
230249 vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() });
231250 useComposerDraftStore.setState({ draftsByThreadKey: {} });
......@@ -436,3 +455,82 @@ it("changes the title only for the active tab's main scroller", async () => {
436455 await act(async () => mainScroll.props.onScrollCapture({ target: code }));
437456 expect(titleFold().props.inert).toBe(false);
438457});
458
459it.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
490it.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 @@
1import {
2 mergebotPullRequestUrl,
3 mergebotPullRequestBrowserThreadId,
4 ThreadId,
5} from "@t3tools/contracts";
6import { MergebotQueueBadge } from "./MergebotQueueBadge";
17import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
28import { useAtomValue } from "@effect/atom-react";
39import { usePullRequestStack } from "~/state/usePullRequestStack";
......@@ -412,7 +418,68 @@ function PullRequestBaseFreshnessWarning({
412418 );
413419}
414420
415export function PullRequestDetailPanel({
421const MergebotBrowserPanel = lazy(() =>
422 import("./MergebotBrowserPanel").then((module) => ({ default: module.MergebotBrowserPanel })),
423);
424
425export 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
482function NativePullRequestDetailPanel({
416483 environmentId,
417484 shortcutsEnabled,
418485 getShortcutContext,
......@@ -432,6 +499,7 @@ export function PullRequestDetailPanel({
432499 shortcutsEnabled: boolean;
433500 getShortcutContext: () => ShortcutMatchContext;
434501 onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined;
502 onMergebotReady?: ((tabId: string) => boolean) | undefined;
435503 /**
436504 * The thread this panel sits beside, if any. Links that are not the pull
437505 * request itself (check details, host permalinks) can open in that thread's
......@@ -2775,6 +2843,16 @@ export function PullRequestDetailPanel({
27752843 }
27762844 />
27772845 ) : 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}
27782856 </div>
27792857 </div>
27802858
apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx+8
......@@ -48,6 +48,7 @@ import {
4848} from "./pullRequestPresentation";
4949import { PullRequestGlyph } from "./pullRequestIcons";
5050import { pullRequestQuickActions } from "./pullRequestList.logic";
51import { MergebotQueueBadge } from "./MergebotQueueBadge";
5152import { PullRequestSpeedActions } from "./PullRequestSpeedActions";
5253
5354const SOURCE_LABELS: Record<ThreadPullRequestLink["source"], string> = {
......@@ -206,6 +207,13 @@ function LinkRow({
206207 }
207208 meta={
208209 <>
210 {open ? (
211 <MergebotQueueBadge
212 environmentId={threadRef.environmentId}
213 repository={link.repository}
214 number={link.number}
215 />
216 ) : null}
209217 {stack ? (
210218 <Tooltip>
211219 <TooltipTrigger
apps/web/src/components/sidebar/SidebarChrome.tsx+3-49
......@@ -1,11 +1,9 @@
11import { ArrowLeftIcon } from "lucide-react";
2import type { ReactNode } from "react";
32import { memo, useCallback } from "react";
4import { useLocation, useNavigate } from "@tanstack/react-router";
3import { useLocation } from "@tanstack/react-router";
54
65import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";
76import { cn } from "../../lib/utils";
8import { usePullRequestsSupported } from "../../state/environments";
97import {
108 resolveEnvironmentIdentificationPillLabel,
119 useEnvironmentStageLabel,
......@@ -19,12 +17,10 @@ import {
1917 SidebarTrigger,
2018 useSidebar,
2119} from "../ui/sidebar";
22import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
23import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences";
2420import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation";
2521import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice";
2622import { AppMenu } from "./AppMenu";
27import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
23import { SidebarMergebotButton } from "./SidebarMergebotButton";
2824import { SidebarUsageButton } from "./SidebarUsageButton";
2925
3026export const SidebarChromeHeader = memo(function SidebarChromeHeader({
......@@ -65,51 +61,17 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
6561 );
6662});
6763
68function 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
9364export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
94 const navigate = useNavigate();
9565 const navigateToMainApp = useNavigateToMainApp();
9666 const { isMobile, setOpenMobile } = useSidebar();
9767 const isOnUtilityPage = useLocation({
9868 select: (location) => isSidebarUtilityPage(location.pathname),
9969 });
100 const pullRequestsSupported = usePullRequestsSupported();
10170 const closeMobileSidebar = useCallback(() => {
10271 if (isMobile) {
10372 setOpenMobile(false);
10473 }
10574 }, [isMobile, setOpenMobile]);
106 const handlePullRequestsClick = useCallback(() => {
107 closeMobileSidebar();
108 void navigate({
109 to: "/pull-requests",
110 search: readPullRequestListPreferences(),
111 });
112 }, [closeMobileSidebar, navigate]);
11375 const handleBackClick = useCallback(() => {
11476 closeMobileSidebar();
11577 void navigateToMainApp();
......@@ -125,15 +87,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
12587 </SidebarMenuButton>
12688 </SidebarMenuItem>
12789 ) : (
128 <>
129 {pullRequestsSupported ? (
130 <SidebarUtilityItem
131 icon={<PullRequestGlyph.pullRequest />}
132 label="Pull Requests"
133 onClick={handlePullRequestsClick}
134 />
135 ) : null}
136 </>
90 <SidebarMergebotButton />
13791 )}
13892 </SidebarMenu>
13993 );
apps/web/src/components/sidebar/SidebarMergebotButton.test.tsx created+134
......@@ -0,0 +1,134 @@
1import { AsyncResult } from "effect/reactivity";
2import * as Cause from "effect/Cause";
3import { act } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6
7const 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}));
17vi.mock("@tanstack/react-router", () => ({
18 useNavigate: () => state.navigate,
19 useParams: ({ select }: { select: (value: object) => unknown }) => select(state.params),
20}));
21vi.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}));
31vi.mock("../../state/session", () => ({ useEnvironmentScope: () => state.canPreview }));
32vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
33vi.mock("../../state/preview", () => ({ previewEnvironment: { open: {} } }));
34vi.mock("../preview/openPreviewSession", () => ({ openPreviewSession: state.open }));
35vi.mock("../../rightPanelStore", () => ({
36 useRightPanelStore: { getState: () => ({ openBrowser: state.showBrowser }) },
37}));
38vi.mock("../../previewStateStore", () => ({ readThreadPreviewState: () => ({ sessions: {} }) }));
39vi.mock("../../localApi", () => ({
40 readLocalApi: () => ({ shell: { openExternal: state.external } }),
41}));
42vi.mock("../ui/toast", () => ({ toastManager: { add: state.toast } }));
43vi.mock("../ui/sidebar", () => ({
44 useSidebar: () => ({ isMobile: false, setOpenMobile: vi.fn() }),
45 SidebarMenuItem: "div",
46 SidebarMenuButton: "button",
47}));
48vi.mock("../ui/tooltip", () => ({
49 Tooltip: "div",
50 TooltipPopup: "aside",
51 TooltipTrigger: ({ render }: { render: unknown }) => render,
52}));
53
54import { SidebarMergebotButton } from "./SidebarMergebotButton";
55let renderer: ReactTestRenderer;
56async function mount() {
57 await act(() => {
58 renderer = create(<SidebarMergebotButton />);
59 });
60}
61const click = () => renderer.root.findByType("button").props.onClick();
62beforeEach(() => {
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});
73afterEach(async () => {
74 await act(() => renderer?.unmount());
75 vi.unstubAllGlobals();
76});
77
78it("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});
104it("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});
115it("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
128it("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 @@
1import {
2 isAtomCommandInterrupted,
3 squashAtomCommandFailure,
4} from "@t3tools/client-runtime/state/runtime";
5import { AuthPreviewOperateScope } from "@t3tools/contracts";
6import { useNavigate, useParams } from "@tanstack/react-router";
7import { ListOrderedIcon } from "lucide-react";
8import { useRef, useState } from "react";
9
10import { useRightPanelStore } from "../../rightPanelStore";
11import { previewEnvironment } from "../../state/preview";
12import { useEnvironmentScope } from "../../state/session";
13import { useEnvironment, usePrimaryEnvironmentId } from "../../state/environments";
14import { useAtomCommand } from "../../state/use-atom-command";
15import { resolveThreadRouteRef } from "../../threadRoutes";
16import { readThreadPreviewState } from "../../previewStateStore";
17import { openPreviewSession } from "../preview/openPreviewSession";
18import { SidebarMenuButton, SidebarMenuItem, useSidebar } from "../ui/sidebar";
19import { toastManager } from "../ui/toast";
20import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
21
22export 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 @@
11import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import { EnvironmentId, ProjectId, type ScopedThreadRef } from "@t3tools/contracts";
2import {
3 EnvironmentId,
4 ProjectId,
5 mergebotPullRequestUrl,
6 type ScopedThreadRef,
7} from "@t3tools/contracts";
38import { useMemo } from "react";
49
510import {
......@@ -9,7 +14,7 @@ import {
914} from "../components/pullRequest/pullRequestDetail.logic";
1015import { gitHubPullRequestBrowserUrl } from "../lib/openPullRequestLink";
1116import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanelStore";
12import { useProject } from "../state/entities";
17import { useProject, useServerConfigs } from "../state/entities";
1318import { pullRequestEnvironment } from "../state/pullRequests";
1419import { useEnvironmentQuery } from "../state/query";
1520import { useSupportsMultiplePullRequests } from "./useSupportsMultiplePullRequests";
......@@ -45,8 +50,13 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {
4550 )
4651 : input;
4752 }, [requestedReference, supportsMultiplePullRequests, project?.repositoryIdentity]);
48 const detail = useEnvironmentQuery(
53 const configurations = useServerConfigs();
54 const mergebotUrl =
4955 reference && environmentId
56 ? mergebotPullRequestUrl(configurations.get(environmentId)?.mergebot, reference)
57 : null;
58 const detail = useEnvironmentQuery(
59 reference && environmentId && !mergebotUrl
5060 ? pullRequestEnvironment.detail({
5161 environmentId,
5262 input: reference,
......@@ -64,13 +74,17 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {
6474 : null,
6575 [environmentId, reference],
6676 );
67 return reference
68 ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference })?.url ??
77 return (
78 mergebotUrl ??
79 (reference
80 ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference })
81 ?.url ??
6982 requestedReference?.url ??
7083 gitHubPullRequestBrowserUrl(
7184 project?.repositoryIdentity,
7285 reference.repository,
7386 reference.number,
7487 ))
75 : undefined;
88 : undefined)
89 );
7690}
apps/web/src/lib/openMergebotPullRequestLink.test.ts created+108
......@@ -0,0 +1,108 @@
1import { beforeEach, expect, it, vi } from "vite-plus/test";
2import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
3
4const state = vi.hoisted(() => ({
5 navigate: vi.fn(),
6 panel: vi.fn(),
7 hasProject: true,
8}));
9vi.mock("react", async (original) => ({
10 ...(await original<typeof import("react")>()),
11 useCallback: (callback: unknown) => callback,
12 useMemo: (fn: () => unknown) => fn(),
13}));
14vi.mock("../components/ui/toast", () => ({
15 toastManager: { add: vi.fn() },
16 stackedThreadToast: (value: unknown) => value,
17}));
18vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
19vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate }));
20vi.mock("../browser/useOpenLink", () => ({ useOpenLink: () => vi.fn() }));
21vi.mock("../state/server", () => ({ serverEnvironment: {} }));
22vi.mock("../state/environments", () => ({ usePrimaryEnvironmentId: () => "env" }));
23vi.mock("../rightPanelStore", () => ({
24 useRightPanelStore: { getState: () => ({ openPullRequest: state.panel }) },
25}));
26vi.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}));
57import { useOpenChangeRequestLink } from "./openPullRequestLink";
58const ref = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") };
59const event = () => ({
60 preventDefault: vi.fn(),
61 stopPropagation: vi.fn(),
62 metaKey: false,
63 ctrlKey: false,
64});
65beforeEach(() => {
66 state.hasProject = true;
67 state.navigate.mockReset();
68 state.panel.mockReset();
69});
70it.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);
86it.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);
102it("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 @@
1import type { EnvironmentId, PullRequestRef, ScopedThreadRef } from "@t3tools/contracts";
1import {
2 parseMergebotPullRequestUrl,
3 mergebotPullRequestUrl,
4 type EnvironmentId,
5 type PullRequestRef,
6 type ScopedThreadRef,
7} from "@t3tools/contracts";
28import { useAtomValue } from "@effect/atom-react";
39import { useNavigate } from "@tanstack/react-router";
410import { type MouseEvent, useCallback, useMemo } from "react";
......@@ -12,7 +18,7 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast";
1218import { useRightPanelStore } from "../rightPanelStore";
1319import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
1420
15import { useProjects, useServerConfigs } from "../state/entities";
21import { readThreadShell, useProjects, useServerConfigs } from "../state/entities";
1622import { serverEnvironment } from "../state/server";
1723import { usePrimaryEnvironmentId } from "../state/environments";
1824
......@@ -150,7 +156,18 @@ export function useOpenChangeRequestLink(
150156 if (shouldOpenPullRequestExternally(event)) return false;
151157 const resolvedThreadRef = targetThreadRef ?? threadRef;
152158 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;
154171 if (parsed === null) return false;
155172 const reads = (environmentId: string) =>
156173 serverConfigs.get(environmentId as EnvironmentId)?.environment.capabilities.pullRequests ===
......@@ -173,6 +190,9 @@ export function useOpenChangeRequestLink(
173190 Number(left.environmentId === primaryEnvironmentId),
174191 );
175192 const exactProject = findProjectForChangeRequest(projects, parsed);
193 const threadProjectId = resolvedThreadRef
194 ? readThreadShell(resolvedThreadRef)?.projectId
195 : undefined;
176196 const project =
177197 exactProject ??
178198 (resolvedPanelRef
......@@ -184,8 +204,30 @@ export function useOpenChangeRequestLink(
184204 ),
185205 parsed,
186206 )
207 : undefined) ??
208 (mergebot && resolvedThreadRef
209 ? projects.find(
210 (candidate) =>
211 candidate.environmentId === resolvedThreadRef.environmentId &&
212 candidate.id === threadProjectId,
213 )
187214 : 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;
189231 const repository =
190232 serverConfigs.get(project.environmentId)?.environment.capabilities.threadPullRequests ===
191233 true
apps/web/src/rightPanelStore.test.ts+51
......@@ -1240,3 +1240,54 @@ describe("rightPanelStore", () => {
12401240 expect(store.getUserActionRevision(refA)).toBe(revision);
12411241 });
12421242});
1243
1244describe("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 {
151151 openDevice: (ref: ScopedThreadRef, target: DeviceTabTarget, automatic?: boolean) => void;
152152 renameDevice: (ref: ScopedThreadRef, surfaceId: string, title: string) => void;
153153 openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void;
154 replacePullRequestWithBrowser: (
155 ref: ScopedThreadRef,
156 surfaceId: string,
157 tabId: string,
158 ) => boolean;
154159 openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void;
155160 openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void;
156161 openPullRequest: (
......@@ -692,6 +697,30 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
692697 return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface);
693698 }),
694699 ),
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 },
695724 openPullRequest: (ref, target) =>
696725 set((state) =>
697726 userAction(state, scopedThreadKey(ref), (current) => {
apps/web/src/routes/_chat.pull-requests.tsx+128-19
......@@ -1,3 +1,14 @@
1import {
2 MERGEBOT_BROWSER_THREAD_ID,
3 ThreadId,
4 mergebotPullRequestUrl,
5 mergebotPullRequestBrowserThreadId,
6} from "@t3tools/contracts";
7import { scopeThreadRef } from "@t3tools/client-runtime/environment";
8import { MergebotBrowserPanel } from "../components/pullRequest/MergebotBrowserPanel";
9import { clearThreadPreviewState } from "../previewStateStore";
10import { previewEnvironment } from "../state/preview";
11import { useEnvironment } from "../state/environments";
112import { RefreshIcon } from "~/components/ui/refresh-icon";
213import { Spinner } from "~/components/ui/spinner";
314import { useShortcutModifierState } from "~/shortcutModifierState";
......@@ -153,6 +164,7 @@ import {
153164 selectThreadRightPanelState,
154165 useRightPanelStore,
155166 type PullRequestSurface,
167 type RightPanelSurface,
156168} from "../rightPanelStore";
157169import { useDebouncedValue } from "../state/queries";
158170import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities";
......@@ -185,6 +197,7 @@ function getShortcutContext() {
185197}
186198
187199export interface PullRequestsSearch extends PullRequestListPreferences {
200 readonly mergebot?: EnvironmentId;
188201 /**
189202 * Narrows the list to one server. Absent means every connected one, which is the default the
190203 * 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,
306319
307320export const Route = createFileRoute("/_chat/pull-requests")({
308321 validateSearch: (raw: Record<string, unknown>): PullRequestsSearch => ({
322 ...(typeof raw.mergebot === "string" && raw.mergebot
323 ? { mergebot: raw.mergebot as EnvironmentId }
324 : {}),
309325 involvement:
310326 raw.involvement === "reviewing" || raw.involvement === "authored" ? raw.involvement : "all",
311327 state:
......@@ -356,6 +372,50 @@ export const Route = createFileRoute("/_chat/pull-requests")({
356372
357373function PullRequestsRouteView() {
358374 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
417function NativePullRequestsRouteView() {
418 const closeMergebotPreview = useAtomCommand(previewEnvironment.close);
359419 const modifiers = useShortcutModifierState(true);
360420 const speedMode =
361421 modifiers.shiftKey && !modifiers.metaKey && !modifiers.ctrlKey && !modifiers.altKey;
......@@ -2118,6 +2178,28 @@ function PullRequestsRouteView() {
21182178 searchInput,
21192179 sortMenu,
21202180 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 )),
21212203 rightPanelControl:
21222204 // Footprint reserve while the panel is closed: the toggle itself stays
21232205 // mounted at the fixed titlebar inset in both states so it cannot move
......@@ -2165,34 +2247,51 @@ function PullRequestsRouteView() {
21652247 useRightPanelStore.getState().activateSurface(rightPanelRef, surface.id);
21662248 selectSurfaceInUrl(surface);
21672249 };
2168 const closeSurface = (surface: PullRequestSurface) => {
2250 const closeSurfaces = async (surfaces: readonly RightPanelSurface[]) => {
21692251 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);
21712255 const next = selectActiveRightPanelSurface(
21722256 useRightPanelStore.getState().byThreadKey,
21732257 rightPanelRef,
21742258 );
21752259 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 }
21762286 };
2177 const closeOtherSurfaces = (surface: PullRequestSurface) => {
2178 if (rightPanelRef === null) return;
2179 useRightPanelStore.getState().closeOtherSurfaces(rightPanelRef, surface.id);
2180 selectSurfaceInUrl(surface);
2181 };
2287 const closeSurface = (surface: PullRequestSurface) => void closeSurfaces([surface]);
2288 const closeOtherSurfaces = (surface: PullRequestSurface) =>
2289 void closeSurfaces(rightPanelState.surfaces.filter((entry) => entry.id !== surface.id));
21822290 const closeSurfacesToRight = (surface: PullRequestSurface) => {
2183 if (rightPanelRef === null) return;
2184 useRightPanelStore.getState().closeSurfacesToRight(rightPanelRef, surface.id);
2185 const next = selectActiveRightPanelSurface(
2186 useRightPanelStore.getState().byThreadKey,
2187 rightPanelRef,
2188 );
2189 selectSurfaceInUrl(next?.kind === "pull-request" ? next : null);
2190 };
2191 const closeAllSurfaces = () => {
2192 if (rightPanelRef === null) return;
2193 useRightPanelStore.getState().closeAllSurfaces(rightPanelRef);
2194 selectSurfaceInUrl(null);
2291 const index = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id);
2292 if (index >= 0) void closeSurfaces(rightPanelState.surfaces.slice(index + 1));
21952293 };
2294 const closeAllSurfaces = () => void closeSurfaces(rightPanelState.surfaces);
21962295
21972296 // This page has no ChatView, so it handles the shared panel shortcuts itself.
21982297 const copyPullRequestFromShortcut = useEffectEvent((event: KeyboardEvent) => {
......@@ -2314,6 +2413,13 @@ function PullRequestsRouteView() {
23142413 shortcutsEnabled={activePullRequestSurface?.id === renderedPullRequestSurface.id}
23152414 key={renderedPullRequestSurface.id}
23162415 environmentId={panelEnvironmentId}
2416 onMergebotReady={() =>
2417 rightPanelRef !== null &&
2418 selectThreadRightPanelState(
2419 useRightPanelStore.getState().byThreadKey,
2420 rightPanelRef,
2421 ).surfaces.some((surface) => surface.id === renderedPullRequestSurface.id)
2422 }
23172423 onGuideShown={onGuideShown}
23182424 onSelectPullRequest={(reference) => {
23192425 if (rightPanelRef === null) return;
......@@ -2574,6 +2680,7 @@ function PullRequestsColumn({
25742680 searchInput,
25752681 sortMenu,
25762682 filtersMenu,
2683 mergebotControl,
25772684 rightPanelControl,
25782685 titlebarControls,
25792686 rightPanelOpen,
......@@ -2594,6 +2701,7 @@ function PullRequestsColumn({
25942701 searchInput: ReactNode;
25952702 sortMenu: ReactNode;
25962703 filtersMenu: ReactNode;
2704 mergebotControl: ReactNode;
25972705 rightPanelControl: ReactNode;
25982706 titlebarControls: ReactNode;
25992707 rightPanelOpen: boolean;
......@@ -2752,6 +2860,7 @@ function PullRequestsColumn({
27522860 </div>
27532861 {sortMenu}
27542862 {filtersMenu}
2863 {mergebotControl}
27552864 <CompactFilterMenu
27562865 label="Filter by provider"
27572866 outlined
apps/web/src/state/mergebot.test.tsx created+102
......@@ -0,0 +1,102 @@
1import { RegistryContext } from "@effect/atom-react";
2import { EnvironmentId, type MergebotRepoQueue } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";
4import * as Option from "effect/Option";
5import { AsyncResult, AtomRegistry } from "effect/reactivity";
6import { act, useLayoutEffect } from "react";
7import { create, type ReactTestRenderer } from "react-test-renderer";
8import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
9
10import { useMergebotQueue } from "./mergebot";
11
12const 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
40vi.mock("@t3tools/client-runtime/state/pull-requests", () => ({
41 createMergebotQueueAtomFamily: () => () => state.queue,
42}));
43vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: undefined }));
44vi.mock("../connection/catalog", () => ({
45 environmentCatalog: { stateAtom: () => state.connection },
46}));
47
48let registry: AtomRegistry.AtomRegistry;
49let renderer: ReactTestRenderer;
50let observed: MergebotRepoQueue | null;
51
52function Probe() {
53 const value = useMergebotQueue(EnvironmentId.make("local"), "work/app");
54 useLayoutEffect(() => {
55 observed = value;
56 });
57 return null;
58}
59
60beforeEach(() => {
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
72afterEach(() => {
73 act(() => renderer.unmount());
74 registry.dispose();
75 vi.unstubAllGlobals();
76});
77
78it("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
84it("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
93it("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 @@
1import { useAtomValue } from "@effect/atom-react";
2import { createMergebotQueueAtomFamily } from "@t3tools/client-runtime/state/pull-requests";
3import type { EnvironmentId } from "@t3tools/contracts";
4import { environmentCatalog } from "../connection/catalog";
5import { connectionAtomRuntime } from "../connection/runtime";
6
7const mergebotQueue = createMergebotQueueAtomFamily(connectionAtomRuntime);
8
9export 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 =
6868 | typeof WS_METHODS.subscribeDeviceState
6969 | typeof WS_METHODS.subscribeResourceTelemetry
7070 | typeof WS_METHODS.pullRequestsSubscribeRefreshes
71 | typeof WS_METHODS.mergebotSubscribe
7172 | typeof WS_METHODS.subscribeVcsStatus
7273 | typeof WS_METHODS.subscribeWorktreeSetup
7374 | typeof WS_METHODS.subscribeProjectClones
packages/client-runtime/src/state/pullRequests.test.ts+44
......@@ -9,6 +9,7 @@ import {
99 PullRequestOperationError,
1010 WS_METHODS,
1111 type PullRequestStack,
12 type MergebotRepoQueue,
1213 type AuthSessionState,
1314} from "@t3tools/contracts";
1415import { expect, it } from "@effect/vitest";
......@@ -43,6 +44,7 @@ import {
4344 createLinkedPullRequestSummaryAtomFamily,
4445 createPullRequestEnvironmentAtoms,
4546 createPullRequestStackAtomFamily,
47 createMergebotQueueAtomFamily,
4648} from "./pullRequests.ts";
4749import * as PullRequestDiffLoader from "./pullRequestDiffHttp.ts";
4850import { executeAtomQuery } from "./runtime.ts";
......@@ -690,6 +692,48 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (
690692 return { runtime, atoms, registry, environmentRegistry, supervisor, localSupervisor };
691693});
692694
695it.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
693737it.effect.each(["default", "origin-off", "destination-off", "read-only"] as const)(
694738 "does not probe another environment with %s routing permission",
695739 (permission) =>
packages/client-runtime/src/state/pullRequests.ts+9
......@@ -619,3 +619,12 @@ export function createPullRequestEnvironmentAtoms<R, E>(
619619 }),
620620 };
621621}
622
623export 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";
6565export * from "./worktreeSetup.ts";
6666export * from "./secretRequest.ts";
6767export * from "./clientRpcPermissions.ts";
68export * from "./desktopTerminal.ts";
68export * from "./mergebot.ts";
6969export * from "./scriptProgress.ts";
70export * from "./desktopTerminal.ts";
7071
7172export * from "./uiSettings.ts";
7273export * from "./sharedAccounts.ts";
packages/contracts/src/mergebot.test.ts created+158
......@@ -0,0 +1,158 @@
1import * as Schema from "effect/Schema";
2import * as Struct from "effect/Struct";
3import { describe, expect, it } from "vite-plus/test";
4import {
5 MergebotPullRequestDetails,
6 isMergebotRepository,
7 mergebotPullRequestStatus,
8 mergebotPullRequestUrl,
9 mergebotPullRequestBrowserThreadId,
10 parseMergebotPullRequestUrl,
11 type MergebotRepoQueue,
12} from "./mergebot.ts";
13
14const decodeDetails = Schema.decodeSync(MergebotPullRequestDetails);
15
16const 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;
25const queue: MergebotRepoQueue = {
26 repo: "work/app",
27 status: "ready",
28 url: "https://mergebot.example/work/app",
29 processing: true,
30 pullRequests: [entry],
31};
32
33describe("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
52const configuration = {
53 origin: "https://mergebot.example",
54 repositories: ["work/app", "work/research.sh", "work/research"],
55};
56
57it("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
68describe("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
81describe("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
122describe("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
141describe("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 @@
1import * as Schema from "effect/Schema";
2import { PullRequestActor, PullRequestComment, PullRequestLabel } from "./pullRequest.ts";
3import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
4
5export 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});
11export type MergebotConfiguration = typeof MergebotConfiguration.Type;
12
13export 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
23export const MergebotQueueInput = Schema.Struct({ repository: TrimmedNonEmptyString });
24export type MergebotQueueInput = typeof MergebotQueueInput.Type;
25
26export 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});
44export type MergebotPullRequestStatus = typeof MergebotPullRequestStatus.Type;
45
46export 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});
53export type MergebotRepoQueue = typeof MergebotRepoQueue.Type;
54
55export 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
67export const MergebotPullRequestInput = Schema.Struct({
68 repository: TrimmedNonEmptyString,
69 number: PositiveInt,
70});
71export type MergebotPullRequestInput = typeof MergebotPullRequestInput.Type;
72
73export 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});
83export type MergebotPullRequestCheck = typeof MergebotPullRequestCheck.Type;
84
85export 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});
107export type MergebotPullRequestDetails = typeof MergebotPullRequestDetails.Type;
108
109export const MERGEBOT_BROWSER_THREAD_ID = "mergebot-browser";
110
111export 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
118export 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
136export 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 @@
1import { MergebotQueueInput, MergebotRepoQueue } from "./mergebot.ts";
12import {
23 UiSettingsProfile,
34 UiSettingsPatchInput,
......@@ -544,6 +545,8 @@ export const WS_METHODS = {
544545 cloudInstallRelayClient: "cloud.installRelayClient",
545546
546547 // Pull request methods
548 mergebotQueue: "mergebot.queue",
549 mergebotSubscribe: "mergebot.subscribe",
547550 pullRequestsList: "pullRequests.list",
548551 pullRequestsListStats: "pullRequests.listStats",
549552 pullRequestsSummary: "pullRequests.summary",
......@@ -990,6 +993,19 @@ const PullRequestRpcError = Schema.Union([
990993 EnvironmentAuthorizationError,
991994]);
992995
996const WsMergebotQueueRpc = Rpc.make(WS_METHODS.mergebotQueue, {
997 payload: MergebotQueueInput,
998 success: Schema.NullOr(MergebotRepoQueue),
999 error: EnvironmentAuthorizationError,
1000});
1001
1002const WsMergebotSubscribeRpc = Rpc.make(WS_METHODS.mergebotSubscribe, {
1003 payload: MergebotQueueInput,
1004 success: Schema.NullOr(MergebotRepoQueue),
1005 error: EnvironmentAuthorizationError,
1006 stream: true,
1007});
1008
9931009const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, {
9941010 payload: PullRequestListInput,
9951011 success: PullRequestListResult,
......@@ -2004,6 +2020,8 @@ export const WsRpcGroup = RpcGroup.make(
20042020 WsServerGetBackgroundPolicyRpc,
20052021 WsCloudGetRelayClientStatusRpc,
20062022 WsCloudInstallRelayClientRpc,
2023 WsMergebotQueueRpc,
2024 WsMergebotSubscribeRpc,
20072025 WsPullRequestsListRpc,
20082026 WsPullRequestsListStatsRpc,
20092027 WsPullRequestsSummaryRpc,
packages/contracts/src/server.ts+2
......@@ -28,6 +28,7 @@ import { RuntimeMode } from "./providerPolicy.ts";
2828import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
2929import { ServerProviderUsageLimits, UsageLimitSourceSnapshots } from "./providerUsageLimits.ts";
3030import { ServerSettings } from "./settings.ts";
31import { MergebotConfiguration } from "./mergebot.ts";
3132
3233const KeybindingsMalformedConfigIssue = Schema.Struct({
3334 kind: Schema.Literal("keybindings.malformed-config"),
......@@ -673,6 +674,7 @@ export const ServerDirectEndpoint = Schema.Struct({
673674export type ServerDirectEndpoint = typeof ServerDirectEndpoint.Type;
674675
675676export const ServerConfig = Schema.Struct({
677 mergebot: Schema.optionalKey(MergebotConfiguration),
676678 environment: ExecutionEnvironmentDescriptor,
677679 auth: ServerAuthDescriptor,
678680 cwd: TrimmedNonEmptyString,