authorclover caruso <git@paperclover.net> 2026-10-15 14:27:26-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
logdf271d779ac3c09fbcb75b04c3bbb7791b7edb0b
tree913b9354aad2d49d4ebe4d44f98954bda589ef84
parent28cb2bd6523c6c53c7c801784e5a2724cee49a3f
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

feat(at-work): review Jujutsu stacks and ship through Mergebot

Clover wants to review agent work as a jj stack and ship it through Mergebot without leaving T3. In jj workspaces the right panel's Diff surface shows the stack (ancestors down to trunk plus anything above @) as a horizontal strip, or as a stack-and-files sidebar when there is room. Selecting a change edits its description in the fork's Markdown editor (larger title line, conventional-commit colors, Assisted-by trailers as provider chips) and saves it with jj describe; a change covered by an open PR edits the PR's text instead. Selecting a range reviews the combined diff. Expose the review surface in non-colocated Jujutsu workspaces and nested project folders, while keeping Git-only actions hidden. Auto-merge pushes a bookmark named by the repository's push template and opens the PR through Mergebot, optionally as a draft; follow-ups push by moving the bookmark. Put the stack/files toggle at the left edge beside the rail it opens. Keep empty stacks query-free, expose retryable diff failures, and retain known PR identity while live status loads. Return the bookmark actually pushed when several PR branches share a commit. The sidebar shows each thread's Mergebot status as a tinted pill on the card's third row, and a footer button beside usage opens a port of Mergebot's deploy pane with Deploy to here. Tighten the sidebar header above search and align the new-work button at its trailing edge with the other titlebar controls. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5 Assisted-by: gpt-6.1-sol

64 files changed, 5732 insertions(+), 184 deletions(-)

apps/server/src/auth/RpcAuthorization.test.ts+23-1
...@@ -166,11 +166,33 @@ describe("RPC authorization scopes", () => {...@@ -166,11 +166,33 @@ describe("RPC authorization scopes", () => {
166 });166 });
167167
168 it("requires orchestration reads for Mergebot snapshots and live updates", () => {168 it("requires orchestration reads for Mergebot snapshots and live updates", () => {
169 for (const method of [WS_METHODS.mergebotQueue, WS_METHODS.mergebotSubscribe]) {169 for (const method of [
170 WS_METHODS.mergebotQueue,
171 WS_METHODS.mergebotSubscribe,
172 WS_METHODS.mergebotSubscribePullRequestStatus,
173 WS_METHODS.mergebotSubscribeDeployments,
174 ]) {
170 expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope);175 expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope);
171 }176 }
172 });177 });
173178
179 it("requires source-control writes for Jujutsu and Mergebot mutations", () => {
180 for (const method of [
181 WS_METHODS.mergebotOpenPullRequest,
182 WS_METHODS.mergebotSetAutoMerge,
183 WS_METHODS.mergebotUpdatePullRequest,
184 WS_METHODS.mergebotDeployProduction,
185 WS_METHODS.jjDescribe,
186 WS_METHODS.jjSquash,
187 WS_METHODS.jjPush,
188 ]) {
189 expect(requiredScopeForRpcMethod(method)).toBe(AuthSourceControlWriteScope);
190 }
191 for (const method of [WS_METHODS.jjSubscribeStack, WS_METHODS.jjSnapshot, WS_METHODS.jjDiff]) {
192 expect(requiredScopeForRpcMethod(method)).toBe(AuthFilesystemReadScope);
193 }
194 });
195
174 it("separates preview control from observation", () => {196 it("separates preview control from observation", () => {
175 for (const method of [197 for (const method of [
176 WS_METHODS.previewOpen,198 WS_METHODS.previewOpen,
apps/server/src/auth/RpcAuthorization.ts+5
...@@ -126,6 +126,11 @@ export const RPC_REQUIRED_SCOPES = {...@@ -126,6 +126,11 @@ export const RPC_REQUIRED_SCOPES = {
126 [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope,126 [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope,
127 [WS_METHODS.mergebotQueue]: AuthOrchestrationReadScope,127 [WS_METHODS.mergebotQueue]: AuthOrchestrationReadScope,
128 [WS_METHODS.mergebotSubscribe]: AuthOrchestrationReadScope,128 [WS_METHODS.mergebotSubscribe]: AuthOrchestrationReadScope,
129 [WS_METHODS.mergebotSubscribePullRequestStatus]: AuthOrchestrationReadScope,
130 [WS_METHODS.mergebotSubscribeDeployments]: AuthOrchestrationReadScope,
131 [WS_METHODS.jjSubscribeStack]: AuthFilesystemReadScope,
132 [WS_METHODS.jjSnapshot]: AuthFilesystemReadScope,
133 [WS_METHODS.jjDiff]: AuthFilesystemReadScope,
129 [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,134 [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,
130 [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope,135 [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope,
131 [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope,136 [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope,
apps/server/src/git/GitManager.test.ts+6
...@@ -927,6 +927,12 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -927,6 +927,12 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
927 mergebot: {927 mergebot: {
928 configuration: { origin: "https://mergebot.example", repositories: ["work/app"] },928 configuration: { origin: "https://mergebot.example", repositories: ["work/app"] },
929 read: () => Effect.succeed(null),929 read: () => Effect.succeed(null),
930 watchPullRequestStatus: () => Stream.empty,
931 watchDeployments: () => Stream.empty,
932 openPullRequest: () => Effect.die("unused"),
933 setAutoMerge: () => Effect.die("unused"),
934 updatePullRequest: () => Effect.die("unused"),
935 deployProduction: () => Effect.die("unused"),
930 watchQueue: () => Stream.empty,936 watchQueue: () => Stream.empty,
931 streamChanges: Stream.empty,937 streamChanges: Stream.empty,
932 readPullRequest: () => Effect.sync(() => value),938 readPullRequest: () => Effect.sync(() => value),
apps/server/src/git/GitWorkflowService.test.ts+32
...@@ -8,6 +8,7 @@ import { VcsRepositoryDetectionError } from "@t3tools/contracts";...@@ -8,6 +8,7 @@ import { VcsRepositoryDetectionError } from "@t3tools/contracts";
88
9import * as NodeServices from "@effect/platform-node/NodeServices";9import * as NodeServices from "@effect/platform-node/NodeServices";
10import * as FileSystem from "effect/FileSystem";10import * as FileSystem from "effect/FileSystem";
11import * as Path from "effect/Path";
1112
12import * as ServerConfig from "../config.ts";13import * as ServerConfig from "../config.ts";
13import * as VcsProcess from "../vcs/VcsProcess.ts";14import * as VcsProcess from "../vcs/VcsProcess.ts";
...@@ -27,6 +28,7 @@ function layer(input: {...@@ -27,6 +28,7 @@ function layer(input: {
27 ),28 ),
28 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),29 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
29 Layer.provide(Layer.mock(GitManager.GitManager)({})),30 Layer.provide(Layer.mock(GitManager.GitManager)({})),
31 Layer.provide(NodeServices.layer),
30 );32 );
31}33}
3234
...@@ -86,6 +88,35 @@ describe("GitWorkflowService", () => {...@@ -86,6 +88,35 @@ describe("GitWorkflowService", () => {
86 ),88 ),
87 );89 );
8890
91 it.effect(
92 "keeps non-colocated Jujutsu changes available without reporting a Git repository",
93 () =>
94 Effect.gen(function* () {
95 const workflow = yield* GitWorkflowService.GitWorkflowService;
96 const fs = yield* FileSystem.FileSystem;
97 const path = yield* Path.Path;
98 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-jj-workflow-" });
99 yield* fs.makeDirectory(path.join(root, ".jj"));
100 const nested = path.join(root, "packages", "app");
101 yield* fs.makeDirectory(nested, { recursive: true });
102
103 for (const cwd of [root, nested]) {
104 const local = yield* workflow.localStatus({ cwd });
105 const status = yield* workflow.status({ cwd });
106 expect(local.isRepo).toBe(false);
107 expect(local.isJjWorkspace).toBe(true);
108 expect(status.isRepo).toBe(false);
109 expect(status.isJjWorkspace).toBe(true);
110 expect(status.refName).toBeNull();
111 }
112 }).pipe(
113 Effect.scoped,
114 Effect.provide(
115 Layer.mergeAll(layer({ detect: () => Effect.succeed(null) }), NodeServices.layer),
116 ),
117 ),
118 );
119
89 it.effect("returns an empty full status when no VCS repository is detected", () =>120 it.effect("returns an empty full status when no VCS repository is detected", () =>
90 Effect.gen(function* () {121 Effect.gen(function* () {
91 const workflow = yield* GitWorkflowService.GitWorkflowService;122 const workflow = yield* GitWorkflowService.GitWorkflowService;
...@@ -136,6 +167,7 @@ describe("GitWorkflowService", () => {...@@ -136,6 +167,7 @@ describe("GitWorkflowService", () => {
136 status,167 status,
137 }),168 }),
138 ),169 ),
170 Layer.provide(NodeServices.layer),
139 );171 );
140172
141 return Effect.gen(function* () {173 return Effect.gen(function* () {
apps/server/src/git/GitWorkflowService.ts+40-30
...@@ -1,6 +1,9 @@...@@ -1,6 +1,9 @@
1import * as Context from "effect/Context";1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import { mergeGitStatusParts } from "@t3tools/shared/git";
47
5import {8import {
6 GitManagerError,9 GitManagerError,
...@@ -127,32 +130,6 @@ export class GitWorkflowService extends Context.Service<...@@ -127,32 +130,6 @@ export class GitWorkflowService extends Context.Service<
127 }130 }
128>()("t3/git/GitWorkflowService") {}131>()("t3/git/GitWorkflowService") {}
129132
130function nonRepositoryLocalStatus(): VcsStatusLocalResult {
131 return {
132 isRepo: false,
133 hasPrimaryRemote: false,
134 isDefaultRef: false,
135 refName: null,
136 hasWorkingTreeChanges: false,
137 workingTree: {
138 files: [],
139 insertions: 0,
140 deletions: 0,
141 },
142 };
143}
144
145function nonRepositoryStatus(): VcsStatusResult {
146 return {
147 ...nonRepositoryLocalStatus(),
148 hasUpstream: false,
149 aheadCount: 0,
150 behindCount: 0,
151 aheadOfDefaultCount: 0,
152 pr: null,
153 };
154}
155
156function nonRepositoryListRefs(): VcsListRefsResult {133function nonRepositoryListRefs(): VcsListRefsResult {
157 return {134 return {
158 refs: [],135 refs: [],
...@@ -168,6 +145,37 @@ export const make = Effect.gen(function* () {...@@ -168,6 +145,37 @@ export const make = Effect.gen(function* () {
168 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;145 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
169 const git = yield* GitVcsDriver.GitVcsDriver;146 const git = yield* GitVcsDriver.GitVcsDriver;
170 const gitManager = yield* GitManager.GitManager;147 const gitManager = yield* GitManager.GitManager;
148 const fileSystem = yield* FileSystem.FileSystem;
149 const path = yield* Path.Path;
150
151 const nonGitLocalStatus = Effect.fn("GitWorkflowService.nonGitLocalStatus")(function* (
152 cwd: string,
153 ) {
154 let directory = yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd));
155 let isJjWorkspace = false;
156 while (true) {
157 const metadata = yield* fileSystem
158 .stat(path.join(directory, ".jj"))
159 .pipe(Effect.orElseSucceed(() => null));
160 if (metadata?.type === "Directory") {
161 isJjWorkspace = true;
162 break;
163 }
164 const parent = path.dirname(directory);
165 if (parent === directory) break;
166 directory = parent;
167 }
168
169 return {
170 isRepo: false,
171 ...(isJjWorkspace ? { isJjWorkspace: true } : {}),
172 hasPrimaryRemote: false,
173 isDefaultRef: false,
174 refName: null,
175 hasWorkingTreeChanges: false,
176 workingTree: { files: [], insertions: 0, deletions: 0 },
177 } satisfies VcsStatusLocalResult;
178 });
171179
172 const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* (180 const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* (
173 operation: string,181 operation: string,
...@@ -331,15 +339,17 @@ export const make = Effect.gen(function* () {...@@ -331,15 +339,17 @@ export const make = Effect.gen(function* () {
331 status: (input) =>339 status: (input) =>
332 detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe(340 detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe(
333 Effect.flatMap((isGitRepository) =>341 Effect.flatMap((isGitRepository) =>
334 isGitRepository ? gitManager.status(input) : Effect.succeed(nonRepositoryStatus()),342 isGitRepository
343 ? gitManager.status(input)
344 : nonGitLocalStatus(input.cwd).pipe(
345 Effect.map((local) => mergeGitStatusParts(local, null)),
346 ),
335 ),347 ),
336 ),348 ),
337 localStatus: (input) =>349 localStatus: (input) =>
338 detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe(350 detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe(
339 Effect.flatMap((isGitRepository) =>351 Effect.flatMap((isGitRepository) =>
340 isGitRepository352 isGitRepository ? gitManager.localStatus(input) : nonGitLocalStatus(input.cwd),
341 ? gitManager.localStatus(input)
342 : Effect.succeed(nonRepositoryLocalStatus()),
343 ),353 ),
344 ),354 ),
345 remoteStatus: (input, options) =>355 remoteStatus: (input, options) =>
apps/server/src/mergebot/MergebotQueue.test.ts+270-10
...@@ -51,6 +51,16 @@ const check = {...@@ -51,6 +51,16 @@ const check = {
51 startedAt: "2026-10-14T10:00:00Z",51 startedAt: "2026-10-14T10:00:00Z",
52 completedAt: "2026-10-14T10:01:00Z",52 completedAt: "2026-10-14T10:01:00Z",
53};53};
54const repo = {
55 processing: true,
56 defaultBranch: "main",
57 productionBranch: "production",
58 stagingUrl: "https://staging.example",
59 productionUrl: "https://app.example",
60 triggerLabel: "merge",
61 stagingDeployment: null,
62 productionDeployment: null,
63};
54const repoChannel = { kind: "repo_overview", repoId: repository };64const repoChannel = { kind: "repo_overview", repoId: repository };
55const detailChannel = { kind: "pull_request_detail", repoId: repository, prNumber: 123 };65const detailChannel = { kind: "pull_request_detail", repoId: repository, prNumber: 123 };
56const repoSnapshot = {66const repoSnapshot = {
...@@ -59,8 +69,13 @@ const repoSnapshot = {...@@ -59,8 +69,13 @@ const repoSnapshot = {
59 channel: repoChannel,69 channel: repoChannel,
60 channelKey: `repo_overview:${repository}`,70 channelKey: `repo_overview:${repository}`,
61 payload: {71 payload: {
62 entities: { repos: { [repository]: { processing: true } }, pullRequests: { [pr.id]: pr } },72 entities: {
63 view: { repoId: repository, queuePrIds: [pr.id] },73 repos: { [repository]: repo },
74 users: {},
75 pullRequests: { [pr.id]: pr },
76 mergedCommits: {},
77 },
78 view: { repoId: repository, queuePrIds: [pr.id], mergedCommitIds: [] },
64 },79 },
65};80};
66const detailSnapshot = {81const detailSnapshot = {
...@@ -122,11 +137,26 @@ function harness(configurationText: string | null = JSON.stringify(configuration...@@ -122,11 +137,26 @@ function harness(configurationText: string | null = JSON.stringify(configuration
122 const sockets: TestSocket[] = [];137 const sockets: TestSocket[] = [];
123 const exchanges: Array<{ url: string; authorization: string | undefined }> = [];138 const exchanges: Array<{ url: string; authorization: string | undefined }> = [];
124 let exchangeStatus = 200;139 let exchangeStatus = 200;
140 let session = "sealed-mergebot-session";
141 const mutations: Array<{
142 method: string;
143 url: string;
144 authorization: string | undefined;
145 body: unknown;
146 }> = [];
147 let respond = (_request: (typeof mutations)[number]) => Response.json({ ok: true });
125 const requests: Array<{ url: string; options?: Socket.WebSocketConstructorOptions }> = [];148 const requests: Array<{ url: string; options?: Socket.WebSocketConstructorOptions }> = [];
126 return {149 return {
127 sockets,150 sockets,
128 requests,151 requests,
129 exchanges,152 exchanges,
153 mutations,
154 respondWith: (handler: typeof respond) => {
155 respond = handler;
156 },
157 setSession: (value: string) => {
158 session = value;
159 },
130 setExchangeStatus: (status: number) => {160 setExchangeStatus: (status: number) => {
131 exchangeStatus = status;161 exchangeStatus = status;
132 },162 },
...@@ -143,13 +173,26 @@ function harness(configurationText: string | null = JSON.stringify(configuration...@@ -143,13 +173,26 @@ function harness(configurationText: string | null = JSON.stringify(configuration
143 Layer.succeed(173 Layer.succeed(
144 HttpClient.HttpClient,174 HttpClient.HttpClient,
145 HttpClient.make((request) => {175 HttpClient.make((request) => {
146 exchanges.push({ url: request.url, authorization: request.headers.authorization });176 if (request.url.endsWith("/api/auth/github/token")) {
147 return Effect.succeed(177 exchanges.push({ url: request.url, authorization: request.headers.authorization });
148 HttpClientResponse.fromWeb(178 return Effect.succeed(
149 request,179 HttpClientResponse.fromWeb(
150 Response.json({ token: "sealed-mergebot-session" }, { status: exchangeStatus }),180 request,
151 ),181 Response.json({ token: session }, { status: exchangeStatus }),
152 );182 ),
183 );
184 }
185 const mutation = {
186 method: request.method,
187 url: request.url,
188 authorization: request.headers.authorization,
189 body:
190 request.body._tag === "Uint8Array"
191 ? JSON.parse(new TextDecoder().decode(request.body.body))
192 : undefined,
193 };
194 mutations.push(mutation);
195 return Effect.succeed(HttpClientResponse.fromWeb(request, respond(mutation)));
153 }),196 }),
154 ),197 ),
155 Layer.succeed(HostProcess.HomeDirectory, "/test"),198 Layer.succeed(HostProcess.HomeDirectory, "/test"),
...@@ -553,7 +596,7 @@ describe("MergebotQueue", () => {...@@ -553,7 +596,7 @@ describe("MergebotQueue", () => {
553 ...repoSnapshot.payload,596 ...repoSnapshot.payload,
554 entities: {597 entities: {
555 ...repoSnapshot.payload.entities,598 ...repoSnapshot.payload.entities,
556 repos: { [repository]: { processing: false } },599 repos: { [repository]: { ...repo, processing: false } },
557 },600 },
558 },601 },
559 });602 });
...@@ -664,4 +707,221 @@ describe("MergebotQueue", () => {...@@ -664,4 +707,221 @@ describe("MergebotQueue", () => {
664 }).pipe(Effect.provide(h.layer));707 }).pipe(Effect.provide(h.layer));
665 },708 },
666 );709 );
710
711 it.effect("mutations reuse the session and enable auto-merge after opening", () => {
712 const h = harness();
713 h.respondWith((request) =>
714 request.url.endsWith("/pulls") ? Response.json({ number: 456 }) : Response.json({ ok: true }),
715 );
716 return Effect.gen(function* () {
717 const service = yield* MergebotQueue.MergebotQueue;
718 expect(
719 yield* service.openPullRequest({
720 repository: "WORK/APP",
721 head: "feature",
722 title: "Ship change",
723 body: "",
724 draft: false,
725 autoMerge: true,
726 }),
727 ).toEqual({ number: 456, url: `${url}/pull/456` });
728 yield* service.updatePullRequest({ repository, number: 456, draft: true });
729 yield* service.setAutoMerge({ repository, number: 456, enabled: false });
730 yield* service.deployProduction({ repository, sha: "abc123" });
731 const api = "https://mergebot.example/api/repos/work/app";
732 const authorization = "Bearer sealed-mergebot-session";
733 expect(h.mutations).toEqual([
734 {
735 method: "POST",
736 url: `${api}/pulls`,
737 authorization,
738 body: { head: "feature", title: "Ship change", body: "", draft: false },
739 },
740 {
741 method: "POST",
742 url: `${api}/pulls/456/auto-merge`,
743 authorization,
744 body: { enabled: true },
745 },
746 { method: "PATCH", url: `${api}/pulls/456`, authorization, body: { draft: true } },
747 {
748 method: "POST",
749 url: `${api}/pulls/456/auto-merge`,
750 authorization,
751 body: { enabled: false },
752 },
753 { method: "POST", url: `${api}/deploy/production`, authorization, body: { sha: "abc123" } },
754 ]);
755 expect(h.exchanges).toHaveLength(1);
756 expect(h.sockets).toHaveLength(0);
757 }).pipe(Effect.provide(h.layer));
758 });
759
760 it.effect("a rejected session is exchanged once and failures carry Mergebot's message", () => {
761 const h = harness();
762 h.setSession("stale-session");
763 h.respondWith((request) => {
764 if (request.authorization !== "Bearer stale-session")
765 return Response.json({ message: "Reference update failed" }, { status: 422 });
766 h.setSession("fresh-session");
767 return new Response(null, { status: 401 });
768 });
769 return Effect.gen(function* () {
770 const service = yield* MergebotQueue.MergebotQueue;
771 const error = yield* service
772 .deployProduction({ repository, sha: "abc123" })
773 .pipe(Effect.flip);
774 expect(error).toMatchObject({
775 _tag: "MergebotOperationError",
776 operation: "deploy to production",
777 status: 422,
778 detail: "Reference update failed",
779 });
780 expect(h.mutations.map((request) => request.authorization)).toEqual([
781 "Bearer stale-session",
782 "Bearer fresh-session",
783 ]);
784 expect(h.exchanges).toHaveLength(2);
785 expect(
786 yield* service
787 .deployProduction({ repository: "other/app", sha: "abc123" })
788 .pipe(Effect.flip),
789 ).toMatchObject({ status: null, detail: "other/app is not a Mergebot repository" });
790 expect(h.mutations).toHaveLength(2);
791 }).pipe(Effect.provide(h.layer));
792 });
793
794 it.effect("an auto-merge failure does not fail the pull request it follows", () => {
795 const h = harness();
796 h.respondWith((request) =>
797 request.url.endsWith("/pulls")
798 ? Response.json({ number: 7 })
799 : Response.json({ message: "No trigger label" }, { status: 400 }),
800 );
801 return Effect.gen(function* () {
802 const service = yield* MergebotQueue.MergebotQueue;
803 expect(
804 yield* service.openPullRequest({
805 repository,
806 head: "feature",
807 base: "release",
808 title: "Ship change",
809 body: "Body",
810 draft: true,
811 autoMerge: true,
812 }),
813 ).toEqual({ number: 7, url: `${url}/pull/7` });
814 expect(h.mutations[0]?.body).toEqual({
815 head: "feature",
816 base: "release",
817 title: "Ship change",
818 body: "Body",
819 draft: true,
820 });
821 }).pipe(Effect.provide(h.layer));
822 });
823
824 it.effect("status watchers hold the detail channel and release it after they leave", () => {
825 const h = harness();
826 return Effect.gen(function* () {
827 const service = yield* MergebotQueue.MergebotQueue;
828 const loaded = yield* Deferred.make<unknown>();
829 const watcher = yield* service.watchPullRequestStatus({ repository, number: 123 }).pipe(
830 Stream.filter((status) => status?.checks != null),
831 Stream.runForEach((status) => Deferred.succeed(loaded, status)),
832 Effect.forkScoped({ startImmediately: true }),
833 );
834 expect(yield* Deferred.await(loaded)).toEqual({
835 number: 123,
836 url: `${url}/pull/123`,
837 title: "Ship change",
838 body: null,
839 headSha: "current-head",
840 headBranch: "feature",
841 lifecycle: "open",
842 draft: false,
843 autoMerge: false,
844 ciStatus: "pending",
845 checks: { completed: 1, total: 1, failed: 1 },
846 hasConflicts: false,
847 queueStatus: "waiting_ci",
848 position: 1,
849 mergeEtaAt: null,
850 deploy: null,
851 });
852 const unsubscribe = { type: "unsubscribe", channelKey: detailSnapshot.channelKey };
853 yield* TestClock.adjust("6 minutes");
854 expect(h.sockets[0]?.sent).not.toContainEqual(unsubscribe);
855 yield* Fiber.interrupt(watcher);
856 yield* TestClock.adjust("5 minutes");
857 expect(h.sockets[0]?.sent.at(-1)).toEqual(unsubscribe);
858 }).pipe(Effect.provide(h.layer));
859 });
860
861 it.effect("deployment watchers map the overview's repository record", () => {
862 const h = harness();
863 const stagingDeployment = {
864 sha: "merged-sha",
865 deployedSha: "merged-sha",
866 mode: "branch_ci",
867 status: "passed",
868 currentStep: null,
869 etaAt: null,
870 checksUrl: "https://github.com/work/app/commit/merged-sha/checks",
871 };
872 const merged = { ...pr, id: `${repository}#100`, number: 100, lifecycle: "merged" };
873 h.subscribeWith((socket, channel) => {
874 if (channel.kind !== "repo_overview") return;
875 socket.snapshot({
876 ...repoSnapshot,
877 payload: {
878 entities: {
879 repos: { [repository]: { ...repo, stagingDeployment } },
880 users: {},
881 pullRequests: { [pr.id]: pr, [merged.id]: merged },
882 mergedCommits: {
883 "merged-sha": {
884 id: "merged-sha",
885 repoId: repository,
886 sha: "merged-sha",
887 pullRequestNumber: 100,
888 authorId: null,
889 authorName: "Octo",
890 message: "Merged change",
891 mergedAt: "2026-10-14T09:00:00Z",
892 url: "https://github.com/work/app/pull/100",
893 labels: [],
894 status: "default",
895 isProductionHead: false,
896 },
897 },
898 },
899 view: { repoId: repository, queuePrIds: [pr.id], mergedCommitIds: ["merged-sha"] },
900 },
901 });
902 });
903 return Effect.gen(function* () {
904 const service = yield* MergebotQueue.MergebotQueue;
905 const deployments = yield* Stream.runHead(service.watchDeployments({ repository }));
906 expect(deployments).toMatchObject({
907 _tag: "Some",
908 value: {
909 repository,
910 staging: { sha: "merged-sha", status: "passed" },
911 production: null,
912 mergedCommits: [{ sha: "merged-sha", pullRequestNumber: 100, authorName: "Octo" }],
913 },
914 });
915 const status = yield* Stream.runHead(
916 service.watchPullRequestStatus({ repository, number: 100 }),
917 );
918 expect(status).toMatchObject({
919 _tag: "Some",
920 value: { lifecycle: "merged", deploy: { state: "staging" } },
921 });
922 expect(yield* Stream.runHead(service.watchDeployments({ repository: "other/app" }))).toEqual(
923 expect.objectContaining({ _tag: "Some", value: null }),
924 );
925 }).pipe(Effect.provide(h.layer));
926 });
667});927});
apps/server/src/mergebot/MergebotQueue.ts+294-75
...@@ -1,11 +1,19 @@...@@ -1,11 +1,19 @@
1import {1import {
2 MergebotConfiguration,2 MergebotConfiguration,
3 MergebotDeployments,
4 MergebotOpenPullRequestResult,
5 MergebotOperationError,
3 MergebotPullRequestCheck,6 MergebotPullRequestCheck,
7 type MergebotDeployProductionInput,
8 type MergebotOpenPullRequestInput,
4 MergebotPullRequestDetails,9 MergebotPullRequestDetails,
10 MergebotPullRequestLiveStatus,
5 MergebotPullRequestStatus,11 MergebotPullRequestStatus,
6 type MergebotPullRequestInput,12 type MergebotPullRequestInput,
7 type MergebotQueueInput,13 type MergebotQueueInput,
8 MergebotRepoQueue,14 MergebotRepoQueue,
15 type MergebotSetAutoMergeInput,
16 type MergebotUpdatePullRequestInput,
9 PullRequestActor,17 PullRequestActor,
10} from "@t3tools/contracts";18} from "@t3tools/contracts";
11import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials";19import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials";
...@@ -25,19 +33,8 @@ import * as Schema from "effect/Schema";...@@ -25,19 +33,8 @@ import * as Schema from "effect/Schema";
25import * as Stream from "effect/Stream";33import * as Stream from "effect/Stream";
26import * as Socket from "effect/socket/Socket";34import * as Socket from "effect/socket/Socket";
27import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";35import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
36import { deployments, pullRequestLiveStatus, PullRequestRecord, RepoPayload } from "./overview.ts";
2837
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({38const Comment = Schema.Struct({
42 id: Schema.String,39 id: Schema.String,
43 kind: Schema.Literals(["comment", "review", "review_comment"]),40 kind: Schema.Literals(["comment", "review", "review_comment"]),
...@@ -55,13 +52,6 @@ const DetailChannel = Schema.Struct({...@@ -55,13 +52,6 @@ const DetailChannel = Schema.Struct({
55 repoId: Schema.String,52 repoId: Schema.String,
56 prNumber: Schema.Number,53 prNumber: Schema.Number,
57});54});
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([55const Message = Schema.Union([
66 Schema.Struct({56 Schema.Struct({
67 type: Schema.Literal("snapshot"),57 type: Schema.Literal("snapshot"),
...@@ -98,6 +88,12 @@ const Message = Schema.Union([...@@ -98,6 +88,12 @@ const Message = Schema.Union([
98const decodeMessage = Schema.decodeEffect(Schema.fromJsonString(Message));88const decodeMessage = Schema.decodeEffect(Schema.fromJsonString(Message));
99const encodeStatus = Schema.encodeSync(MergebotPullRequestStatus);89const encodeStatus = Schema.encodeSync(MergebotPullRequestStatus);
100const queueEquivalence = Schema.toEquivalence(Schema.NullOr(MergebotRepoQueue));90const queueEquivalence = Schema.toEquivalence(Schema.NullOr(MergebotRepoQueue));
91const liveStatusEquivalence = Schema.toEquivalence(Schema.NullOr(MergebotPullRequestLiveStatus));
92const deploymentsEquivalence = Schema.toEquivalence(Schema.NullOr(MergebotDeployments));
93const decodeApiError = Schema.decodeUnknownEffect(Schema.Struct({ message: Schema.String }));
94const decodeOpenedPullRequest = Schema.decodeUnknownEffect(
95 Schema.Struct({ number: MergebotOpenPullRequestResult.fields.number }),
96);
101const decodeSession = Schema.decodeUnknownEffect(97const decodeSession = Schema.decodeUnknownEffect(
102 Schema.Struct({98 Schema.Struct({
103 token: Schema.String.check(Schema.isMinLength(1)),99 token: Schema.String.check(Schema.isMinLength(1)),
...@@ -125,6 +121,24 @@ export class MergebotQueue extends Context.Service<...@@ -125,6 +121,24 @@ export class MergebotQueue extends Context.Service<
125 readonly repository: string;121 readonly repository: string;
126 readonly headBranch: string;122 readonly headBranch: string;
127 }) => Effect.Effect<MergebotPullRequestDetails | null>;123 }) => Effect.Effect<MergebotPullRequestDetails | null>;
124 readonly watchPullRequestStatus: (
125 input: MergebotPullRequestInput,
126 ) => Stream.Stream<MergebotPullRequestLiveStatus | null>;
127 readonly watchDeployments: (
128 input: MergebotQueueInput,
129 ) => Stream.Stream<MergebotDeployments | null>;
130 readonly openPullRequest: (
131 input: MergebotOpenPullRequestInput,
132 ) => Effect.Effect<MergebotOpenPullRequestResult, MergebotOperationError>;
133 readonly setAutoMerge: (
134 input: MergebotSetAutoMergeInput,
135 ) => Effect.Effect<void, MergebotOperationError>;
136 readonly updatePullRequest: (
137 input: MergebotUpdatePullRequestInput,
138 ) => Effect.Effect<void, MergebotOperationError>;
139 readonly deployProduction: (
140 input: MergebotDeployProductionInput,
141 ) => Effect.Effect<void, MergebotOperationError>;
128 readonly streamChanges: Stream.Stream<Change>;142 readonly streamChanges: Stream.Stream<Change>;
129 }143 }
130>()("t3/mergebot/MergebotQueue") {}144>()("t3/mergebot/MergebotQueue") {}
...@@ -165,7 +179,7 @@ const make = Effect.gen(function* () {...@@ -165,7 +179,7 @@ const make = Effect.gen(function* () {
165 const constructSocket = yield* Socket.WebSocketConstructor;179 const constructSocket = yield* Socket.WebSocketConstructor;
166 const scope = yield* Effect.scope;180 const scope = yield* Effect.scope;
167 const changes = yield* PubSub.unbounded<Change>();181 const changes = yield* PubSub.unbounded<Change>();
168 const repos = new Map<string, typeof RepoPayload.Type | null>();182 const repos = new Map<string, RepoPayload | null>();
169 const details = new Map<string, MergebotPullRequestDetails | null>();183 const details = new Map<string, MergebotPullRequestDetails | null>();
170 const channels = new Map<string, Channel>();184 const channels = new Map<string, Channel>();
171 const observations = new Map<string, number>();185 const observations = new Map<string, number>();
...@@ -175,6 +189,9 @@ const make = Effect.gen(function* () {...@@ -175,6 +189,9 @@ const make = Effect.gen(function* () {
175 let started = false;189 let started = false;
176 let account: string | undefined;190 let account: string | undefined;
177 let activeSocket: Socket.WebSocketLike | undefined;191 let activeSocket: Socket.WebSocketLike | undefined;
192 let session: { readonly fingerprint: string; readonly token: Redacted.Redacted } | undefined;
193 /** Live status watchers per detail channel; a watched channel is never idle. */
194 const watchers = new Map<string, number>();
178195
179 const repositoryFor = (repository: string) =>196 const repositoryFor = (repository: string) =>
180 configuration?.repositories.find((repo) => repo.toLowerCase() === repository.toLowerCase());197 configuration?.repositories.find((repo) => repo.toLowerCase() === repository.toLowerCase());
...@@ -268,10 +285,7 @@ const make = Effect.gen(function* () {...@@ -268,10 +285,7 @@ const make = Effect.gen(function* () {
268 yield* invalidate(message.channelKey);285 yield* invalidate(message.channelKey);
269 return;286 return;
270 }287 }
271 repos.set(repository, {288 repos.set(repository, { entities, view });
272 entities: { repos: entities.repos, pullRequests: entities.pullRequests },
273 view,
274 });
275 for (const record of pullRequestRecords) {289 for (const record of pullRequestRecords) {
276 const key = channelKey({290 const key = channelKey({
277 kind: "pull_request_detail",291 kind: "pull_request_detail",
...@@ -351,16 +365,13 @@ const make = Effect.gen(function* () {...@@ -351,16 +365,13 @@ const make = Effect.gen(function* () {
351 yield* PubSub.publish(changes, { repository, number: message.channel.prNumber });365 yield* PubSub.publish(changes, { repository, number: message.channel.prNumber });
352 });366 });
353367
354 const connect = Effect.gen(function* () {368 const exchange = Effect.fnUntraced(function* (
355 if (!configuration) return;369 origin: string,
356 const credential = yield* credentials.get("github.com");370 credential: GitHubCredentials.GitHubCredential,
357 if (account !== undefined && account !== credential.fingerprint) {371 ) {
358 yield* invalidate();
359 }
360 account = credential.fingerprint;
361 const response = yield* http372 const response = yield* http
362 .execute(373 .execute(
363 HttpClientRequest.post(`${configuration.origin}/api/auth/github/token`).pipe(374 HttpClientRequest.post(`${origin}/api/auth/github/token`).pipe(
364 HttpClientRequest.setHeader(375 HttpClientRequest.setHeader(
365 "Authorization",376 "Authorization",
366 `Bearer ${Redacted.value(credential.token)}`,377 `Bearer ${Redacted.value(credential.token)}`,
...@@ -376,8 +387,19 @@ const make = Effect.gen(function* () {...@@ -376,8 +387,19 @@ const make = Effect.gen(function* () {
376 if (response.status === 401) yield* credentials.invalidate("github.com");387 if (response.status === 401) yield* credentials.invalidate("github.com");
377 return yield* new MergebotAuthenticationError({ status: response.status });388 return yield* new MergebotAuthenticationError({ status: response.status });
378 }389 }
379 const session = yield* response.json.pipe(Effect.flatMap(decodeSession));390 const { token } = yield* response.json.pipe(Effect.flatMap(decodeSession));
380 const token = Redacted.make(session.token);391 session = { fingerprint: credential.fingerprint, token: Redacted.make(token) };
392 return session.token;
393 });
394
395 const connect = Effect.gen(function* () {
396 if (!configuration) return;
397 const credential = yield* credentials.get("github.com");
398 if (account !== undefined && account !== credential.fingerprint) {
399 yield* invalidate();
400 }
401 account = credential.fingerprint;
402 const token = yield* exchange(configuration.origin, credential);
381 const socket = yield* Socket.fromWebSocket(403 const socket = yield* Socket.fromWebSocket(
382 Effect.acquireRelease(404 Effect.acquireRelease(
383 Effect.sync(() => {405 Effect.sync(() => {
...@@ -432,7 +454,7 @@ const make = Effect.gen(function* () {...@@ -432,7 +454,7 @@ const make = Effect.gen(function* () {
432 const releaseIdleChannels = Effect.gen(function* () {454 const releaseIdleChannels = Effect.gen(function* () {
433 const now = yield* Clock.currentTimeMillis;455 const now = yield* Clock.currentTimeMillis;
434 for (const [key, at] of lastReadAt) {456 for (const [key, at] of lastReadAt) {
435 if (now - at < 5 * 60_000) continue;457 if (watchers.has(key) || now - at < 5 * 60_000) continue;
436 lastReadAt.delete(key);458 lastReadAt.delete(key);
437 channels.delete(key);459 channels.delete(key);
438 details.delete(key);460 details.delete(key);
...@@ -498,21 +520,9 @@ const make = Effect.gen(function* () {...@@ -498,21 +520,9 @@ const make = Effect.gen(function* () {
498 return (yield* authorize()) === fingerprint ? value : null;520 return (yield* authorize()) === fingerprint ? value : null;
499 });521 });
500522
501 const readPullRequest = Effect.fn("MergebotQueue.readPullRequest")(function* (523 const subscribeDetail = Effect.fnUntraced(function* (repository: string, number: number) {
502 input: MergebotPullRequestInput,524 const channel = { kind: "pull_request_detail", repoId: repository, prNumber: number } as const;
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);525 const key = channelKey(channel);
515 lastReadAt.set(key, yield* Clock.currentTimeMillis);
516 if (!channels.has(key)) {526 if (!channels.has(key)) {
517 channels.set(key, channel);527 channels.set(key, channel);
518 if (writer)528 if (writer)
...@@ -521,46 +531,189 @@ const make = Effect.gen(function* () {...@@ -521,46 +531,189 @@ const make = Effect.gen(function* () {
521 .pipe(Effect.orElseSucceed(() => undefined));531 .pipe(Effect.orElseSucceed(() => undefined));
522 }532 }
523 yield* retryChannel(key);533 yield* retryChannel(key);
524 return yield* waitFor(fingerprint, () => details.get(key));534 return key;
525 });535 });
526536
527 const read = Effect.fn("MergebotQueue.read")(function* (input: MergebotQueueInput) {537 /** Resolves a configured repository for the current account and keeps its overview live. */
528 const repository = repositoryFor(input.repository);538 const openOverview = Effect.fnUntraced(function* (input: string) {
539 const repository = repositoryFor(input);
529 if (!repository) return null;540 if (!repository) return null;
530 const fingerprint = yield* authorize();541 const fingerprint = yield* authorize();
531 if (fingerprint === null) return null;542 if (fingerprint === null) return null;
532 yield* start();543 yield* start();
533 yield* retryChannel(channelKey({ kind: "repo_overview", repoId: repository }));544 yield* retryChannel(channelKey({ kind: "repo_overview", repoId: repository }));
534 return yield* waitFor(fingerprint, () => queueFor(repository));545 return { repository, fingerprint };
546 });
547
548 const readPullRequest = Effect.fn("MergebotQueue.readPullRequest")(function* (
549 input: MergebotPullRequestInput,
550 ) {
551 const repository = repositoryFor(input.repository);
552 if (!repository) return null;
553 const fingerprint = yield* authorize();
554 if (fingerprint === null) return null;
555 yield* start();
556 const key = yield* subscribeDetail(repository, input.number);
557 lastReadAt.set(key, yield* Clock.currentTimeMillis);
558 return yield* waitFor(fingerprint, () => details.get(key));
559 });
560
561 const read = Effect.fn("MergebotQueue.read")(function* (input: MergebotQueueInput) {
562 const opened = yield* openOverview(input.repository);
563 if (!opened) return null;
564 return yield* waitFor(opened.fingerprint, () => queueFor(opened.repository));
565 });
566
567 const readPullRequestStatus = Effect.fn("MergebotQueue.readPullRequestStatus")(function* (
568 input: MergebotPullRequestInput,
569 ) {
570 const opened = yield* openOverview(input.repository);
571 if (!opened || !configuration) return null;
572 const { origin } = configuration;
573 const { repository } = opened;
574 const key = yield* subscribeDetail(repository, input.number);
575 return yield* waitFor(opened.fingerprint, () => {
576 const payload = repos.get(repository);
577 const detail = details.get(key);
578 if (!payload) return payload;
579 // Closed and long-merged PRs exist only in their detail snapshot.
580 const inOverview = payload.entities.pullRequests[`${repository}#${input.number}`];
581 if (!inOverview && detail === undefined) return undefined;
582 return pullRequestLiveStatus(
583 `${origin}/${repository}`,
584 repository,
585 payload,
586 detail,
587 input.number,
588 );
589 });
590 });
591
592 const readDeployments = Effect.fn("MergebotQueue.readDeployments")(function* (
593 input: MergebotQueueInput,
594 ) {
595 const opened = yield* openOverview(input.repository);
596 if (!opened || !configuration) return null;
597 const { origin } = configuration;
598 const { repository } = opened;
599 return yield* waitFor(opened.fingerprint, () => {
600 const payload = repos.get(repository);
601 return payload && deployments(`${origin}/${repository}`, repository, payload);
602 });
603 });
604
605 const watch = <A>(
606 repository: string,
607 read: Effect.Effect<A>,
608 equivalence: (self: A, that: A) => boolean,
609 ) =>
610 Stream.unwrap(
611 Effect.gen(function* () {
612 const subscription = yield* PubSub.subscribe(changes);
613 return Stream.concat(
614 Stream.fromEffect(read),
615 Stream.fromSubscription(subscription).pipe(
616 Stream.filter((change) => change.repository.toLowerCase() === repository.toLowerCase()),
617 Stream.mapEffect(() => read),
618 ),
619 );
620 }),
621 ).pipe(Stream.changesWith(equivalence));
622
623 /** Sends one mutation with the Mergebot session, exchanging a fresh session once on 401. */
624 const request = Effect.fnUntraced(function* (
625 operation: string,
626 input: { readonly repository: string },
627 method: "POST" | "PATCH",
628 path: string,
629 body: object,
630 ) {
631 const repository = repositoryFor(input.repository);
632 if (!configuration || !repository)
633 return yield* new MergebotOperationError({
634 operation,
635 status: null,
636 detail: `${input.repository} is not a Mergebot repository`,
637 });
638 const { origin } = configuration;
639
640 const sessionToken = Effect.gen(function* () {
641 const credential = yield* credentials.get("github.com").pipe(
642 Effect.mapError(
643 () =>
644 new MergebotOperationError({
645 operation,
646 status: null,
647 detail: "GitHub is not signed in",
648 }),
649 ),
650 );
651 return session?.fingerprint === credential.fingerprint
652 ? session.token
653 : yield* exchange(origin, credential);
654 });
655 const send = (token: Redacted.Redacted) =>
656 http
657 .execute(
658 HttpClientRequest.make(method)(`${origin}/api/repos/${repository}${path}`).pipe(
659 HttpClientRequest.setHeader("Authorization", `Bearer ${Redacted.value(token)}`),
660 HttpClientRequest.setHeader("accept", "application/json"),
661 HttpClientRequest.bodyJsonUnsafe(body),
662 ),
663 )
664 .pipe(Effect.timeout("30 seconds"));
665
666 const response = yield* Effect.gen(function* () {
667 const first = yield* send(yield* sessionToken);
668 if (first.status !== 401) return first;
669 session = undefined;
670 return yield* send(yield* sessionToken);
671 }).pipe(
672 Effect.mapError((cause) =>
673 cause._tag === "MergebotOperationError"
674 ? cause
675 : cause._tag === "MergebotAuthenticationError"
676 ? new MergebotOperationError({
677 operation,
678 status: cause.status,
679 detail: "Mergebot did not accept the GitHub sign-in",
680 })
681 : new MergebotOperationError({
682 operation,
683 status: null,
684 detail: "Mergebot is unreachable",
685 }),
686 ),
687 );
688 const json = yield* response.json.pipe(Effect.orElseSucceed(() => undefined));
689 if (response.status >= 200 && response.status < 300)
690 return { json, url: `${origin}/${repository}` };
691
692 const detail = yield* decodeApiError(json).pipe(
693 Effect.map((error) => error.message),
694 Effect.orElseSucceed(() => `HTTP ${response.status}`),
695 );
696 return yield* new MergebotOperationError({ operation, status: response.status, detail });
697 }, Effect.scoped);
698
699 const setAutoMerge = Effect.fn("MergebotQueue.setAutoMerge")(function* (
700 input: MergebotSetAutoMergeInput,
701 ) {
702 yield* request("update auto-merge", input, "POST", `/pulls/${input.number}/auto-merge`, {
703 enabled: input.enabled,
704 });
535 });705 });
536706
537 return MergebotQueue.of({707 return MergebotQueue.of({
538 configuration,708 configuration,
539 read,709 read,
540 watchQueue: (input) =>710 watchQueue: (input) => watch(input.repository, read(input), queueEquivalence),
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,711 readPullRequest,
556 findPullRequest: Effect.fn("MergebotQueue.findPullRequest")(function* (input) {712 findPullRequest: Effect.fn("MergebotQueue.findPullRequest")(function* (input) {
557 const repository = repositoryFor(input.repository);713 const opened = yield* openOverview(input.repository);
558 if (!repository) return null;714 if (!opened) return null;
559 const fingerprint = yield* authorize();715 const { repository } = opened;
560 if (fingerprint === null) return null;716 const queue = yield* waitFor(opened.fingerprint, () => queueFor(repository));
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;717 if (!queue || queue.status !== "ready") return null;
565 const matches = Object.values(repos.get(repository)?.entities.pullRequests ?? {})718 const matches = Object.values(repos.get(repository)?.entities.pullRequests ?? {})
566 .filter((pr) => pr.headBranch === input.headBranch)719 .filter((pr) => pr.headBranch === input.headBranch)
...@@ -572,6 +725,72 @@ const make = Effect.gen(function* () {...@@ -572,6 +725,72 @@ const make = Effect.gen(function* () {
572 const match = matches?.[0];725 const match = matches?.[0];
573 return match ? yield* readPullRequest({ repository, number: match.number }) : null;726 return match ? yield* readPullRequest({ repository, number: match.number }) : null;
574 }),727 }),
728 watchPullRequestStatus: (input) =>
729 Stream.unwrap(
730 Effect.gen(function* () {
731 const repository = repositoryFor(input.repository);
732 if (repository) {
733 const key = channelKey({
734 kind: "pull_request_detail",
735 repoId: repository,
736 prNumber: input.number,
737 });
738 yield* Effect.acquireRelease(
739 Effect.sync(() => watchers.set(key, (watchers.get(key) ?? 0) + 1)),
740 () =>
741 Effect.map(Clock.currentTimeMillis, (now) => {
742 const remaining = (watchers.get(key) ?? 1) - 1;
743 if (remaining > 0) {
744 watchers.set(key, remaining);
745 } else {
746 // The last watcher hands the channel to the idle lease.
747 watchers.delete(key);
748 lastReadAt.set(key, now);
749 }
750 }),
751 );
752 }
753 return watch(input.repository, readPullRequestStatus(input), liveStatusEquivalence);
754 }),
755 ),
756 watchDeployments: (input) =>
757 watch(input.repository, readDeployments(input), deploymentsEquivalence),
758 openPullRequest: Effect.fn("MergebotQueue.openPullRequest")(function* (input) {
759 const operation = "open the pull request";
760 const pullRequest = Struct.omit(input, ["repository", "autoMerge"]);
761 const created = yield* request(operation, input, "POST", "/pulls", pullRequest);
762 const { number } = yield* decodeOpenedPullRequest(created.json).pipe(
763 Effect.mapError(
764 () =>
765 new MergebotOperationError({
766 operation,
767 status: null,
768 detail: "Mergebot returned an unexpected response",
769 }),
770 ),
771 );
772 // The PR exists now, so failing here would invite a retry that collides with it.
773 if (input.autoMerge)
774 yield* setAutoMerge({ repository: input.repository, number, enabled: true }).pipe(
775 Effect.catchTags({
776 MergebotOperationError: (error) =>
777 Effect.logWarning("Mergebot opened the pull request without auto-merge", {
778 status: error.status,
779 }),
780 }),
781 );
782 return { number, url: `${created.url}/pull/${number}` };
783 }),
784 setAutoMerge,
785 updatePullRequest: Effect.fn("MergebotQueue.updatePullRequest")(function* (input) {
786 const changes = Struct.omit(input, ["repository", "number"]);
787 yield* request("update the pull request", input, "PATCH", `/pulls/${input.number}`, changes);
788 }),
789 deployProduction: Effect.fn("MergebotQueue.deployProduction")(function* (input) {
790 yield* request("deploy to production", input, "POST", "/deploy/production", {
791 sha: input.sha,
792 });
793 }),
575 streamChanges: Stream.fromPubSub(changes),794 streamChanges: Stream.fromPubSub(changes),
576 });795 });
577});796});
apps/server/src/mergebot/overview.test.ts created+263
...@@ -0,0 +1,263 @@
1import { describe, expect, it } from "vite-plus/test";
2import type { MergebotDeploymentEnvironment, MergebotPullRequestDetails } from "@t3tools/contracts";
3import { deployments, deployState, pullRequestLiveStatus, type RepoPayload } from "./overview.ts";
4
5const repository = "work/app";
6const url = `https://mergebot.example/${repository}`;
7
8const commit = (number: number, isProductionHead = false) => ({
9 sha: `sha-${number}`,
10 pullRequestNumber: number,
11 authorId: `user-${number}`,
12 authorName: `Commit Author ${number}`,
13 message: `Change ${number}`,
14 mergedAt: `2026-10-14T10:0${number}:00Z`,
15 url: `https://github.com/${repository}/pull/${number}`,
16 labels: [],
17 status: "default" as const,
18 isProductionHead,
19});
20// Newest first, as Mergebot orders `mergedCommitIds`.
21const commits = [5, 4, 3, 2, 1, 0].map((number) => commit(number, number === 0));
22
23const deployment = (
24 sha: string,
25 deployedSha: string | null,
26 status: MergebotDeploymentEnvironment["status"],
27 etaAt: string | null = null,
28): MergebotDeploymentEnvironment => ({
29 sha,
30 deployedSha,
31 status,
32 currentStep: null,
33 etaAt,
34 checksUrl: "https://github.com/checks",
35});
36
37const repo = {
38 processing: false,
39 defaultBranch: "main",
40 productionBranch: "production",
41 stagingUrl: "https://staging.example",
42 productionUrl: "https://app.example",
43 triggerLabel: "merge",
44 stagingDeployment: deployment("sha-4", "sha-2", "pending", "2026-10-14T11:00:00Z"),
45 productionDeployment: deployment("sha-1", "sha-0", "failed"),
46};
47
48const pr = {
49 id: `${repository}#5`,
50 repoId: repository,
51 number: 5,
52 authorId: null,
53 createdAt: "2026-10-14T10:00:00Z",
54 updatedAt: "2026-10-14T10:02:00Z",
55 additions: 3,
56 deletions: 1,
57 changedFilesCount: 2,
58 labels: [{ name: "merge", color: null }],
59 title: "Ship change",
60 body: "Body",
61 draft: false,
62 headBranch: "feature",
63 baseBranch: "main",
64 headSha: "head",
65 lifecycle: "open" as const,
66 queueStatus: "queued" as const,
67 mergeEtaAt: "2026-10-14T10:30:00Z",
68 ciStatus: "passed" as const,
69 hasConflicts: false,
70 url: `https://github.com/${repository}/pull/5`,
71};
72
73const payload = (overrides: Partial<RepoPayload["entities"]> = {}): RepoPayload => ({
74 entities: {
75 repos: { [repository]: repo },
76 users: {
77 "user-4": { login: "octo", name: "Octo Cat", avatarUrl: "https://avatars.example/octo" },
78 },
79 pullRequests: { [pr.id]: pr },
80 mergedCommits: Object.fromEntries(commits.map((entry) => [entry.sha, entry])),
81 ...overrides,
82 },
83 view: {
84 repoId: repository,
85 queuePrIds: [`${repository}#9`, pr.id],
86 mergedCommitIds: commits.map((entry) => entry.sha),
87 },
88});
89
90const detail = (
91 overrides: Partial<MergebotPullRequestDetails> = {},
92): MergebotPullRequestDetails => ({
93 ...pr,
94 author: null,
95 comments: [],
96 observedAt: 0,
97 position: null,
98 checks: [
99 { status: "completed", conclusion: "success" },
100 { status: "completed", conclusion: "failure" },
101 { status: "in_progress", conclusion: null },
102 ].map((check, index) => ({
103 ...check,
104 id: `check:${index}`,
105 name: `Check ${index}`,
106 workflowName: null,
107 url: null,
108 startedAt: null,
109 completedAt: null,
110 })),
111 ...overrides,
112});
113
114describe("deployState", () => {
115 it.each([
116 [5, { state: "waiting", environment: "staging", etaAt: null }],
117 [4, { state: "deploying", environment: "staging", etaAt: "2026-10-14T11:00:00Z" }],
118 [3, { state: "deploying", environment: "staging", etaAt: "2026-10-14T11:00:00Z" }],
119 [2, { state: "staging", environment: "staging", etaAt: null }],
120 [1, { state: "failed", environment: "production", etaAt: null }],
121 [0, { state: "production", environment: "production", etaAt: null }],
122 ])("places #%s on the deploy timeline", (number, expected) => {
123 expect(deployState(repo, commits, { number })).toEqual(expected);
124 });
125
126 it("treats a passed deploy as covering everything at or below its head", () => {
127 const passed = { ...repo, stagingDeployment: deployment("sha-4", "sha-4", "passed") };
128 expect(deployState(passed, commits, { number: 3 })?.state).toBe("staging");
129 expect(deployState(passed, commits, { number: 5 })?.state).toBe("waiting");
130 });
131
132 it("reports a production deploy in flight with its own ETA", () => {
133 const shipping = {
134 ...repo,
135 productionDeployment: deployment("sha-2", "sha-0", "pending", "2026-10-14T12:00:00Z"),
136 };
137 expect(deployState(shipping, commits, { number: 1 })).toEqual({
138 state: "deploying",
139 environment: "production",
140 etaAt: "2026-10-14T12:00:00Z",
141 });
142 });
143
144 it("falls back to the production branch head without deployment checks", () => {
145 const branchOnly = { ...repo, stagingDeployment: null, productionDeployment: null };
146 expect(deployState(branchOnly, commits, { number: 0 })?.state).toBe("production");
147 expect(deployState(branchOnly, commits, { number: 1 })).toEqual({
148 state: "waiting",
149 environment: "production",
150 etaAt: null,
151 });
152 });
153
154 it("has no deploy state for a repository without deploys", () => {
155 const none = {
156 ...repo,
157 productionBranch: null,
158 stagingDeployment: null,
159 productionDeployment: null,
160 };
161 expect(deployState(none, commits, { number: 3 })).toBeNull();
162 });
163
164 it("places merges outside Mergebot's window by merge time", () => {
165 expect(
166 deployState(repo, commits, { number: 99, mergedAt: "2026-10-13T00:00:00Z" })?.state,
167 ).toBe("production");
168 expect(deployState(repo, commits, { number: 99, mergedAt: "2026-10-15T00:00:00Z" })).toBeNull();
169 expect(deployState(repo, commits, { number: 99 })).toBeNull();
170 });
171});
172
173describe("pullRequestLiveStatus", () => {
174 it("composes overview fields, queue position, and checks for the current head", () => {
175 expect(pullRequestLiveStatus(url, repository, payload(), detail(), 5)).toEqual({
176 number: 5,
177 url: `${url}/pull/5`,
178 title: "Ship change",
179 body: "Body",
180 headSha: "head",
181 headBranch: "feature",
182 lifecycle: "open",
183 draft: false,
184 autoMerge: true,
185 ciStatus: "passed",
186 checks: { completed: 2, total: 3, failed: 1 },
187 hasConflicts: false,
188 queueStatus: "queued",
189 position: 2,
190 mergeEtaAt: "2026-10-14T10:30:00Z",
191 deploy: null,
192 });
193 });
194
195 it("drops checks read at an older head and reports auto-merge from the trigger label", () => {
196 const status = pullRequestLiveStatus(
197 url,
198 repository,
199 payload({ pullRequests: { [pr.id]: { ...pr, labels: [] } } }),
200 detail({ headSha: "old-head" }),
201 5,
202 );
203 expect(status).toMatchObject({ checks: null, autoMerge: false });
204 });
205
206 it("falls back to the detail snapshot for PRs outside the overview", () => {
207 const closed = detail({ number: 77, lifecycle: "closed", queueStatus: "not_queued" });
208 expect(pullRequestLiveStatus(url, repository, payload(), closed, 77)).toMatchObject({
209 number: 77,
210 lifecycle: "closed",
211 position: null,
212 deploy: null,
213 });
214 expect(pullRequestLiveStatus(url, repository, payload(), null, 77)).toBeNull();
215 });
216
217 it("maps a merged PR through the merged commits", () => {
218 const merged = { ...pr, id: `${repository}#2`, number: 2, lifecycle: "merged" as const };
219 const status = pullRequestLiveStatus(
220 url,
221 repository,
222 payload({ pullRequests: { [merged.id]: merged } }),
223 undefined,
224 2,
225 );
226 expect(status?.deploy).toEqual({ state: "staging", environment: "staging", etaAt: null });
227 });
228});
229
230describe("deployments", () => {
231 it("keeps Mergebot's newest-first order and prefers overview author details", () => {
232 const result = deployments(url, repository, payload());
233 expect(result).toMatchObject({
234 repository,
235 url,
236 defaultBranch: "main",
237 productionBranch: "production",
238 staging: repo.stagingDeployment,
239 production: repo.productionDeployment,
240 });
241 expect(result?.mergedCommits.map((entry) => entry.sha)).toEqual(
242 commits.map((entry) => entry.sha),
243 );
244 expect(result?.mergedCommits[1]).toEqual({
245 sha: "sha-4",
246 pullRequestNumber: 4,
247 authorLogin: "octo",
248 authorName: "Octo Cat",
249 authorAvatarUrl: "https://avatars.example/octo",
250 message: "Change 4",
251 mergedAt: "2026-10-14T10:04:00Z",
252 url: `https://github.com/${repository}/pull/4`,
253 labels: [],
254 status: "default",
255 isProductionHead: false,
256 });
257 expect(result?.mergedCommits[0]).toMatchObject({
258 authorLogin: null,
259 authorName: "Commit Author 5",
260 authorAvatarUrl: null,
261 });
262 });
263});
apps/server/src/mergebot/overview.ts created+218
...@@ -0,0 +1,218 @@
1import {
2 MergebotDeploymentEnvironment,
3 MergebotDeployments,
4 MergebotMergedCommit,
5 MergebotPullRequestDetails,
6 PullRequestActor,
7 type MergebotPullRequestLiveStatus,
8} from "@t3tools/contracts";
9import * as Schema from "effect/Schema";
10import * as Struct from "effect/Struct";
11import { mergebotChecks } from "./pullRequestRead.ts";
12
13export const PullRequestRecord = Schema.Struct({
14 ...Struct.omit(MergebotPullRequestDetails.fields, [
15 "position",
16 "checks",
17 "author",
18 "comments",
19 "observedAt",
20 ]),
21 authorId: Schema.NullOr(Schema.String),
22 id: Schema.String,
23 repoId: Schema.String,
24});
25
26const RepoRecord = Schema.Struct({
27 ...Struct.pick(MergebotDeployments.fields, [
28 "defaultBranch",
29 "productionBranch",
30 "stagingUrl",
31 "productionUrl",
32 ]),
33 processing: Schema.Boolean,
34 triggerLabel: Schema.String,
35 stagingDeployment: Schema.NullOr(MergebotDeploymentEnvironment),
36 productionDeployment: Schema.NullOr(MergebotDeploymentEnvironment),
37});
38
39const MergedCommitRecord = Schema.Struct({
40 ...Struct.omit(MergebotMergedCommit.fields, ["authorLogin", "authorAvatarUrl"]),
41 authorId: Schema.NullOr(Schema.String),
42});
43
44export const RepoPayload = Schema.Struct({
45 entities: Schema.Struct({
46 repos: Schema.Record(Schema.String, RepoRecord),
47 users: Schema.Record(Schema.String, PullRequestActor),
48 pullRequests: Schema.Record(Schema.String, PullRequestRecord),
49 mergedCommits: Schema.Record(Schema.String, MergedCommitRecord),
50 }),
51 view: Schema.Struct({
52 repoId: Schema.String,
53 queuePrIds: Schema.Array(Schema.String),
54 mergedCommitIds: Schema.Array(Schema.String),
55 }),
56});
57export type RepoPayload = typeof RepoPayload.Type;
58type Repo = typeof RepoRecord.Type;
59type MergedCommit = typeof MergedCommitRecord.Type;
60type Deploy = NonNullable<MergebotPullRequestLiveStatus["deploy"]>;
61
62const mergedCommitsOf = (payload: RepoPayload) =>
63 payload.view.mergedCommitIds.flatMap((id) => payload.entities.mergedCommits[id] ?? []);
64
65/** Where a commit at `index` (0 is the newest merge) stands against one environment's deployment. */
66function environmentState(
67 deployment: MergebotDeploymentEnvironment,
68 commits: ReadonlyArray<MergedCommit>,
69 index: number,
70): "waiting" | "deploying" | "deployed" | "failed" {
71 const headIndex = Math.max(
72 0,
73 commits.findIndex((commit) => commit.sha === deployment.sha),
74 );
75 const deployedIndex =
76 deployment.deployedSha === null
77 ? -1
78 : commits.findIndex((commit) => commit.sha === deployment.deployedSha);
79
80 if (deployedIndex !== -1 && index >= deployedIndex) return "deployed";
81 if (index < headIndex) return "waiting";
82 if (deployment.status === "passed") return "deployed";
83 if (deployment.status === "pending") return "deploying";
84 return deployment.status === "failed" ? "failed" : "waiting";
85}
86
87/**
88 * Maps a merged PR onto the deploy timeline. A merge missing from Mergebot's window but older than
89 * all of it sits below every listed commit; a newer one has not reached the overview yet.
90 */
91export function deployState(
92 repo: Repo,
93 commits: ReadonlyArray<MergedCommit>,
94 pullRequest: { readonly number: number; readonly mergedAt?: string | null | undefined },
95): Deploy | null {
96 const listed = commits.findIndex((commit) => commit.pullRequestNumber === pullRequest.number);
97 const oldest = commits.at(-1);
98 const mergedAt = Date.parse(pullRequest.mergedAt ?? "");
99 const index =
100 listed !== -1
101 ? listed
102 : oldest && mergedAt < Date.parse(oldest.mergedAt)
103 ? commits.length
104 : null;
105 if (index === null) return null;
106
107 const productionHeadIndex = commits.findIndex((commit) => commit.isProductionHead);
108 const production = repo.productionDeployment
109 ? environmentState(repo.productionDeployment, commits, index)
110 : repo.productionBranch === null
111 ? null
112 : productionHeadIndex !== -1 && index >= productionHeadIndex
113 ? "deployed"
114 : "waiting";
115 const staging = repo.stagingDeployment
116 ? environmentState(repo.stagingDeployment, commits, index)
117 : null;
118
119 if (production === "deployed")
120 return { state: "production", environment: "production", etaAt: null };
121 if (production === "deploying")
122 return {
123 state: "deploying",
124 environment: "production",
125 etaAt: repo.productionDeployment?.etaAt ?? null,
126 };
127 if (production === "failed") return { state: "failed", environment: "production", etaAt: null };
128 if (staging === "deployed") return { state: "staging", environment: "staging", etaAt: null };
129 if (staging === "deploying")
130 return {
131 state: "deploying",
132 environment: "staging",
133 etaAt: repo.stagingDeployment?.etaAt ?? null,
134 };
135 if (staging === "failed") return { state: "failed", environment: "staging", etaAt: null };
136 if (staging === null && production === null) return null;
137 return {
138 state: "waiting",
139 environment: staging === null ? "production" : "staging",
140 etaAt: null,
141 };
142}
143
144/**
145 * Composes one PR's live status. The overview carries open and recently merged PRs; the detail
146 * snapshot covers the rest and supplies checks for the current head.
147 */
148export function pullRequestLiveStatus(
149 url: string,
150 repository: string,
151 payload: RepoPayload,
152 detail: MergebotPullRequestDetails | null | undefined,
153 number: number,
154): MergebotPullRequestLiveStatus | null {
155 const id = `${repository}#${number}`;
156 const pullRequest = payload.entities.pullRequests[id] ?? detail;
157 const repo = payload.entities.repos[repository];
158 if (!pullRequest || !repo) return null;
159
160 const position = payload.view.queuePrIds.indexOf(id) + 1;
161 const checks = detail && detail.headSha === pullRequest.headSha ? mergebotChecks(detail) : null;
162
163 return {
164 number,
165 url: `${url}/pull/${number}`,
166 title: pullRequest.title,
167 body: pullRequest.body,
168 headSha: pullRequest.headSha,
169 headBranch: pullRequest.headBranch,
170 lifecycle: pullRequest.lifecycle,
171 draft: pullRequest.draft,
172 autoMerge: pullRequest.labels.some((label) => label.name === repo.triggerLabel),
173 ciStatus: pullRequest.ciStatus,
174 checks: checks && {
175 total: checks.length,
176 completed: checks.filter((check) => check.status !== "pending").length,
177 failed: checks.filter((check) => check.status === "failure").length,
178 },
179 hasConflicts: pullRequest.hasConflicts,
180 queueStatus: pullRequest.queueStatus,
181 position: position === 0 ? null : position,
182 mergeEtaAt: pullRequest.mergeEtaAt,
183 deploy:
184 pullRequest.lifecycle === "merged"
185 ? deployState(repo, mergedCommitsOf(payload), pullRequest)
186 : null,
187 };
188}
189
190export function deployments(
191 url: string,
192 repository: string,
193 payload: RepoPayload,
194): MergebotDeployments | null {
195 const repo = payload.entities.repos[repository];
196 if (!repo) return null;
197 const users = payload.entities.users;
198
199 return {
200 repository,
201 url,
202 defaultBranch: repo.defaultBranch,
203 productionBranch: repo.productionBranch,
204 stagingUrl: repo.stagingUrl,
205 productionUrl: repo.productionUrl,
206 staging: repo.stagingDeployment,
207 production: repo.productionDeployment,
208 mergedCommits: mergedCommitsOf(payload).map(({ authorId, ...commit }) => {
209 const user = authorId === null ? undefined : users[authorId];
210 return {
211 ...commit,
212 authorLogin: user?.login ?? null,
213 authorName: user?.name ?? commit.authorName,
214 authorAvatarUrl: user?.avatarUrl ?? null,
215 };
216 }),
217 };
218}
apps/server/src/observability/RpcInstrumentation.ts+12
...@@ -101,6 +101,18 @@ const RPC_AGGREGATES = {...@@ -101,6 +101,18 @@ const RPC_AGGREGATES = {
101 [WS_METHODS.cloudInstallRelayClient]: "cloud",101 [WS_METHODS.cloudInstallRelayClient]: "cloud",
102 [WS_METHODS.mergebotQueue]: "mergebot",102 [WS_METHODS.mergebotQueue]: "mergebot",
103 [WS_METHODS.mergebotSubscribe]: "mergebot",103 [WS_METHODS.mergebotSubscribe]: "mergebot",
104 [WS_METHODS.mergebotOpenPullRequest]: "mergebot",
105 [WS_METHODS.mergebotSetAutoMerge]: "mergebot",
106 [WS_METHODS.mergebotUpdatePullRequest]: "mergebot",
107 [WS_METHODS.mergebotSubscribePullRequestStatus]: "mergebot",
108 [WS_METHODS.mergebotSubscribeDeployments]: "mergebot",
109 [WS_METHODS.mergebotDeployProduction]: "mergebot",
110 [WS_METHODS.jjSubscribeStack]: "vcs",
111 [WS_METHODS.jjSnapshot]: "vcs",
112 [WS_METHODS.jjDiff]: "vcs",
113 [WS_METHODS.jjDescribe]: "vcs",
114 [WS_METHODS.jjSquash]: "vcs",
115 [WS_METHODS.jjPush]: "vcs",
104 [WS_METHODS.pullRequestsList]: "pull-requests",116 [WS_METHODS.pullRequestsList]: "pull-requests",
105 [WS_METHODS.pullRequestsListStats]: "pull-requests",117 [WS_METHODS.pullRequestsListStats]: "pull-requests",
106 [WS_METHODS.pullRequestsSummary]: "pull-requests",118 [WS_METHODS.pullRequestsSummary]: "pull-requests",
apps/server/src/orchestration-v2/PullRequestWatchReactor.test.ts+6
...@@ -299,6 +299,12 @@ it.effect(...@@ -299,6 +299,12 @@ it.effect(
299 mergebot: {299 mergebot: {
300 configuration: { origin: "https://mergebot.example", repositories: [key.repository] },300 configuration: { origin: "https://mergebot.example", repositories: [key.repository] },
301 read: () => Effect.succeed(null),301 read: () => Effect.succeed(null),
302 watchPullRequestStatus: () => Stream.empty,
303 watchDeployments: () => Stream.empty,
304 openPullRequest: () => Effect.die("unused"),
305 setAutoMerge: () => Effect.die("unused"),
306 updatePullRequest: () => Effect.die("unused"),
307 deployProduction: () => Effect.die("unused"),
302 watchQueue: () => Stream.empty,308 watchQueue: () => Stream.empty,
303 findPullRequest: () => Effect.succeed(null),309 findPullRequest: () => Effect.succeed(null),
304 readPullRequest: () =>310 readPullRequest: () =>
apps/server/src/pullRequest/PullRequestService.test.ts+6
...@@ -7959,6 +7959,12 @@ it.effect(...@@ -7959,6 +7959,12 @@ it.effect(
7959 mergebot: {7959 mergebot: {
7960 configuration: { origin: "https://mergebot.example", repositories: ["work/app"] },7960 configuration: { origin: "https://mergebot.example", repositories: ["work/app"] },
7961 read: () => Effect.succeed(null),7961 read: () => Effect.succeed(null),
7962 watchPullRequestStatus: () => Stream.empty,
7963 watchDeployments: () => Stream.empty,
7964 openPullRequest: () => Effect.die("unused"),
7965 setAutoMerge: () => Effect.die("unused"),
7966 updatePullRequest: () => Effect.die("unused"),
7967 deployProduction: () => Effect.die("unused"),
7962 watchQueue: () => Stream.empty,7968 watchQueue: () => Stream.empty,
7963 readPullRequest: () => Effect.sync(() => value),7969 readPullRequest: () => Effect.sync(() => value),
7964 findPullRequest: () => Effect.succeed(null),7970 findPullRequest: () => Effect.succeed(null),
apps/server/src/server.ts+2
...@@ -126,6 +126,7 @@ import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";...@@ -126,6 +126,7 @@ import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
126import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";126import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
127import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";127import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
128import * as VcsProcess from "./vcs/VcsProcess.ts";128import * as VcsProcess from "./vcs/VcsProcess.ts";
129import * as JjStackService from "./vcs/JjStackService.ts";
129import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";130import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
130import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";131import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
131import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";132import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
...@@ -658,6 +659,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(...@@ -658,6 +659,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
658 Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)),659 Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)),
659 Layer.provideMerge(layerServerSettings),660 Layer.provideMerge(layerServerSettings),
660 Layer.provideMerge(MergebotQueue.layer),661 Layer.provideMerge(MergebotQueue.layer),
662 Layer.provideMerge(JjStackService.layer),
661 // The asset route uses the registry's GitHub credential for private PR media, which the663 // The asset route uses the registry's GitHub credential for private PR media, which the
662 // built-in drivers' layer provides alongside the registry.664 // built-in drivers' layer provides alongside the registry.
663 Layer.provideMerge(layerSourceControlProviderRegistry),665 Layer.provideMerge(layerSourceControlProviderRegistry),
apps/server/src/vcs/JjStackService.test.ts created+103
...@@ -0,0 +1,103 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { ChildProcessSpawner } from "effect/process";
6
7import { gitDiffFileStats, githubRepository } from "./JjStackService.ts";
8import * as JjStackService from "./JjStackService.ts";
9import { VcsProcess } from "./VcsProcess.ts";
10
11describe("gitDiffFileStats", () => {
12 it("counts lines per file and keeps rename sources", () => {
13 const diff = [
14 "diff --git a/src/old.ts b/src/new.ts",
15 "rename from src/old.ts",
16 "rename to src/new.ts",
17 "--- a/src/old.ts",
18 "+++ b/src/new.ts",
19 "@@ -1,2 +1,2 @@",
20 "-const a = 1;",
21 "+const a = 2;",
22 " export { a };",
23 "diff --git a/README.md b/README.md",
24 "new file mode 100644",
25 "--- /dev/null",
26 "+++ b/README.md",
27 "@@ -0,0 +1,2 @@",
28 "+# Title",
29 "+--- a line that looks like a header",
30 ].join("\n");
31 expect(gitDiffFileStats(diff)).toEqual([
32 { path: "src/new.ts", previousPath: "src/old.ts", additions: 1, deletions: 1 },
33 { path: "README.md", previousPath: null, additions: 2, deletions: 0 },
34 ]);
35 });
36});
37
38describe("githubRepository", () => {
39 const remotes = [
40 "origin git@github.com:acme/app.git",
41 "upstream https://github.com/acme/upstream",
42 "mirror https://git.example.com/acme/app.git",
43 "lookalike https://notgithub.com/acme/app.git",
44 ].join("\n");
45
46 it("reads owner/name from the push remote's GitHub URL", () => {
47 expect(githubRepository(remotes, "origin")).toBe("acme/app");
48 expect(githubRepository(remotes, "upstream")).toBe("acme/upstream");
49 });
50
51 it("returns null for other hosts and unknown remotes", () => {
52 expect(githubRepository(remotes, "mirror")).toBeNull();
53 expect(githubRepository(remotes, "lookalike")).toBeNull();
54 expect(githubRepository(remotes, "fork")).toBeNull();
55 });
56});
57
58const pushLayer = (bookmarks: string) =>
59 JjStackService.layer.pipe(
60 Layer.provide(
61 Layer.succeed(
62 VcsProcess,
63 VcsProcess.of({
64 run: (input) =>
65 Effect.succeed({
66 exitCode: ChildProcessSpawner.ExitCode(0),
67 stdout:
68 input.operation === "JjStackService.push-template"
69 ? '"review-" ++ change_id.short()'
70 : input.operation === "JjStackService.bookmark-name"
71 ? "review-selected"
72 : input.operation === "JjStackService.pushed-bookmark"
73 ? bookmarks
74 : "",
75 stderr: "",
76 stdoutTruncated: false,
77 stderrTruncated: false,
78 }),
79 }),
80 ),
81 ),
82 Layer.provide(NodeServices.layer),
83 );
84
85describe("JjStackService.push", () => {
86 it.effect("returns the bookmark it pushed when other bookmarks point at the same commit", () =>
87 Effect.gen(function* () {
88 const service = yield* JjStackService.JjStackService;
89 const result = yield* service.push({ cwd: "/workspace", changeId: "selected" });
90 expect(result).toEqual({ bookmark: "review-selected", remote: "origin" });
91 }).pipe(Effect.provide(pushLayer("another-pr\torigin\nreview-selected\torigin"))),
92 );
93
94 it.effect("refuses to report success when only another bookmark is pushed", () =>
95 Effect.gen(function* () {
96 const service = yield* JjStackService.JjStackService;
97 const result = yield* service
98 .push({ cwd: "/workspace", changeId: "selected" })
99 .pipe(Effect.flip);
100 expect(result).toMatchObject({ _tag: "JjError", operation: "push" });
101 }).pipe(Effect.provide(pushLayer("another-pr\torigin"))),
102 );
103});
apps/server/src/vcs/JjStackService.ts created+404
...@@ -0,0 +1,404 @@
1import {
2 JjDescriptionConflictError,
3 JjError,
4 type JjChange,
5 type JjChangeInput,
6 type JjDescribeInput,
7 type JjDiffInput,
8 type JjDiffResult,
9 type JjPushInput,
10 type JjPushResult,
11 type JjStack,
12 type JjStackInput,
13 type ReviewDiffFileStat,
14} from "@t3tools/contracts";
15import { parseGitHubRepositoryNameWithOwnerFromRemoteUrl } from "@t3tools/shared/git";
16import * as Context from "effect/Context";
17import * as Duration from "effect/Duration";
18import * as Effect from "effect/Effect";
19import * as FileSystem from "effect/FileSystem";
20import * as Layer from "effect/Layer";
21import * as Path from "effect/Path";
22import * as Schema from "effect/Schema";
23import * as Stream from "effect/Stream";
24
25import { VcsProcess } from "./VcsProcess.ts";
26
27/** Ancestors down to trunk plus anything above `@`; sibling lines that share a base stay out. */
28const STACK_REVSET = "(::@ | @::) ~ ::trunk() ~ (empty() ~ @)";
29const STACK_LIMIT = 60;
30const MAX_DIFF_BYTES = 4_000_000;
31
32const STACK_TEMPLATE = [
33 '"{\\"changeId\\":" ++ json(change_id)',
34 '",\\"shortId\\":" ++ json(stringify(change_id.shortest(4)))',
35 '",\\"commitId\\":" ++ json(commit_id)',
36 '",\\"parentCommitIds\\":[" ++ parents.map(|p| json(p.commit_id())).join(",") ++ "]"',
37 '",\\"description\\":" ++ json(description)',
38 '",\\"trailers\\":[" ++ trailers.map(|t| "{\\"key\\":" ++ json(t.key()) ++ ",\\"value\\":" ++ json(t.value()) ++ "}").join(",") ++ "]"',
39 '",\\"localBookmarks\\":[" ++ local_bookmarks.map(|b| json(b.name())).join(",") ++ "]"',
40 '",\\"remoteBookmarks\\":[" ++ remote_bookmarks.filter(|b| b.remote() != "git").map(|b| json(b.name())).join(",") ++ "]"',
41 '",\\"immutable\\":" ++ json(immutable)',
42 '",\\"conflict\\":" ++ json(conflict)',
43 '",\\"empty\\":" ++ json(empty)',
44 '",\\"workingCopy\\":" ++ json(current_working_copy)',
45 '",\\"additions\\":" ++ json(diff.stat().total_added())',
46 '",\\"deletions\\":" ++ json(diff.stat().total_removed())',
47 '",\\"authoredAt\\":" ++ json(stringify(author.timestamp().format("%Y-%m-%dT%H:%M:%S%:z"))) ++ "}\\n"',
48].join(" ++ ");
49
50const RawChange = Schema.Struct({
51 changeId: Schema.String,
52 shortId: Schema.String,
53 commitId: Schema.String,
54 parentCommitIds: Schema.Array(Schema.String),
55 description: Schema.String,
56 trailers: Schema.Array(Schema.Struct({ key: Schema.String, value: Schema.String })),
57 localBookmarks: Schema.Array(Schema.String),
58 remoteBookmarks: Schema.Array(Schema.String),
59 immutable: Schema.Boolean,
60 conflict: Schema.Boolean,
61 empty: Schema.Boolean,
62 workingCopy: Schema.Boolean,
63 additions: Schema.Number,
64 deletions: Schema.Number,
65 authoredAt: Schema.String,
66});
67const decodeRawChange = Schema.decodeUnknownSync(Schema.fromJsonString(RawChange));
68
69function toChange(line: string): JjChange {
70 const raw = decodeRawChange(line);
71 return {
72 changeId: raw.changeId,
73 shortId: raw.shortId,
74 commitId: raw.commitId,
75 parentCommitIds: raw.parentCommitIds,
76 description: raw.description.replace(/\n+$/, ""),
77 trailers: raw.trailers,
78 bookmarks: raw.localBookmarks.map((name) => ({
79 name,
80 pushed: raw.remoteBookmarks.includes(name),
81 })),
82 immutable: raw.immutable,
83 conflict: raw.conflict,
84 empty: raw.empty,
85 workingCopy: raw.workingCopy,
86 additions: raw.additions,
87 deletions: raw.deletions,
88 authoredAt: raw.authoredAt,
89 };
90}
91
92/** Per-file statistics from `jj diff --git`, so the client can list files before rendering patches. */
93export function gitDiffFileStats(diff: string): ReviewDiffFileStat[] {
94 const files: ReviewDiffFileStat[] = [];
95 let current: {
96 path: string;
97 previousPath: string | null;
98 additions: number;
99 deletions: number;
100 } | null = null;
101 for (const line of diff.split("\n")) {
102 const header = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
103 if (header) {
104 if (current) files.push(current);
105 current = { path: header[2]!, previousPath: null, additions: 0, deletions: 0 };
106 continue;
107 }
108 if (!current) continue;
109 if (line.startsWith("rename from ")) current.previousPath = line.slice("rename from ".length);
110 else if (line.startsWith("+") && !line.startsWith("+++ ")) current.additions++;
111 else if (line.startsWith("-") && !line.startsWith("--- ")) current.deletions++;
112 }
113 if (current) files.push(current);
114 return files;
115}
116
117/** `owner/name` of the named remote in `jj git remote list` output, when it is on GitHub. */
118export function githubRepository(remoteList: string, remote: string): string | null {
119 for (const line of remoteList.split("\n")) {
120 const [name, url] = line.trim().split(/\s+/);
121 if (name !== remote || !url) continue;
122 return parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url);
123 }
124 return null;
125}
126
127export class JjStackService extends Context.Service<
128 JjStackService,
129 {
130 readonly watchStack: (input: JjStackInput) => Stream.Stream<JjStack | null, JjError>;
131 readonly snapshot: (input: JjStackInput) => Effect.Effect<void, JjError>;
132 readonly diff: (input: JjDiffInput) => Effect.Effect<JjDiffResult, JjError>;
133 readonly describe: (
134 input: JjDescribeInput,
135 ) => Effect.Effect<{ readonly commitId: string }, JjError | JjDescriptionConflictError>;
136 readonly squash: (input: JjChangeInput) => Effect.Effect<void, JjError>;
137 readonly push: (input: JjPushInput) => Effect.Effect<JjPushResult, JjError>;
138 }
139>()("t3/vcs/JjStackService") {}
140
141const make = Effect.gen(function* () {
142 const vcs = yield* VcsProcess;
143 const fs = yield* FileSystem.FileSystem;
144 const path = yield* Path.Path;
145
146 const jj = (
147 operation: string,
148 cwd: string,
149 args: ReadonlyArray<string>,
150 options: {
151 readonly stdin?: string;
152 readonly timeoutMs?: number;
153 readonly maxOutputBytes?: number;
154 } = {},
155 ) =>
156 vcs
157 .run({
158 operation: `JjStackService.${operation}`,
159 command: "jj",
160 args: ["--no-pager", "--color=never", ...args],
161 cwd,
162 ...options,
163 })
164 .pipe(Effect.mapError((error) => new JjError({ operation, detail: error.message })));
165
166 const workspaceRoot = (cwd: string) =>
167 jj("root", cwd, ["--ignore-working-copy", "root"]).pipe(
168 Effect.map((output) => output.stdout.trim() || null),
169 Effect.orElseSucceed(() => null),
170 );
171
172 const readStack = Effect.fn("JjStackService.readStack")(function* (root: string) {
173 const [changes, trunk, behind, remotes, pushRemote] = yield* Effect.all(
174 [
175 jj("log", root, [
176 "--ignore-working-copy",
177 "log",
178 "--no-graph",
179 "--limit",
180 String(STACK_LIMIT),
181 "-r",
182 STACK_REVSET,
183 "-T",
184 STACK_TEMPLATE,
185 ]),
186 jj("trunk", root, [
187 "--ignore-working-copy",
188 "log",
189 "--no-graph",
190 "-r",
191 "trunk()",
192 "-T",
193 'commit_id ++ "\\t" ++ remote_bookmarks.filter(|b| b.remote() != "git").map(|b| stringify(b.name() ++ "@" ++ b.remote())).join(",") ++ "\\n"',
194 ]),
195 jj("behind", root, [
196 "--ignore-working-copy",
197 "log",
198 "--no-graph",
199 "-r",
200 "::trunk() ~ ::@",
201 "-T",
202 '"."',
203 ]),
204 jj("remotes", root, ["--ignore-working-copy", "git", "remote", "list"]),
205 jj("push-remote", root, ["--ignore-working-copy", "config", "get", "git.push"]).pipe(
206 Effect.map((output) => output.stdout.trim()),
207 Effect.orElseSucceed(() => ""),
208 ),
209 ],
210 { concurrency: "unbounded" },
211 );
212 const [trunkCommitId, trunkNames] = trunk.stdout.trim().split("\t");
213 return {
214 workspaceRoot: root,
215 repository: githubRepository(remotes.stdout, pushRemote || "origin"),
216 trunk: trunkCommitId
217 ? {
218 name: trunkNames?.split(",")[0] || "trunk()",
219 commitId: trunkCommitId,
220 behind: behind.stdout.trim().length,
221 }
222 : null,
223 changes: changes.stdout
224 .split("\n")
225 .filter((line) => line.trim())
226 .map(toChange),
227 } satisfies JjStack;
228 });
229
230 // Every jj operation, from any workspace of the repo, adds a file under op_heads/heads.
231 const opHeadsDirectory = Effect.fn("JjStackService.opHeadsDirectory")(function* (root: string) {
232 const repoPath = path.join(root, ".jj", "repo");
233 const info = yield* fs.stat(repoPath);
234 const repo =
235 info.type === "File"
236 ? path.resolve(path.join(root, ".jj"), (yield* fs.readFileString(repoPath)).trim())
237 : repoPath;
238 return path.join(repo, "op_heads", "heads");
239 });
240
241 const watchStack = (input: JjStackInput) =>
242 Stream.unwrap(
243 Effect.gen(function* () {
244 const root = yield* workspaceRoot(input.cwd);
245 if (!root) return Stream.make(null);
246 const heads = yield* opHeadsDirectory(root).pipe(
247 Effect.mapError((error) => new JjError({ operation: "watch", detail: String(error) })),
248 );
249 const changes = fs.watch(heads).pipe(
250 Stream.debounce(Duration.millis(150)),
251 Stream.catch(() => Stream.empty),
252 );
253 let previous = "";
254 return Stream.concat(Stream.make(undefined), changes).pipe(
255 Stream.mapEffect(() => readStack(root)),
256 Stream.filter((stack) => {
257 const key = JSON.stringify(stack);
258 if (key === previous) return false;
259 previous = key;
260 return true;
261 }),
262 );
263 }),
264 );
265
266 const snapshot = (input: JjStackInput) =>
267 jj("snapshot", input.cwd, ["util", "snapshot"]).pipe(Effect.asVoid);
268
269 const diff = Effect.fn("JjStackService.diff")(function* (input: JjDiffInput) {
270 const output = yield* jj(
271 "diff",
272 input.cwd,
273 [
274 "--ignore-working-copy",
275 "diff",
276 "--git",
277 ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []),
278 "--from",
279 input.fromCommitId,
280 "--to",
281 input.toCommitId,
282 ],
283 { maxOutputBytes: MAX_DIFF_BYTES },
284 );
285 const range = `${input.fromCommitId}..${input.toCommitId}`;
286 return {
287 id: range,
288 kind: "branch-range",
289 title: "Changes",
290 baseRef: input.fromCommitId,
291 headRef: input.toCommitId,
292 diff: output.stdout,
293 // Commit ids are content addresses, so the range alone identifies the diff.
294 diffHash: `${range}${input.ignoreWhitespace ? ":w" : ""}`,
295 truncated: output.stdoutTruncated,
296 files: gitDiffFileStats(output.stdout),
297 } satisfies JjDiffResult;
298 });
299
300 const readCommit = (cwd: string, revision: string) =>
301 jj("show-commit", cwd, [
302 "--ignore-working-copy",
303 "log",
304 "--no-graph",
305 "-r",
306 revision,
307 "-T",
308 'commit_id ++ "\\n" ++ description',
309 ]).pipe(
310 Effect.map((output) => {
311 const newline = output.stdout.indexOf("\n");
312 return {
313 commitId: output.stdout.slice(0, newline),
314 description: output.stdout.slice(newline + 1).replace(/\n+$/, ""),
315 };
316 }),
317 );
318
319 const describe = Effect.fn("JjStackService.describe")(function* (input: JjDescribeInput) {
320 const [current, loaded] = yield* Effect.all([
321 readCommit(input.cwd, input.changeId),
322 readCommit(input.cwd, input.expectedCommitId),
323 ]);
324 if (current.commitId !== input.expectedCommitId && current.description !== loaded.description) {
325 return yield* new JjDescriptionConflictError({
326 changeId: input.changeId,
327 description: current.description,
328 commitId: current.commitId,
329 });
330 }
331 if (current.description !== input.message) {
332 yield* jj(
333 "describe",
334 input.cwd,
335 ["--ignore-working-copy", "describe", "-r", input.changeId, "--stdin"],
336 { stdin: input.message },
337 );
338 }
339 return { commitId: (yield* readCommit(input.cwd, input.changeId)).commitId };
340 });
341
342 const squash = (input: JjChangeInput) =>
343 jj("squash", input.cwd, ["squash", "-r", input.changeId, "--use-destination-message"]).pipe(
344 Effect.asVoid,
345 );
346
347 const push = Effect.fn("JjStackService.push")(function* (input: JjPushInput) {
348 const network = { timeoutMs: 120_000 };
349 let bookmark = input.bookmark;
350 if (bookmark) {
351 yield* jj("bookmark-move", input.cwd, ["bookmark", "move", bookmark, "--to", input.changeId]);
352 yield* jj("push", input.cwd, ["git", "push", "--bookmark", bookmark], network);
353 } else {
354 const template = yield* jj("push-template", input.cwd, [
355 "--ignore-working-copy",
356 "config",
357 "get",
358 "templates.git_push_bookmark",
359 ]).pipe(
360 Effect.map((output) => output.stdout.trim()),
361 Effect.orElseSucceed(() => '"push-" ++ change_id.short()'),
362 );
363 bookmark = (yield* jj("bookmark-name", input.cwd, [
364 "--ignore-working-copy",
365 "log",
366 "--no-graph",
367 "-r",
368 input.nameFrom ?? input.changeId,
369 "-T",
370 template,
371 ])).stdout.trim();
372 yield* jj(
373 "push",
374 input.cwd,
375 ["git", "push", "--named", `${bookmark}=${input.changeId}`],
376 network,
377 );
378 }
379 const pushed = yield* jj("pushed-bookmark", input.cwd, [
380 "--ignore-working-copy",
381 "log",
382 "--no-graph",
383 "-r",
384 input.changeId,
385 "-T",
386 'remote_bookmarks.filter(|b| b.remote() != "git").map(|b| stringify(b.name() ++ "\\t" ++ b.remote())).join("\\n")',
387 ]);
388 const remote = pushed.stdout
389 .split("\n")
390 .find((line) => line.startsWith(`${bookmark}\t`))
391 ?.split("\t")[1];
392 if (!bookmark || !remote) {
393 return yield* new JjError({
394 operation: "push",
395 detail: "No pushed bookmark points at this change.",
396 });
397 }
398 return { bookmark, remote };
399 });
400
401 return { watchStack, snapshot, diff, describe, squash, push };
402});
403
404export const layer = Layer.effect(JjStackService, make);
apps/server/src/ws.ts+15
...@@ -188,6 +188,7 @@ import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/Atta...@@ -188,6 +188,7 @@ import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/Atta
188import * as PortScanner from "./preview/PortScanner.ts";188import * as PortScanner from "./preview/PortScanner.ts";
189import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";189import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
190import * as MergebotQueue from "./mergebot/MergebotQueue.ts";190import * as MergebotQueue from "./mergebot/MergebotQueue.ts";
191import * as JjStackService from "./vcs/JjStackService.ts";
191import * as DevServer from "./workspace/DevServer.ts";192import * as DevServer from "./workspace/DevServer.ts";
192import * as ManagedWorkspaces from "./workspace/ManagedWorkspaces.ts";193import * as ManagedWorkspaces from "./workspace/ManagedWorkspaces.ts";
193import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";194import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
...@@ -1233,6 +1234,7 @@ const layerWsRpc = (...@@ -1233,6 +1234,7 @@ const layerWsRpc = (
1233 const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup;1234 const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup;
1234 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;1235 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
1235 const devServer = yield* DevServer.DevServer;1236 const devServer = yield* DevServer.DevServer;
1237 const jjStack = yield* JjStackService.JjStackService;
1236 const managedWorkspaces = yield* ManagedWorkspaces.ManagedWorkspaces;1238 const managedWorkspaces = yield* ManagedWorkspaces.ManagedWorkspaces;
1237 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;1239 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
1238 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;1240 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
...@@ -2419,6 +2421,13 @@ const layerWsRpc = (...@@ -2419,6 +2421,13 @@ const layerWsRpc = (
2419 ),2421 ),
2420 [WS_METHODS.mergebotQueue]: (input) => mergebotQueue.read(input),2422 [WS_METHODS.mergebotQueue]: (input) => mergebotQueue.read(input),
2421 [WS_METHODS.mergebotSubscribe]: (input) => mergebotQueue.watchQueue(input),2423 [WS_METHODS.mergebotSubscribe]: (input) => mergebotQueue.watchQueue(input),
2424 [WS_METHODS.mergebotSubscribePullRequestStatus]: (input) =>
2425 mergebotQueue.watchPullRequestStatus(input),
2426 [WS_METHODS.mergebotSubscribeDeployments]: (input) => mergebotQueue.watchDeployments(input),
2427 [WS_METHODS.mergebotOpenPullRequest]: (input) => mergebotQueue.openPullRequest(input),
2428 [WS_METHODS.mergebotSetAutoMerge]: (input) => mergebotQueue.setAutoMerge(input),
2429 [WS_METHODS.mergebotUpdatePullRequest]: (input) => mergebotQueue.updatePullRequest(input),
2430 [WS_METHODS.mergebotDeployProduction]: (input) => mergebotQueue.deployProduction(input),
2422 [WS_METHODS.pullRequestsList]: (input) => pullRequests.list(input),2431 [WS_METHODS.pullRequestsList]: (input) => pullRequests.list(input),
2423 [WS_METHODS.pullRequestsListStats]: (input) => pullRequests.listStats(input),2432 [WS_METHODS.pullRequestsListStats]: (input) => pullRequests.listStats(input),
2424 [WS_METHODS.pullRequestsRoutingIdentity]: (input) => pullRequests.routingIdentity(input),2433 [WS_METHODS.pullRequestsRoutingIdentity]: (input) => pullRequests.routingIdentity(input),
...@@ -2611,6 +2620,12 @@ const layerWsRpc = (...@@ -2611,6 +2620,12 @@ const layerWsRpc = (
2611 [WS_METHODS.managedWorkspacesArchiveCompleted]: (input) =>2620 [WS_METHODS.managedWorkspacesArchiveCompleted]: (input) =>
2612 managedWorkspaces.archiveCompleted(input),2621 managedWorkspaces.archiveCompleted(input),
2613 [WS_METHODS.devServerStatus]: (input) => devServer.status(input),2622 [WS_METHODS.devServerStatus]: (input) => devServer.status(input),
2623 [WS_METHODS.jjSubscribeStack]: (input) => jjStack.watchStack(input),
2624 [WS_METHODS.jjSnapshot]: (input) => jjStack.snapshot(input),
2625 [WS_METHODS.jjDiff]: (input) => jjStack.diff(input),
2626 [WS_METHODS.jjDescribe]: (input) => jjStack.describe(input),
2627 [WS_METHODS.jjSquash]: (input) => jjStack.squash(input),
2628 [WS_METHODS.jjPush]: (input) => jjStack.push(input),
2614 [WS_METHODS.devServerStart]: (input) => devServer.start(input),2629 [WS_METHODS.devServerStart]: (input) => devServer.start(input),
2615 [WS_METHODS.projectsReadFile]: (input) =>2630 [WS_METHODS.projectsReadFile]: (input) =>
2616 workspaceFileSystem.readFile(input).pipe(2631 workspaceFileSystem.readFile(input).pipe(
apps/web/src/closedViewStore.ts+1
...@@ -63,6 +63,7 @@ const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => {...@@ -63,6 +63,7 @@ const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => {
63 case "diff":63 case "diff":
64 case "files":64 case "files":
65 case "pull-requests":65 case "pull-requests":
66 case "deployments":
66 return surface.id === surface.kind;67 return surface.id === surface.kind;
67 case "preview":68 case "preview":
68 return surface.resourceId === null || typeof surface.resourceId === "string";69 return surface.resourceId === null || typeof surface.resourceId === "string";
apps/web/src/components/AppSidebarLayout.tsx-8
...@@ -35,7 +35,6 @@ import LegacyThreadSidebar from "./LegacySidebar";...@@ -35,7 +35,6 @@ import LegacyThreadSidebar from "./LegacySidebar";
35import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration";35import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration";
36import ThreadSidebar from "./Sidebar";36import ThreadSidebar from "./Sidebar";
37import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";37import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
38import { NewWorkWorkspaceButton } from "./sidebar/NewWorkWorkspaceButton";
39import { AppMenu } from "./sidebar/AppMenu";38import { AppMenu } from "./sidebar/AppMenu";
40import { SidebarChromeHeader } from "./sidebar/SidebarChrome";39import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
41import { MainAppLocationTracker } from "./sidebar/mainAppLocation";40import { MainAppLocationTracker } from "./sidebar/mainAppLocation";
...@@ -54,7 +53,6 @@ import {...@@ -54,7 +53,6 @@ import {
54 SidebarRail,53 SidebarRail,
55 SidebarTrigger,54 SidebarTrigger,
56 useSidebar,55 useSidebar,
57 useSidebarVisibility,
58} from "./ui/sidebar";56} from "./ui/sidebar";
59import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";57import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
6058
...@@ -85,7 +83,6 @@ function SidebarControl() {...@@ -85,7 +83,6 @@ function SidebarControl() {
85 const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" });83 const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" });
86 const keybindings = useAtomValue(primaryServerKeybindingsAtom);84 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
87 const { toggleSidebar, isMobile, mobileSheetMounted } = useSidebar();85 const { toggleSidebar, isMobile, mobileSheetMounted } = useSidebar();
88 const isSidebarVisible = useSidebarVisibility();
89 const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", {86 const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", {
90 context: { usagePageOpen },87 context: { usagePageOpen },
91 });88 });
...@@ -156,11 +153,6 @@ function SidebarControl() {...@@ -156,11 +153,6 @@ function SidebarControl() {
156 Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""}153 Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""}
157 </TooltipPopup>154 </TooltipPopup>
158 </Tooltip>155 </Tooltip>
159 {isSidebarVisible ? (
160 <div className="pointer-events-auto ml-2 [-webkit-app-region:no-drag]">
161 <NewWorkWorkspaceButton />
162 </div>
163 ) : null}
164 </div>156 </div>
165 );157 );
166}158}
apps/web/src/components/ChatView.tsx+16-6
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { supportsWorkspaceChanges } from "./review/jjReview.logic";
1import { ThreadFind, ThreadFindCanvas, type ThreadFindControls } from "./chat/ThreadFindProvider";2import { ThreadFind, ThreadFindCanvas, type ThreadFindControls } from "./chat/ThreadFindProvider";
2import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar";3import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar";
3import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner";4import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner";
...@@ -718,7 +719,10 @@ function useDraftHeroLayoutTransition(...@@ -718,7 +719,10 @@ function useDraftHeroLayoutTransition(
718const PreviewPanel = lazy(() =>719const PreviewPanel = lazy(() =>
719 import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })),720 import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })),
720);721);
721const DiffPanel = lazy(() => import("./DiffPanel"));722const DiffPanel = lazy(() => import("./review/DiffSurface"));
723const DeploymentsPanel = lazy(() =>
724 import("./deployments/DeploymentsPanel").then((module) => ({ default: module.DeploymentsPanel })),
725);
722const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) =>726const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) =>
723 settings.browserAutoShowFloatingPreview;727 settings.browserAutoShowFloatingPreview;
724const DevicePanel = lazy(() =>728const DevicePanel = lazy(() =>
...@@ -4287,6 +4291,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -4287,6 +4291,8 @@ export default function ChatView(props: ChatViewProps) {
4287 }4291 }
4288 }, [environmentId, gitStatusCwd, liveIsGitRepo]);4292 }, [environmentId, gitStatusCwd, liveIsGitRepo]);
4289 const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true;4293 const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true;
4294 const workspaceChangesAvailable =
4295 isServerThread && supportsWorkspaceChanges({ ...gitStatusQuery.data, isRepo: isGitRepo });
4290 // When context is enabled, keep a hidden, off-flow strip mounted so the composer4296 // When context is enabled, keep a hidden, off-flow strip mounted so the composer
4291 // can measure whether its relocated controls fit. The visible chrome remains4297 // can measure whether its relocated controls fit. The visible chrome remains
4292 // content-driven: Git/environment context or controls that actually fit.4298 // content-driven: Git/environment context or controls that actually fit.
...@@ -5554,11 +5560,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -5554,11 +5560,11 @@ export default function ChatView(props: ChatViewProps) {
5554 [activeThreadRef, canOperatePreview, openPreview],5560 [activeThreadRef, canOperatePreview, openPreview],
5555 );5561 );
5556 const addDiffSurface = useCallback(() => {5562 const addDiffSurface = useCallback(() => {
5557 if (!activeThreadRef || !isServerThread || !isGitRepo) return;5563 if (!activeThreadRef || !workspaceChangesAvailable) return;
5558 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch");5564 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch");
5559 useRightPanelStore.getState().open(activeThreadRef, "diff");5565 useRightPanelStore.getState().open(activeThreadRef, "diff");
5560 onDiffPanelOpen?.();5566 onDiffPanelOpen?.();
5561 }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]);5567 }, [activeThreadRef, workspaceChangesAvailable, onDiffPanelOpen]);
5562 const openChangesFromThreadPanel = useCallback(() => {5568 const openChangesFromThreadPanel = useCallback(() => {
5563 addDiffSurface();5569 addDiffSurface();
5564 }, [addDiffSurface]);5570 }, [addDiffSurface]);
...@@ -11278,6 +11284,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -11278,6 +11284,10 @@ export default function ChatView(props: ChatViewProps) {
11278 />11284 />
11279 ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? (11285 ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? (
11280 <ThreadPullRequestsPanel threadRef={activeThreadRef} />11286 <ThreadPullRequestsPanel threadRef={activeThreadRef} />
11287 ) : renderedRightPanelSurface?.kind === "deployments" && activeThreadRef ? (
11288 <Suspense fallback={null}>
11289 <DeploymentsPanel environmentId={activeThreadRef.environmentId} />
11290 </Suspense>
11281 ) : renderedRightPanelSurface?.kind === "device" ? (11291 ) : renderedRightPanelSurface?.kind === "device" ? (
11282 <Suspense fallback={null}>11292 <Suspense fallback={null}>
11283 <DevicePanel11293 <DevicePanel
...@@ -11382,7 +11392,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11382,7 +11392,7 @@ export default function ChatView(props: ChatViewProps) {
11382 ? { onCheckoutPullRequestRequest: openPullRequestDialog }11392 ? { onCheckoutPullRequestRequest: openPullRequestDialog }
11383 : {}),11393 : {}),
11384 onComposerFocusRequest: scheduleComposerFocus,11394 onComposerFocusRequest: scheduleComposerFocus,
11385 ...(isServerThread && isGitRepo ? { onOpenChanges: openChangesFromThreadPanel } : {}),11395 ...(workspaceChangesAvailable ? { onOpenChanges: openChangesFromThreadPanel } : {}),
11386 onRunProjectScript: runProjectScript,11396 onRunProjectScript: runProjectScript,
11387 onAddProjectScript: saveProjectScript,11397 onAddProjectScript: saveProjectScript,
11388 onUpdateProjectScript: updateProjectScript,11398 onUpdateProjectScript: updateProjectScript,
...@@ -12192,7 +12202,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -12192,7 +12202,7 @@ export default function ChatView(props: ChatViewProps) {
12192 onAddDevice={addDeviceSurface}12202 onAddDevice={addDeviceSurface}
12193 browserAvailable={canOperatePreview && browserAvailable}12203 browserAvailable={canOperatePreview && browserAvailable}
12194 terminalAvailable={activeProject !== null && canOperateTerminal}12204 terminalAvailable={activeProject !== null && canOperateTerminal}
12195 diffAvailable={isServerThread && isGitRepo}12205 diffAvailable={workspaceChangesAvailable}
12196 filesAvailable={activeProject !== null}12206 filesAvailable={activeProject !== null}
12197 pullRequestAvailable={pullRequestSurfaceAvailable}12207 pullRequestAvailable={pullRequestSurfaceAvailable}
12198 pullRequestsAvailable={pullRequestsSurfaceAvailable}12208 pullRequestsAvailable={pullRequestsSurfaceAvailable}
...@@ -12251,7 +12261,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -12251,7 +12261,7 @@ export default function ChatView(props: ChatViewProps) {
12251 onAddDevice={addDeviceSurface}12261 onAddDevice={addDeviceSurface}
12252 browserAvailable={canOperatePreview && browserAvailable}12262 browserAvailable={canOperatePreview && browserAvailable}
12253 terminalAvailable={activeProject !== null && canOperateTerminal}12263 terminalAvailable={activeProject !== null && canOperateTerminal}
12254 diffAvailable={isServerThread && isGitRepo}12264 diffAvailable={workspaceChangesAvailable}
12255 filesAvailable={activeProject !== null}12265 filesAvailable={activeProject !== null}
12256 pullRequestAvailable={pullRequestSurfaceAvailable}12266 pullRequestAvailable={pullRequestSurfaceAvailable}
12257 pullRequestsAvailable={pullRequestsSurfaceAvailable}12267 pullRequestsAvailable={pullRequestsSurfaceAvailable}
apps/web/src/components/GitActionsControl.lifecycle.test.ts+32-1
...@@ -11,6 +11,7 @@ const state = vi.hoisted(() => ({...@@ -11,6 +11,7 @@ const state = vi.hoisted(() => ({
11 toasts: [] as { type?: string; description?: string; actionProps?: { onClick: () => void } }[],11 toasts: [] as { type?: string; description?: string; actionProps?: { onClick: () => void } }[],
12 scopes: new Set<string>(),12 scopes: new Set<string>(),
13 primaryScopes: new Set<string>(),13 primaryScopes: new Set<string>(),
14 isRepo: true,
14}));15}));
1516
16vi.mock("@effect/atom-react", () => ({17vi.mock("@effect/atom-react", () => ({
...@@ -40,7 +41,7 @@ vi.mock("~/state/query", () => ({...@@ -40,7 +41,7 @@ vi.mock("~/state/query", () => ({
40 useEnvironmentQuery: () => ({41 useEnvironmentQuery: () => ({
41 data: {42 data: {
42 sourceControlProviders: [],43 sourceControlProviders: [],
43 isRepo: true,44 isRepo: state.isRepo,
44 refName: "main",45 refName: "main",
45 isDefaultRef: false,46 isDefaultRef: false,
46 hasPrimaryRemote: true,47 hasPrimaryRemote: true,
...@@ -191,6 +192,7 @@ beforeEach(() => {...@@ -191,6 +192,7 @@ beforeEach(() => {
191 state.primaryScopes = new Set([AuthSourceControlWriteScope]);192 state.primaryScopes = new Set([AuthSourceControlWriteScope]);
192 state.actions = [];193 state.actions = [];
193 state.toasts = [];194 state.toasts = [];
195 state.isRepo = true;
194});196});
195197
196afterEach(async () => {198afterEach(async () => {
...@@ -200,6 +202,35 @@ afterEach(async () => {...@@ -200,6 +202,35 @@ afterEach(async () => {
200});202});
201203
202describe("Git action targets across thread navigation", () => {204describe("Git action targets across thread navigation", () => {
205 it("keeps Changes reachable in a JJ-only panel without rendering Git actions", async () => {
206 state.isRepo = false;
207 let renderer!: ReturnType<typeof create>;
208 await act(async () => {
209 renderer = create(
210 createElement(MemoGitActionsControl, {
211 ...props("jj-thread", "/jj-workspace"),
212 displayMode: "panel",
213 onOpenChanges: () => {},
214 }),
215 );
216 });
217 renderers.push(renderer);
218 expect(renderer.root.findAllByType("button")).toHaveLength(1);
219 expect(JSON.stringify(renderer.toJSON())).toContain("Changes");
220 expect(JSON.stringify(renderer.toJSON())).not.toContain("Commit");
221 expect(state.actions).toEqual([]);
222
223 await act(async () => {
224 renderer.update(
225 createElement(MemoGitActionsControl, {
226 ...props("plain-thread", "/plain-folder"),
227 displayMode: "panel",
228 }),
229 );
230 });
231 expect(renderer.toJSON()).toBeNull();
232 });
233
203 it("runs a retained PR toast in the thread that produced it", async () => {234 it("runs a retained PR toast in the thread that produced it", async () => {
204 const renderer = await renderActions("first", "/first");235 const renderer = await renderActions("first", "/first");
205 await clickQuickAction(renderer);236 await clickQuickAction(renderer);
apps/web/src/components/GitActionsControl.tsx+26-29
...@@ -14,15 +14,7 @@ import type {...@@ -14,15 +14,7 @@ import type {
14 GitStackedAction,14 GitStackedAction,
15 VcsStatusResult,15 VcsStatusResult,
16} from "@t3tools/contracts";16} from "@t3tools/contracts";
17import {17import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
18 type MouseEvent,
19 useCallback,
20 useEffect,
21 useEffectEvent,
22 useMemo,
23 useRef,
24 useState,
25} from "react";
26import {18import {
27 CheckIcon,19 CheckIcon,
28 ChevronDownIcon,20 ChevronDownIcon,
...@@ -1073,7 +1065,30 @@ export default function GitActionsControl({...@@ -1073,7 +1065,30 @@ export default function GitActionsControl({
1073 </>1065 </>
1074 );1066 );
10751067
1076 if (!gitCwd || !isRepo) return null;1068 const changesControl = isPanel ? (
1069 <ThreadDetailsControl
1070 type="button"
1071 variant="ghost"
1072 size="sm"
1073 part="row"
1074 disabled={!onOpenChanges}
1075 onClick={onOpenChanges}
1076 >
1077 <FileDiffIcon className={THREAD_DETAILS_PANEL_ICON_CLASS} aria-hidden />
1078 <span className={cn("flex-1 text-left", THREAD_DETAILS_PANEL_LABEL_CLASS)}>Changes</span>
1079 {isRepo ? (
1080 <span className="flex items-center gap-1 font-mono text-2xs tabular-nums">
1081 <span className="text-success">+{formatDiffCount(changesTotals?.insertions ?? 0)}</span>
1082 <span className="text-destructive">
1083 -{formatDiffCount(changesTotals?.deletions ?? 0)}
1084 </span>
1085 </span>
1086 ) : null}
1087 </ThreadDetailsControl>
1088 ) : null;
1089
1090 if (!gitCwd) return null;
1091 if (!isRepo) return onOpenChanges ? changesControl : null;
10771092
1078 return (1093 return (
1079 <>1094 <>
...@@ -1248,25 +1263,7 @@ export default function GitActionsControl({...@@ -1248,25 +1263,7 @@ export default function GitActionsControl({
1248 </ActionGroup>1263 </ActionGroup>
1249 )}1264 )}
12501265
1251 {isPanel && isRepo ? (1266 {changesControl}
1252 <ThreadDetailsControl
1253 type="button"
1254 variant="ghost"
1255 size="sm"
1256 part="row"
1257 disabled={!onOpenChanges}
1258 onClick={onOpenChanges}
1259 >
1260 <FileDiffIcon className={THREAD_DETAILS_PANEL_ICON_CLASS} aria-hidden />
1261 <span className={cn("flex-1 text-left", THREAD_DETAILS_PANEL_LABEL_CLASS)}>Changes</span>
1262 <span className="flex items-center gap-1 font-mono text-2xs tabular-nums">
1263 <span className="text-success">+{formatDiffCount(changesTotals?.insertions ?? 0)}</span>
1264 <span className="text-destructive">
1265 -{formatDiffCount(changesTotals?.deletions ?? 0)}
1266 </span>
1267 </span>
1268 </ThreadDetailsControl>
1269 ) : null}
12701267
1271 <Dialog1268 <Dialog
1272 open={isCommitDialogOpen}1269 open={isCommitDialogOpen}
apps/web/src/components/RightPanelTabs.keyboard.test.tsx+16
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9} from "@t3tools/shared/keybindings";9} from "@t3tools/shared/keybindings";
1010
11import { RightPanelTabs } from "./RightPanelTabs";11import { RightPanelTabs } from "./RightPanelTabs";
12import { supportsWorkspaceChanges } from "./review/jjReview.logic";
1213
13vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }) }));14vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }) }));
14vi.mock("~/browser/browserDefaults", () => ({ useBrowserDefaults: () => ({ profiles: [] }) }));15vi.mock("~/browser/browserDefaults", () => ({ useBrowserDefaults: () => ({ profiles: [] }) }));
...@@ -110,6 +111,21 @@ async function press(key: string, options: KeyboardEventInit = {}) {...@@ -110,6 +111,21 @@ async function press(key: string, options: KeyboardEventInit = {}) {
110}111}
111112
112describe("right panel new-tab shortcut", () => {113describe("right panel new-tab shortcut", () => {
114 it("offers Diff in a non-colocated Jujutsu workspace and disables it in a plain folder", async () => {
115 for (const isJjWorkspace of [true, false]) {
116 await renderPanel({
117 diffAvailable: supportsWorkspaceChanges({ isRepo: false, isJjWorkspace }),
118 });
119 await press("t", { metaKey: true });
120 const item = [...document.querySelectorAll('[role="menuitem"]')].find((entry) =>
121 entry.textContent?.includes("Diff"),
122 );
123 expect(item).toBeDefined();
124 expect(item?.getAttribute("aria-disabled") === "true").toBe(!isJjWorkspace);
125 await press("Escape");
126 }
127 });
128
113 it.each(["MacIntel", "Win32", "Linux x86_64"])(129 it.each(["MacIntel", "Win32", "Linux x86_64"])(
114 "opens the menu on %s and chooses a tab",130 "opens the menu on %s and chooses a tab",
115 async (platform) => {131 async (platform) => {
apps/web/src/components/RightPanelTabs.tsx+7-2
...@@ -35,6 +35,7 @@ import {...@@ -35,6 +35,7 @@ import {
35 Globe2,35 Globe2,
36 Plus,36 Plus,
37 TerminalSquare,37 TerminalSquare,
38 RocketIcon,
38} from "lucide-react";39} from "lucide-react";
39import { Volume2, VolumeOff } from "lucide";40import { Volume2, VolumeOff } from "lucide";
40import {41import {
...@@ -174,7 +175,7 @@ const SURFACE_DISABLED_REASONS = {...@@ -174,7 +175,7 @@ const SURFACE_DISABLED_REASONS = {
174 browser: "Browser previews are only available in The Knife desktop app.",175 browser: "Browser previews are only available in The Knife desktop app.",
175 terminal: "Terminal surfaces are only available from a project thread.",176 terminal: "Terminal surfaces are only available from a project thread.",
176 files: "Files are only available when a project is open.",177 files: "Files are only available when a project is open.",
177 diff: "Diff is only available for server threads in Git repositories.",178 diff: "Diff is only available for server threads in Git or Jujutsu workspaces.",
178 pullRequest: "This thread's branch has no pull request yet.",179 pullRequest: "This thread's branch has no pull request yet.",
179 pullRequests: "No linked pull requests are available for this thread.",180 pullRequests: "No linked pull requests are available for this thread.",
180 device: "Devices are only available from a thread.",181 device: "Devices are only available from a thread.",
...@@ -197,7 +198,7 @@ const SURFACE_UNAVAILABLE_HINTS = {...@@ -197,7 +198,7 @@ const SURFACE_UNAVAILABLE_HINTS = {
197 browser: "Only available in the desktop app.",198 browser: "Only available in the desktop app.",
198 terminal: "Available when a project is open.",199 terminal: "Available when a project is open.",
199 files: "Available when a project is open.",200 files: "Available when a project is open.",
200 diff: "Available for Git repositories.",201 diff: "Available for Git or Jujutsu workspaces.",
201 pullRequest: "No pull request on this branch yet.",202 pullRequest: "No pull request on this branch yet.",
202 pullRequests: "No linked pull requests available.",203 pullRequests: "No linked pull requests available.",
203 device: "Available from a thread.",204 device: "Available from a thread.",
...@@ -618,6 +619,8 @@ function surfaceTitle(...@@ -618,6 +619,8 @@ function surfaceTitle(
618 return `#${surface.number}`;619 return `#${surface.number}`;
619 case "pull-requests":620 case "pull-requests":
620 return "Pull requests";621 return "Pull requests";
622 case "deployments":
623 return "Deployments";
621 case "device":624 case "device":
622 return surface.title ?? surface.target?.name ?? "Device";625 return surface.title ?? surface.target?.name ?? "Device";
623 case "preview": {626 case "preview": {
...@@ -701,6 +704,8 @@ function SurfaceIcon({...@@ -701,6 +704,8 @@ function SurfaceIcon({
701 );704 );
702 case "pull-requests":705 case "pull-requests":
703 return <PullRequestGlyph.link className="size-3 shrink-0" />;706 return <PullRequestGlyph.link className="size-3 shrink-0" />;
707 case "deployments":
708 return <RocketIcon className="size-3 shrink-0" />;
704 case "device":709 case "device":
705 return surface.target?.platform === "ios" ? (710 return surface.target?.platform === "ios" ? (
706 <AppleIcon className="size-3 shrink-0" />711 <AppleIcon className="size-3 shrink-0" />
apps/web/src/components/Sidebar.tsx+15-1
...@@ -105,6 +105,7 @@ import {...@@ -105,6 +105,7 @@ import {
105} from "react";105} from "react";
106import { useParams, useRouter } from "@tanstack/react-router";106import { useParams, useRouter } from "@tanstack/react-router";
107107
108import { ThreadMergebotPill, useIsMergebotRepository } from "./review/ThreadMergebotPill";
108import { useRightPanelStore } from "../rightPanelStore";109import { useRightPanelStore } from "../rightPanelStore";
109import {110import {
110 isAtomCommandInterrupted,111 isAtomCommandInterrupted,
...@@ -1299,6 +1300,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1299,6 +1300,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1299 const currentLinkedPr = supportsMultiplePullRequests1300 const currentLinkedPr = supportsMultiplePullRequests
1300 ? resolveThreadCurrentPullRequestLink(thread.pullRequests)1301 ? resolveThreadCurrentPullRequestLink(thread.pullRequests)
1301 : null;1302 : null;
1303 const mergebotPullRequest = useIsMergebotRepository(
1304 thread.environmentId,
1305 currentLinkedPr?.repository,
1306 )
1307 ? currentLinkedPr
1308 : null;
13021309
1303 // Same semantics as the legacy sidebar (never-visited counts as read):1310 // Same semantics as the legacy sidebar (never-visited counts as read):
1304 // switching sidebars must not light up every historical thread as unread.1311 // switching sidebars must not light up every historical thread as unread.
...@@ -2201,6 +2208,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -2201,6 +2208,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
2201 ) : null}2208 ) : null}
2202 </div>2209 </div>
2203 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-secondary-label text-xs">2210 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-secondary-label text-xs">
2211 {mergebotPullRequest ? (
2212 <ThreadMergebotPill
2213 environmentId={thread.environmentId}
2214 repository={mergebotPullRequest.repository}
2215 number={mergebotPullRequest.number}
2216 />
2217 ) : null}
2204 {/* Always the branch. The plan step used to take this slot while2218 {/* Always the branch. The plan step used to take this slot while
2205 working, but it truncated to a half-sentence and dropped the2219 working, but it truncated to a half-sentence and dropped the
2206 branch, so the row lost its most stable identifier. */}2220 branch, so the row lost its most stable identifier. */}
...@@ -2226,7 +2240,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -2226,7 +2240,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
2226 <span className="flex-1" />2240 <span className="flex-1" />
2227 )}2241 )}
2228 {terminalStatusIcon}2242 {terminalStatusIcon}
2229 {prBadge}2243 {mergebotPullRequest ? null : prBadge}
2230 {diff ? (2244 {diff ? (
2231 <span className="shrink-0 font-mono">2245 <span className="shrink-0 font-mono">
2232 <span className="text-diff-addition-foreground">2246 <span className="text-diff-addition-foreground">
apps/web/src/components/deployments/ConfiguredDeployments.tsx created+69
...@@ -0,0 +1,69 @@
1import type { EnvironmentId, MergebotDeployments } from "@t3tools/contracts";
2import type { ReactNode } from "react";
3
4import { useEnvironment } from "../../state/environments";
5import { useMergebotDeployments } from "../../state/mergebot";
6
7type DeploymentsRender = (deployments: readonly MergebotDeployments[]) => ReactNode;
8
9/**
10 * Renders `children` with deployment data for every Mergebot repository configured on the
11 * environment that has any. One component per repository keeps each hook call order stable.
12 */
13export function ConfiguredDeployments(props: {
14 environmentId: EnvironmentId;
15 children: DeploymentsRender;
16}) {
17 const environment = useEnvironment(props.environmentId);
18
19 return (
20 <Collect
21 environmentId={props.environmentId}
22 repositories={environment?.serverConfig?.mergebot?.repositories ?? []}
23 found={[]}
24 >
25 {props.children}
26 </Collect>
27 );
28}
29
30function Collect(props: {
31 environmentId: EnvironmentId;
32 repositories: readonly string[];
33 found: readonly MergebotDeployments[];
34 children: DeploymentsRender;
35}) {
36 const [repository, ...rest] = props.repositories;
37 if (repository === undefined) return props.children(props.found);
38
39 return (
40 <CollectRepository
41 environmentId={props.environmentId}
42 repository={repository}
43 rest={rest}
44 found={props.found}
45 >
46 {props.children}
47 </CollectRepository>
48 );
49}
50
51function CollectRepository(props: {
52 environmentId: EnvironmentId;
53 repository: string;
54 rest: readonly string[];
55 found: readonly MergebotDeployments[];
56 children: DeploymentsRender;
57}) {
58 const deployments = useMergebotDeployments(props.environmentId, props.repository);
59
60 return (
61 <Collect
62 environmentId={props.environmentId}
63 repositories={props.rest}
64 found={deployments ? [...props.found, deployments] : props.found}
65 >
66 {props.children}
67 </Collect>
68 );
69}
apps/web/src/components/deployments/DeploymentsPanel.tsx created+310
...@@ -0,0 +1,310 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId, MergebotDeployments, MergebotMergedCommit } from "@t3tools/contracts";
3import { CircleDashedIcon, CircleXIcon, ExternalLinkIcon } from "lucide-react";
4import { useState } from "react";
5
6import { cn } from "../../lib/utils";
7import { useEnvironment } from "../../state/environments";
8import { mergebotEnvironment } from "../../state/mergebot";
9import { useAtomCommand } from "../../state/use-atom-command";
10import { useNowMinute } from "../../hooks/useNowMinute";
11import { formatRelativeTimeLabel } from "../../timestampFormat";
12import { PullRequestActorAvatar } from "../pullRequest/pullRequestPresentation";
13import {
14 AlertDialog,
15 AlertDialogClose,
16 AlertDialogDescription,
17 AlertDialogFooter,
18 AlertDialogHeader,
19 AlertDialogPopup,
20 AlertDialogTitle,
21} from "../ui/alert-dialog";
22import { Button } from "../ui/button";
23import { ScrollArea } from "../ui/scroll-area";
24import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
25import { ConfiguredDeployments } from "./ConfiguredDeployments";
26import { CommitTitle } from "../review/commitTitle";
27import {
28 buildDeploymentTimeline,
29 railCommitCount,
30 type DeploymentTimelineItem,
31} from "./deployments.logic";
32
33/** Mergebot's deploys pane: environment markers over the merged-commit rail. */
34export function DeploymentsPanel(props: { environmentId: EnvironmentId }) {
35 return (
36 <ConfiguredDeployments environmentId={props.environmentId}>
37 {(found) => <DeploymentsView environmentId={props.environmentId} found={found} />}
38 </ConfiguredDeployments>
39 );
40}
41
42function DeploymentsView(props: {
43 environmentId: EnvironmentId;
44 found: readonly MergebotDeployments[];
45}) {
46 const configuration = useEnvironment(props.environmentId)?.serverConfig?.mergebot;
47 const [selectedRepository, setSelectedRepository] = useState<string | null>(null);
48 const deployments =
49 props.found.find((entry) => entry.repository === selectedRepository) ?? props.found[0];
50
51 if (!configuration) {
52 return <PanelMessage>Mergebot is not configured on this environment.</PanelMessage>;
53 }
54 if (!deployments) {
55 return <PanelMessage>Waiting for deployment data from Mergebot…</PanelMessage>;
56 }
57
58 return (
59 <div className="@container flex h-full min-h-0 flex-col">
60 <div className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2">
61 {props.found.length > 1 ? (
62 <Select value={deployments.repository} onValueChange={setSelectedRepository}>
63 <SelectTrigger
64 aria-label="Repository"
65 size="compact"
66 variant="ghost"
67 className="w-auto min-w-0"
68 >
69 <SelectValue>{deployments.repository}</SelectValue>
70 </SelectTrigger>
71 <SelectPopup align="start" alignItemWithTrigger={false}>
72 {props.found.map((entry) => (
73 <SelectItem key={entry.repository} value={entry.repository}>
74 {entry.repository}
75 </SelectItem>
76 ))}
77 </SelectPopup>
78 </Select>
79 ) : (
80 <span className="min-w-0 truncate px-1 text-xs font-medium">
81 {deployments.repository}
82 </span>
83 )}
84 <Button
85 aria-label="Open in Mergebot"
86 className="ms-auto"
87 size="icon-xs"
88 variant="ghost-muted"
89 render={
90 <a
91 href={`${configuration.origin}/${deployments.repository}`}
92 rel="noopener noreferrer"
93 target="_blank"
94 />
95 }
96 >
97 <ExternalLinkIcon />
98 </Button>
99 </div>
100 <ScrollArea className="min-h-0 flex-1">
101 <DeploymentTimeline
102 key={deployments.repository}
103 environmentId={props.environmentId}
104 deployments={deployments}
105 />
106 </ScrollArea>
107 </div>
108 );
109}
110
111function PanelMessage(props: { children: string }) {
112 return (
113 <div className="flex h-full items-center justify-center p-6">
114 <p className="text-center text-sm text-muted-foreground">{props.children}</p>
115 </div>
116 );
117}
118
119function DeploymentTimeline(props: {
120 environmentId: EnvironmentId;
121 deployments: MergebotDeployments;
122}) {
123 const { deployments } = props;
124 const nowMinute = useNowMinute();
125 const [expanded, setExpanded] = useState(false);
126 const [confirming, setConfirming] = useState<MergebotMergedCommit | null>(null);
127 const [deploying, setDeploying] = useState(false);
128 const canDeploy = useAtomValue(
129 mergebotEnvironment.deployProduction.permissionAtom(props.environmentId),
130 );
131 const deployProduction = useAtomCommand(mergebotEnvironment.deployProduction);
132
133 const railCount = railCommitCount(deployments);
134 const hasMore = deployments.mergedCommits.length > railCount;
135 const items = buildDeploymentTimeline(
136 deployments,
137 Date.parse(`${nowMinute}Z`),
138 expanded ? undefined : railCount,
139 );
140
141 const confirm = async () => {
142 if (!confirming) return;
143 setDeploying(true);
144 const result = await deployProduction({
145 environmentId: props.environmentId,
146 input: { repository: deployments.repository, sha: confirming.sha },
147 });
148 setDeploying(false);
149 if (result._tag === "Success") setConfirming(null);
150 };
151
152 const confirmTitle = confirming ? firstLine(confirming.message) : "";
153
154 return (
155 <div className="flex flex-col gap-1.5 p-3">
156 {items.map((item) =>
157 item.kind === "marker" ? (
158 <TimelineMarker key={item.key} item={item} />
159 ) : (
160 <MergedCommitRow
161 key={item.key}
162 item={item}
163 onDeploy={canDeploy ? () => setConfirming(item.commit) : null}
164 />
165 ),
166 )}
167 {hasMore ? (
168 <Button
169 className="self-start"
170 size="xs"
171 variant="ghost-muted"
172 onClick={() => setExpanded((value) => !value)}
173 >
174 {expanded ? "Show less" : "Show timeline"}
175 </Button>
176 ) : null}
177
178 <AlertDialog
179 open={confirming !== null}
180 onOpenChange={(open) => {
181 if (!open && !deploying) setConfirming(null);
182 }}
183 >
184 <AlertDialogPopup>
185 <AlertDialogHeader>
186 <AlertDialogTitle>Deploy to production?</AlertDialogTitle>
187 <AlertDialogDescription>
188 Fast-forwards <code>{deployments.productionBranch}</code> to{" "}
189 <code>{confirming?.sha.slice(0, 7)}</code>
190 {` (${confirmTitle})`} and ships every commit up to it.
191 </AlertDialogDescription>
192 </AlertDialogHeader>
193 <AlertDialogFooter>
194 <AlertDialogClose render={<Button variant="outline" />} disabled={deploying}>
195 Cancel
196 </AlertDialogClose>
197 <Button
198 variant="destructive"
199 aria-busy={deploying}
200 disabled={deploying}
201 onClick={() => void confirm()}
202 >
203 {deploying ? "Deploying…" : "Deploy to production"}
204 </Button>
205 </AlertDialogFooter>
206 </AlertDialogPopup>
207 </AlertDialog>
208 </div>
209 );
210}
211
212const MARKER_TONE = {
213 default: { text: "text-muted-foreground", line: "bg-border" },
214 pending: { text: "text-warning-foreground", line: "bg-warning" },
215 failed: { text: "text-destructive-foreground", line: "bg-border" },
216} as const;
217
218function TimelineMarker(props: { item: Extract<DeploymentTimelineItem, { kind: "marker" }> }) {
219 const { label, tone, href } = props.item;
220 const content = (
221 <>
222 <span className={cn("h-px w-4 flex-none", MARKER_TONE[tone].line)} />
223 <span
224 className={cn(
225 "inline-flex min-w-0 items-center gap-1.5 text-xs font-medium",
226 MARKER_TONE[tone].text,
227 )}
228 >
229 {tone === "pending" ? <CircleDashedIcon aria-hidden className="size-3.5 shrink-0" /> : null}
230 {tone === "failed" ? <CircleXIcon aria-hidden className="size-3.5 shrink-0" /> : null}
231 <span>{label}</span>
232 </span>
233 <span className={cn("h-px min-w-4 flex-1", MARKER_TONE[tone].line)} />
234 </>
235 );
236 const className = "flex items-center gap-2 py-1";
237
238 return href ? (
239 <a
240 className={cn(className, "transition-opacity hover:opacity-80")}
241 href={href}
242 rel="noopener noreferrer"
243 target="_blank"
244 >
245 {content}
246 </a>
247 ) : (
248 <div className={className}>{content}</div>
249 );
250}
251
252const COMMIT_TONE = {
253 deploying: "border-warning/60 bg-warning-surface",
254 hotfix: "border-warning/40 bg-warning-surface",
255 unshipped: "border-success/50",
256 default: "border-border",
257} as const;
258
259function firstLine(message: string) {
260 return message.split("\n", 1)[0]?.trim() ?? "";
261}
262
263function MergedCommitRow(props: {
264 item: Extract<DeploymentTimelineItem, { kind: "commit" }>;
265 onDeploy: (() => void) | null;
266}) {
267 const { commit, tone, isDeployCandidate } = props.item;
268 const authorName = (commit.authorName ?? commit.authorLogin ?? "ghost").trim().split(/\s+/)[0];
269 const actor = {
270 login: commit.authorLogin ?? authorName ?? "ghost",
271 name: commit.authorName,
272 avatarUrl: commit.authorAvatarUrl,
273 };
274
275 return (
276 <div
277 className={cn(
278 "group relative flex items-center rounded-md border text-xs",
279 commit.status === "hotfix" && "ml-3",
280 COMMIT_TONE[tone],
281 )}
282 >
283 <a
284 className="flex min-w-0 flex-1 items-center gap-2 rounded-[inherit] px-2.5 py-1.5 transition-colors hover:bg-accent/50"
285 href={commit.url}
286 rel="noopener noreferrer"
287 target="_blank"
288 >
289 {commit.pullRequestNumber !== null ? (
290 <span className="shrink-0 font-semibold text-muted-foreground">
291 #{commit.pullRequestNumber}
292 </span>
293 ) : null}
294 <CommitTitle className="min-w-0 flex-1 font-medium" title={firstLine(commit.message)} />
295 <PullRequestActorAvatar actor={actor} />
296 <span className="hidden shrink-0 text-muted-foreground @md:inline">{authorName}</span>
297 <span className="shrink-0 text-muted-foreground tabular-nums">
298 {formatRelativeTimeLabel(commit.mergedAt)}
299 </span>
300 </a>
301 {isDeployCandidate && props.onDeploy ? (
302 <span className="absolute right-2 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100">
303 <Button size="micro" variant="outline" onClick={props.onDeploy}>
304 Deploy to here
305 </Button>
306 </span>
307 ) : null}
308 </div>
309 );
310}
apps/web/src/components/deployments/deployments.logic.test.ts created+232
...@@ -0,0 +1,232 @@
1import type {
2 MergebotDeploymentEnvironment,
3 MergebotDeployments,
4 MergebotMergedCommit,
5} from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";
7
8import {
9 buildDeploymentTimeline,
10 deploymentActivity,
11 formatEta,
12 railCommitCount,
13 stagingOnlyCount,
14} from "./deployments.logic";
15
16const NOW = Date.parse("2026-10-15T12:00:00Z");
17
18function commit(sha: string, overrides: Partial<MergebotMergedCommit> = {}): MergebotMergedCommit {
19 return {
20 sha,
21 pullRequestNumber: null,
22 authorLogin: null,
23 authorName: null,
24 authorAvatarUrl: null,
25 message: `feat: ${sha}`,
26 mergedAt: "2026-10-15T11:00:00Z",
27 url: `https://github.com/acme/app/commit/${sha}`,
28 labels: [],
29 status: "default",
30 isProductionHead: false,
31 ...overrides,
32 };
33}
34
35function environment(
36 sha: string,
37 overrides: Partial<MergebotDeploymentEnvironment> = {},
38): MergebotDeploymentEnvironment {
39 return {
40 sha,
41 deployedSha: null,
42 status: "passed",
43 currentStep: null,
44 etaAt: null,
45 checksUrl: `https://github.com/acme/app/commit/${sha}/checks`,
46 ...overrides,
47 };
48}
49
50function deployments(overrides: Partial<MergebotDeployments> = {}): MergebotDeployments {
51 return {
52 repository: "acme/app",
53 url: "https://github.com/acme/app",
54 defaultBranch: "main",
55 productionBranch: "production",
56 stagingUrl: "https://staging.acme.dev",
57 productionUrl: "https://acme.dev",
58 staging: environment("c1"),
59 production: environment("c3"),
60 mergedCommits: [
61 commit("c1"),
62 commit("c2"),
63 commit("c3", { status: "recent", isProductionHead: true }),
64 commit("c4", { status: "recent" }),
65 ],
66 ...overrides,
67 };
68}
69
70function shape(items: ReturnType<typeof buildDeploymentTimeline>) {
71 return items.map((item) =>
72 item.kind === "marker"
73 ? `marker:${item.label}`
74 : `${item.commit.sha}${item.isDeployCandidate ? " (candidate)" : ""}`,
75 );
76}
77
78describe("stagingOnlyCount", () => {
79 it("counts commits live on staging but not production", () => {
80 expect(stagingOnlyCount(deployments())).toBe(2);
81 });
82
83 it("measures from the live staging sha while staging deploys", () => {
84 const value = deployments({
85 staging: environment("c1", { status: "pending", deployedSha: "c2" }),
86 });
87 expect(stagingOnlyCount(value)).toBe(1);
88 });
89
90 it("measures from the live production sha while production deploys", () => {
91 const value = deployments({
92 production: environment("c2", { status: "pending", deployedSha: "c3" }),
93 });
94 expect(stagingOnlyCount(value)).toBe(2);
95 });
96
97 it("is zero when environments agree or staging is absent", () => {
98 expect(stagingOnlyCount(deployments({ production: environment("c1") }))).toBe(0);
99 expect(stagingOnlyCount(deployments({ staging: null, stagingUrl: null }))).toBe(0);
100 });
101
102 it("falls back to the production branch head", () => {
103 expect(stagingOnlyCount(deployments({ production: null, productionUrl: null }))).toBe(2);
104 });
105});
106
107describe("buildDeploymentTimeline", () => {
108 it("places staging on top and production above its commit", () => {
109 expect(shape(buildDeploymentTimeline(deployments(), NOW))).toEqual([
110 "marker:on staging",
111 "c1 (candidate)",
112 "c2 (candidate)",
113 "marker:on production",
114 "c3",
115 "c4",
116 ]);
117 });
118
119 it("stacks both environments when they share a commit", () => {
120 const value = deployments({ production: environment("c1") });
121 expect(shape(buildDeploymentTimeline(value, NOW))).toEqual([
122 "marker:on staging + production",
123 "c1",
124 "c2",
125 "c3",
126 "c4",
127 ]);
128 });
129
130 it("shows an in-flight staging deploy with its step and ETA above the target commit", () => {
131 const value = deployments({
132 staging: environment("c1", {
133 status: "pending",
134 deployedSha: "c2",
135 currentStep: "deploy_client",
136 etaAt: "2026-10-15T12:02:00Z",
137 }),
138 });
139 const items = buildDeploymentTimeline(value, NOW);
140 expect(shape(items)).toEqual([
141 "marker:deploying to staging · deploy_client · ~2m left",
142 "c1",
143 "marker:on staging",
144 "c2 (candidate)",
145 "marker:on production",
146 "c3",
147 "c4",
148 ]);
149 const c1 = items.find((item) => item.kind === "commit" && item.commit.sha === "c1");
150 expect(c1?.kind === "commit" && c1.tone).toBe("deploying");
151 });
152
153 it("keeps production's live commit marked while it deploys a newer one", () => {
154 const value = deployments({
155 production: environment("c2", { status: "failed", deployedSha: "c3" }),
156 });
157 expect(shape(buildDeploymentTimeline(value, NOW))).toEqual([
158 "marker:on staging",
159 "c1 (candidate)",
160 "marker:failed deployment to production",
161 "c2",
162 "marker:on production",
163 "c3",
164 "c4",
165 ]);
166 });
167
168 it("marks unshipped work without staging and offers no deploys without a production branch", () => {
169 const withoutStaging = deployments({ staging: null, stagingUrl: null });
170 expect(shape(buildDeploymentTimeline(withoutStaging, NOW))).toEqual([
171 "marker:main (unshipped)",
172 "c1 (candidate)",
173 "c2 (candidate)",
174 "marker:on production",
175 "c3",
176 "c4",
177 ]);
178
179 const trunkOnly = deployments({
180 staging: null,
181 stagingUrl: null,
182 production: null,
183 productionUrl: null,
184 productionBranch: null,
185 });
186 expect(shape(buildDeploymentTimeline(trunkOnly, NOW))).toEqual([
187 "marker:main",
188 "c1",
189 "c2",
190 "c3",
191 "c4",
192 ]);
193 });
194
195 it("truncates commits without moving markers", () => {
196 expect(shape(buildDeploymentTimeline(deployments(), NOW, 2))).toEqual([
197 "marker:on staging",
198 "c1 (candidate)",
199 "c2 (candidate)",
200 ]);
201 });
202});
203
204describe("railCommitCount", () => {
205 it("reaches back to the oldest environment anchor", () => {
206 const mergedCommits = Array.from({ length: 12 }, (_, index) =>
207 commit(`c${index}`, { isProductionHead: index === 10 }),
208 );
209 expect(railCommitCount(deployments({ mergedCommits, production: environment("c10") }))).toBe(
210 11,
211 );
212 expect(railCommitCount(deployments({ mergedCommits: mergedCommits.slice(0, 5) }))).toBe(8);
213 });
214});
215
216describe("deploymentActivity", () => {
217 it("reports the worst configured environment", () => {
218 const deploying = deployments({ staging: environment("c1", { status: "pending" }) });
219 const failed = deployments({ production: environment("c3", { status: "failed" }) });
220 expect(deploymentActivity([deployments()])).toBe("healthy");
221 expect(deploymentActivity([deployments(), deploying])).toBe("deploying");
222 expect(deploymentActivity([deploying, failed])).toBe("failed");
223 });
224});
225
226describe("formatEta", () => {
227 it("rounds to minutes", () => {
228 expect(formatEta("2026-10-15T12:00:20Z", NOW)).toBe("<1m");
229 expect(formatEta("2026-10-15T12:02:10Z", NOW)).toBe("~2m");
230 expect(formatEta("2026-10-15T13:05:00Z", NOW)).toBe("~1h 5m");
231 });
232});
apps/web/src/components/deployments/deployments.logic.ts created+308
...@@ -0,0 +1,308 @@
1import type {
2 MergebotDeploymentEnvironment,
3 MergebotDeployments,
4 MergebotMergedCommit,
5} from "@t3tools/contracts";
6
7// A port of Mergebot's deploy pane (www/src/components/deploys.tsx) as pure derivations.
8
9type EnvironmentName = "staging" | "production";
10type EnvironmentState = "on" | "deploying" | "failed";
11
12export type DeploymentMarkerTone = "default" | "pending" | "failed";
13
14export type DeploymentTimelineItem =
15 | {
16 kind: "marker";
17 key: string;
18 label: string;
19 tone: DeploymentMarkerTone;
20 href: string | null;
21 }
22 | {
23 kind: "commit";
24 key: string;
25 commit: MergebotMergedCommit;
26 tone: "deploying" | "hotfix" | "unshipped" | "default";
27 isDeployCandidate: boolean;
28 };
29
30const RAIL_COMMIT_COUNT = 8;
31
32function hasStaging(deployments: MergebotDeployments) {
33 return deployments.stagingUrl !== null || deployments.staging !== null;
34}
35
36function hasProduction(deployments: MergebotDeployments) {
37 return deployments.productionUrl !== null || deployments.production !== null;
38}
39
40function liveSha(environment: MergebotDeploymentEnvironment | null) {
41 if (!environment) return null;
42 return environment.status === "passed" ? environment.sha : environment.deployedSha;
43}
44
45function indexOfSha(commits: readonly MergebotMergedCommit[], sha: string | null | undefined) {
46 return sha ? commits.findIndex((commit) => commit.sha === sha) : -1;
47}
48
49/**
50 * Where production stands: the deployment sha, else the production branch head. Off the listed
51 * window counts as older than every commit.
52 */
53function productionIndex(deployments: MergebotDeployments, sha: string | null | undefined) {
54 const commits = deployments.mergedCommits;
55 const deploymentIndex = indexOfSha(commits, sha);
56 const index =
57 deploymentIndex === -1
58 ? commits.findIndex((commit) => commit.isProductionHead)
59 : deploymentIndex;
60 return index === -1 ? commits.length : index;
61}
62
63/** Commits live on staging that production does not have yet. */
64export function stagingOnlyCount(deployments: MergebotDeployments): number {
65 if (!hasStaging(deployments) || deployments.productionBranch === null) return 0;
66
67 const stagingIndex = indexOfSha(deployments.mergedCommits, liveSha(deployments.staging));
68 if (stagingIndex === -1) return 0;
69
70 const production = productionIndex(deployments, liveSha(deployments.production));
71 return Math.max(0, production - stagingIndex);
72}
73
74/** Worst state across every configured environment, for the sidebar status dot. */
75export function deploymentActivity(
76 found: readonly MergebotDeployments[],
77): "healthy" | "deploying" | "failed" {
78 const states = new Set(
79 found.flatMap((deployments) => [
80 ...(hasStaging(deployments) ? [environmentState(deployments.staging)] : []),
81 ...(hasProduction(deployments) ? [environmentState(deployments.production)] : []),
82 ]),
83 );
84 if (states.has("failed")) return "failed";
85 return states.has("deploying") ? "deploying" : "healthy";
86}
87
88function environmentState(environment: MergebotDeploymentEnvironment | null): EnvironmentState {
89 if (environment?.status === "passed") return "on";
90 return environment?.status === "failed" ? "failed" : "deploying";
91}
92
93export function formatEta(etaAt: string, now: number): string {
94 const minutes = Math.round((new Date(etaAt).getTime() - now) / 60_000);
95 if (minutes < 1) return "<1m";
96 if (minutes < 60) return `~${minutes}m`;
97 const remainder = minutes % 60;
98 return remainder === 0
99 ? `~${Math.floor(minutes / 60)}h`
100 : `~${Math.floor(minutes / 60)}h ${remainder}m`;
101}
102
103// "deploying to staging · deploy_client_fargate · ~2m left"
104function withCurrentStep(
105 label: string,
106 environment: MergebotDeploymentEnvironment | null,
107 now: number,
108) {
109 const withStep = environment?.currentStep ? `${label} · ${environment.currentStep}` : label;
110 return environment?.etaAt ? `${withStep} · ${formatEta(environment.etaAt, now)} left` : withStep;
111}
112
113function environmentPhrase(state: EnvironmentState, names: EnvironmentName[]) {
114 const joined = names.join(" + ");
115 if (state === "on") return `on ${joined}`;
116 return state === "deploying" ? `deploying to ${joined}` : `failed deployment to ${joined}`;
117}
118
119/**
120 * One line for every environment anchored at the same commit. Matching states collapse with "+"
121 * ("on staging + production"); mixed states join with ", ". Links only for a single environment.
122 */
123function environmentHeader(
124 deployments: MergebotDeployments,
125 parts: Array<{ name: EnvironmentName; state: EnvironmentState }>,
126 now: number,
127 key: string,
128): DeploymentTimelineItem {
129 const label = (["on", "deploying", "failed"] as const)
130 .flatMap((state) => {
131 const names = parts.filter((part) => part.state === state).map((part) => part.name);
132 const [only] = names;
133 if (!only) return [];
134
135 const phrase = environmentPhrase(state, names);
136 if (state === "deploying" && names.length === 1) {
137 return [withCurrentStep(phrase, deployments[only], now)];
138 }
139 return [phrase];
140 })
141 .join(", ");
142
143 const tone: DeploymentMarkerTone = parts.some((part) => part.state === "failed")
144 ? "failed"
145 : parts.some((part) => part.state === "deploying")
146 ? "pending"
147 : "default";
148
149 const [first, ...rest] = parts;
150 const href =
151 first && rest.length === 0
152 ? first.name === "staging"
153 ? deployments.stagingUrl
154 : deployments.productionUrl
155 : null;
156
157 return { kind: "marker", key, label, tone, href };
158}
159
160function environmentMarker(
161 deployments: MergebotDeployments,
162 name: EnvironmentName,
163 now: number,
164 key: string,
165): DeploymentTimelineItem {
166 const environment = deployments[name];
167 const status = environment?.status ?? "pending";
168 const checksUrl = environment?.checksUrl ?? null;
169
170 if (status === "failed") {
171 return {
172 kind: "marker",
173 key,
174 label: `failed deployment to ${name}`,
175 tone: "failed",
176 href: checksUrl,
177 };
178 }
179
180 if (status === "passed") {
181 const href = name === "staging" ? deployments.stagingUrl : deployments.productionUrl;
182 return { kind: "marker", key, label: `on ${name}`, tone: "default", href };
183 }
184
185 const label = withCurrentStep(`deploying to ${name}`, environment, now);
186 return { kind: "marker", key, label, tone: "pending", href: checksUrl };
187}
188
189/** The rail: the latest merges, reaching back far enough to show where every environment stands. */
190export function railCommitCount(deployments: MergebotDeployments): number {
191 const anchors = new Set(
192 [deployments.staging, deployments.production].flatMap((environment) =>
193 environment ? [environment.sha, environment.deployedSha] : [],
194 ),
195 );
196 const oldestAnchor = deployments.mergedCommits.findLastIndex(
197 (commit) => commit.isProductionHead || anchors.has(commit.sha),
198 );
199 return Math.max(RAIL_COMMIT_COUNT, oldestAnchor + 1);
200}
201
202/**
203 * Environment markers interleaved with merged commits, newest first. `limit` truncates the
204 * commit list after placement so markers keep their positions.
205 */
206export function buildDeploymentTimeline(
207 deployments: MergebotDeployments,
208 now: number,
209 limit = Number.POSITIVE_INFINITY,
210): DeploymentTimelineItem[] {
211 const { staging, production, productionBranch, mergedCommits } = deployments;
212 const withStaging = hasStaging(deployments);
213 const withProduction = hasProduction(deployments);
214 const defaultBranch = deployments.defaultBranch || "default";
215
216 // Both environments passed on the same commit: one stacked header above everything.
217 const stacked =
218 withProduction &&
219 staging?.status === "passed" &&
220 production?.status === "passed" &&
221 staging.sha === production.sha;
222
223 const items: DeploymentTimelineItem[] = [];
224 if (!withStaging) {
225 if (productionBranch === null) {
226 items.push({ kind: "marker", key: "top", label: defaultBranch, tone: "default", href: null });
227 } else if (mergedCommits.some((commit) => commit.status === "default")) {
228 const label = `${defaultBranch} (unshipped)`;
229 items.push({ kind: "marker", key: "top", label, tone: "default", href: null });
230 }
231 } else if (stacked) {
232 const parts = [
233 { name: "staging" as const, state: "on" as const },
234 { name: "production" as const, state: "on" as const },
235 ];
236 items.push(environmentHeader(deployments, parts, now, "top"));
237 } else if (staging?.status === "passed") {
238 items.push(environmentMarker(deployments, "staging", now, "top"));
239 }
240
241 // Deploy candidates sit newer than production's target and, with staging, no newer than what
242 // staging has live: fast-forwarding to any of them is valid.
243 const productionTarget = productionIndex(deployments, production?.sha);
244 const stagingLive = withStaging ? indexOfSha(mergedCommits, liveSha(staging)) : -1;
245 const candidateFrom = stagingLive === -1 ? 0 : stagingLive;
246
247 const deployingFrom = staging?.status === "pending" ? indexOfSha(mergedCommits, staging.sha) : -1;
248 const deployingTo = indexOfSha(mergedCommits, staging?.deployedSha);
249 const isDeployingToStaging = (index: number) =>
250 staging?.status === "pending" &&
251 index >= (deployingFrom === -1 ? 0 : deployingFrom) &&
252 index < (deployingTo === -1 ? mergedCommits.length : deployingTo);
253
254 mergedCommits.slice(0, limit).forEach((commit, index) => {
255 const notPassed = (environment: MergebotDeploymentEnvironment | null) =>
256 environment !== null && environment.status !== "passed";
257
258 if (withStaging && notPassed(staging) && staging?.sha === commit.sha) {
259 items.push(environmentMarker(deployments, "staging", now, `staging:${commit.sha}`));
260 }
261
262 const productionMarked =
263 !stacked &&
264 productionBranch !== null &&
265 (withProduction ? production?.sha === commit.sha : commit.isProductionHead);
266
267 if (productionMarked && withProduction && production?.status !== "passed") {
268 items.push(environmentMarker(deployments, "production", now, `production:${commit.sha}`));
269 }
270 if (productionMarked && !withProduction) {
271 const key = `production:${commit.sha}`;
272 items.push({ kind: "marker", key, label: "production", tone: "default", href: null });
273 }
274
275 const stagingLiveHere =
276 withStaging && notPassed(staging) && staging?.deployedSha === commit.sha;
277 const productionLiveHere =
278 (productionMarked && withProduction && production?.status === "passed") ||
279 (withProduction && notPassed(production) && production?.deployedSha === commit.sha);
280 const live: EnvironmentName[] = [
281 ...(stagingLiveHere ? (["staging"] as const) : []),
282 ...(productionLiveHere ? (["production"] as const) : []),
283 ];
284 if (live.length > 0) {
285 const parts = live.map((name) => ({ name, state: "on" as const }));
286 items.push(environmentHeader(deployments, parts, now, `live:${commit.sha}`));
287 }
288
289 const tone = isDeployingToStaging(index)
290 ? "deploying"
291 : commit.status === "hotfix"
292 ? "hotfix"
293 : productionBranch !== null && commit.status === "default"
294 ? "unshipped"
295 : "default";
296
297 items.push({
298 kind: "commit",
299 key: commit.sha,
300 commit,
301 tone,
302 isDeployCandidate:
303 productionBranch !== null && index >= candidateFrom && index < productionTarget,
304 });
305 });
306
307 return items;
308}
apps/web/src/components/diffs/AnnotatableCodeView.tsx+3
...@@ -86,6 +86,7 @@ interface AnnotatableCodeViewProps {...@@ -86,6 +86,7 @@ interface AnnotatableCodeViewProps {
86 options: StyledDiffCodeViewOptions<DiffCommentAnnotationGroup>;86 options: StyledDiffCodeViewOptions<DiffCommentAnnotationGroup>;
87 viewerRef?: Ref<AnnotatableCodeViewHandle>;87 viewerRef?: Ref<AnnotatableCodeViewHandle>;
88 className?: string;88 className?: string;
89 renderCodeViewHeader?: () => ReactNode;
89 renderCodeViewFooter?: () => ReactNode;90 renderCodeViewFooter?: () => ReactNode;
90 unsafeCSSExtra?: string;91 unsafeCSSExtra?: string;
91 renderHeaderMetadata?: (fileDiff: FileDiffMetadata) => ReactNode;92 renderHeaderMetadata?: (fileDiff: FileDiffMetadata) => ReactNode;
...@@ -112,6 +113,7 @@ export function AnnotatableCodeView({...@@ -112,6 +113,7 @@ export function AnnotatableCodeView({
112 options,113 options,
113 viewerRef,114 viewerRef,
114 className,115 className,
116 renderCodeViewHeader,
115 renderCodeViewFooter,117 renderCodeViewFooter,
116 unsafeCSSExtra,118 unsafeCSSExtra,
117 renderHeaderMetadata,119 renderHeaderMetadata,
...@@ -261,6 +263,7 @@ export function AnnotatableCodeView({...@@ -261,6 +263,7 @@ export function AnnotatableCodeView({
261 item.type === "diff" ? renderHeaderMetadata(item.fileDiff) : null,263 item.type === "diff" ? renderHeaderMetadata(item.fileDiff) : null,
262 }264 }
263 : {})}265 : {})}
266 {...(renderCodeViewHeader ? { renderCodeViewHeader } : {})}
264 {...(renderCodeViewFooter ? { renderCodeViewFooter } : {})}267 {...(renderCodeViewFooter ? { renderCodeViewFooter } : {})}
265 items={items}268 items={items}
266 selectedLines={selectedLines}269 selectedLines={selectedLines}
apps/web/src/components/review/CommitMessageEditor.test.tsx created+120
...@@ -0,0 +1,120 @@
1// @vitest-environment jsdom
2import { cursorCharLeft, cursorCharRight } from "@codemirror/commands";
3import { syntaxTree } from "@codemirror/language";
4import { EditorView } from "@codemirror/view";
5import { act } from "react";
6import { createRoot, type Root } from "react-dom/client";
7import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
8
9import { CommitMessageEditor } from "./CommitMessageEditor";
10
11let root: Root;
12let container: HTMLDivElement;
13const change = vi.fn();
14
15beforeEach(() => {
16 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
17 container = document.createElement("div");
18 document.body.append(container);
19 root = createRoot(container);
20});
21
22afterEach(() => {
23 act(() => root.unmount());
24 container.remove();
25 change.mockReset();
26 vi.unstubAllGlobals();
27});
28
29function render(value: string, readOnly = false) {
30 act(() =>
31 root.render(<CommitMessageEditor value={value} readOnly={readOnly} onChange={change} />),
32 );
33 const view = EditorView.findFromDOM(container.querySelector<HTMLElement>(".cm-editor")!)!;
34 return view;
35}
36
37function typeAt(view: EditorView, position: number, text: string) {
38 act(() =>
39 view.dispatch({
40 changes: { from: position, insert: text },
41 selection: { anchor: position + text.length },
42 userEvent: "input",
43 }),
44 );
45}
46
47describe("CommitMessageEditor", () => {
48 it("keeps the title decoration on line 1 as lines move", () => {
49 const view = render("fix: title\nbody");
50 const title = () => container.querySelector(".cm-commit-title")?.textContent;
51 expect(title()).toBe("fix: title");
52
53 typeAt(view, 0, "\n");
54 expect(title()).toBe("");
55 expect(view.state.doc.line(2).text).toBe("fix: title");
56 expect(change).toHaveBeenLastCalledWith("\nfix: title\nbody");
57 });
58
59 it("never parses the title as a heading, list, or setext heading", () => {
60 for (const value of ["# not a heading\nbody", "- not a list\nbody", "title\n---"]) {
61 const view = render(value);
62 const tree = syntaxTree(view.state);
63 expect(tree.resolveInner(1, 1).name).toBe("CommitTitle");
64 expect(container.querySelector(".cm-commit-title")?.textContent).toBe(value.split("\n")[0]);
65 }
66 });
67
68 it("colors conventional-commit titles", () => {
69 render("[ABC-1, ABC-22] feat(review)!: add editor\nbody");
70 expect(container.querySelector(".cm-commit-issues")?.textContent).toBe("[ABC-1, ABC-22]");
71 expect(container.querySelector(".cm-commit-type-add")?.textContent).toBe("feat(review)!:");
72 expect(container.querySelector(".cm-commit-scope")?.textContent).toBe("(review)");
73
74 render("hotfix: x");
75 expect(container.querySelector(".cm-commit-type-fix")?.textContent).toBe("hotfix:");
76 render("chore: x");
77 expect(container.querySelector(".cm-commit-type-other")?.textContent).toBe("chore:");
78 render("WIP: x");
79 expect(container.querySelector(".cm-commit-type-wip")?.textContent).toBe("WIP:");
80 render("feat: x\nfeat: body is not a title");
81 expect(container.querySelectorAll(".cm-commit-type-add")).toHaveLength(1);
82 });
83
84 it("shows each Assisted-by trailer once, as an atomic provider chip", () => {
85 const value = "feat: x\nbody\n\nAssisted-by: claude-opus-5.5\nAssisted-by: mystery-1";
86 const view = render(value);
87
88 const chips = [...container.querySelectorAll(".cm-commit-trailer")];
89 expect(chips.map((chip) => chip.textContent)).toEqual(["claude-opus-5.5", "mystery-1"]);
90 expect(chips.every((chip) => chip.querySelector("svg"))).toBe(true);
91 expect(container.querySelector(".cm-content")?.textContent).not.toContain("Assisted-by");
92 expect(view.state.doc.toString()).toBe(value);
93
94 const trailer = view.state.doc.line(4);
95 act(() => view.dispatch({ selection: { anchor: trailer.from } }));
96 act(() => void cursorCharRight(view));
97 expect(view.state.selection.main.head).toBe(trailer.to);
98 act(() => void cursorCharLeft(view));
99 expect(view.state.selection.main.head).toBe(trailer.from);
100 });
101
102 it("adopts external values without echoing onChange and keeps the caret", () => {
103 const view = render("title\nbody");
104 act(() => view.dispatch({ selection: { anchor: "title\nbo".length } }));
105
106 render("new title\nbody");
107 expect(view.state.doc.toString()).toBe("new title\nbody");
108 expect(view.state.selection.main.head).toBe("new title\nbo".length);
109 expect(change).not.toHaveBeenCalled();
110 });
111
112 it("is read-only when asked", () => {
113 const view = render("title", true);
114 expect(view.state.readOnly).toBe(true);
115 expect(view.contentDOM.getAttribute("contenteditable")).toBe("false");
116
117 render("title", false);
118 expect(view.state.readOnly).toBe(false);
119 });
120});
apps/web/src/components/review/CommitMessageEditor.tsx created+294
...@@ -0,0 +1,294 @@
1import {
2 chipField,
3 createMarkdownEditor,
4 livePreview,
5 type MarkdownEditor,
6} from "@clo/markdown-editor";
7import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
8import { Compartment, EditorState, Prec, type Range, Transaction } from "@codemirror/state";
9import {
10 Decoration,
11 EditorView,
12 placeholder as placeholderText,
13 WidgetType,
14} from "@codemirror/view";
15import { BotIcon } from "lucide-react";
16import { useEffect, useLayoutEffect, useRef, useState } from "react";
17import { createPortal } from "react-dom";
18
19import { ClaudeAI, OpenAI } from "../Icons";
20import { ISSUE_PREFIX, parseCommitTitle } from "./commitTitle";
21
22/** Line 1 is always the title: no heading, list, or setext reading of its content. */
23const commitMarkdown = Prec.highest(
24 markdown({
25 base: markdownLanguage,
26 addKeymap: false,
27 pasteURLAsLink: false,
28 completeHTMLTags: false,
29 extensions: {
30 defineNodes: [{ name: "CommitTitle", block: true }],
31 parseBlock: [
32 {
33 name: "CommitTitle",
34 before: "LinkReference",
35 parse(cx, line) {
36 if (cx.lineStart !== 0) return false;
37 const from = cx.lineStart + line.pos;
38 const to = cx.lineStart + line.text.length;
39 cx.nextLine();
40 cx.addElement(cx.elt("CommitTitle", from, to));
41 return true;
42 },
43 },
44 ],
45 },
46 }),
47);
48
49function titleDecorations(state: EditorState): Range<Decoration>[] {
50 const title = state.doc.line(1);
51 const ranges = [Decoration.line({ class: "cm-commit-title" }).range(title.from)];
52 const parsed = parseCommitTitle(title.text);
53 if (parsed.issues.length > 0) {
54 const issuesEnd = title.from + (ISSUE_PREFIX.exec(title.text)?.[0].trimEnd().length ?? 0);
55 ranges.push(Decoration.mark({ class: "cm-commit-issues" }).range(title.from, issuesEnd));
56 }
57 if (!parsed.prefix) return ranges;
58 ranges.push(
59 Decoration.mark({ class: `cm-commit-type-${parsed.prefix.tone}` }).range(
60 title.from + parsed.prefix.from,
61 title.from + parsed.prefix.to,
62 ),
63 );
64 if (parsed.scope) {
65 ranges.push(
66 Decoration.mark({ class: "cm-commit-scope" }).range(
67 title.from + parsed.scope.from,
68 title.from + parsed.scope.to,
69 ),
70 );
71 }
72 return ranges;
73}
74
75const ASSISTED_BY = /^Assisted-by:[ \t]*(\S(?:.*\S)?)[ \t]*$/i;
76
77function assistedByTrailers(state: EditorState) {
78 const trailers: { from: number; to: number; model: string }[] = [];
79 for (let number = 2; number <= state.doc.lines; number++) {
80 const line = state.doc.line(number);
81 const model = ASSISTED_BY.exec(line.text)?.[1];
82 if (model) trailers.push({ from: line.from, to: line.to, model });
83 }
84 return trailers;
85}
86
87interface TrailerSlot {
88 readonly key: number;
89 readonly dom: HTMLElement;
90 readonly model: string;
91}
92
93/** Hands its icon slot to React, which portals the provider glyph in. */
94class AssistedByWidget extends WidgetType {
95 readonly model: string;
96 readonly slots: {
97 readonly add: (dom: HTMLElement, model: string) => void;
98 readonly remove: (dom: HTMLElement) => void;
99 };
100
101 constructor(model: string, slots: AssistedByWidget["slots"]) {
102 super();
103 this.model = model;
104 this.slots = slots;
105 }
106
107 override eq(other: AssistedByWidget): boolean {
108 return other.model === this.model;
109 }
110
111 override toDOM(): HTMLElement {
112 const chip = document.createElement("span");
113 chip.className = "cm-commit-trailer";
114 chip.title = `Assisted-by: ${this.model}`;
115 const icon = document.createElement("span");
116 icon.className = "cm-commit-trailer-icon";
117 chip.append(icon, this.model);
118 this.slots.add(icon, this.model);
119 return chip;
120 }
121
122 override destroy(dom: HTMLElement): void {
123 if (dom.firstElementChild instanceof HTMLElement) this.slots.remove(dom.firstElementChild);
124 }
125}
126
127function trailerIcon(model: string) {
128 if (/^claude-/i.test(model)) return ClaudeAI;
129 if (/^(?:gpt-|chatgpt|o\d)/i.test(model)) return OpenAI;
130 return BotIcon;
131}
132
133const commitTheme = Prec.highest(
134 EditorView.theme({
135 "&": {
136 fontSize: "13.5px",
137 color: "var(--foreground)",
138 "--markdown-text": "var(--foreground)",
139 "--markdown-muted": "var(--muted-foreground)",
140 "--markdown-panel": "var(--muted)",
141 "--markdown-accent": "var(--primary)",
142 },
143 "&.cm-focused": { outline: "none" },
144 ".cm-scroller": { fontFamily: "var(--font-sans)", lineHeight: "1.55" },
145 ".cm-content": { padding: "10px 12px" },
146 ".cm-commit-title": {
147 fontSize: "15px",
148 fontWeight: "600",
149 lineHeight: "1.45",
150 paddingBottom: "4px",
151 },
152 ".cm-commit-issues": { color: "var(--muted-foreground)" },
153 ".cm-commit-type-add": { color: "var(--success-foreground)" },
154 ".cm-commit-type-fix": { color: "var(--error-foreground)" },
155 ".cm-commit-type-other": { color: "var(--info-foreground)" },
156 ".cm-commit-scope": { opacity: "0.65" },
157 ".cm-commit-type-wip": {
158 color: "var(--background)",
159 backgroundColor: "var(--warning)",
160 borderRadius: "3px",
161 boxDecorationBreak: "clone",
162 },
163 ".cm-commit-trailer": {
164 display: "inline-flex",
165 alignItems: "center",
166 gap: "4px",
167 fontSize: "12px",
168 color: "var(--muted-foreground)",
169 verticalAlign: "baseline",
170 },
171 ".cm-commit-trailer-icon": { display: "inline-flex", width: "12px", height: "12px" },
172 ".cm-lp-code, .cm-lp-codeblock": {
173 fontFamily: 'var(--chat-code-font, "AT Name Mono"), "T3 Markdown Symbols", var(--font-mono)',
174 fontVariationSettings: '"CASL" 0.15, "MONO" 1',
175 fontSize: "var(--font-size-code, 13px)",
176 },
177 }),
178);
179
180/** Edits a commit message in editor form: title on line 1, body from line 2. */
181export function CommitMessageEditor(props: {
182 /** Editor form: title on line 1, body from line 2 (no blank separator). */
183 value: string;
184 readOnly: boolean;
185 onChange: (value: string) => void;
186 placeholder?: string;
187}) {
188 const host = useRef<HTMLDivElement>(null);
189 const editor = useRef<MarkdownEditor>(null);
190 const latest = useRef(props);
191 const [editing] = useState(() => new Compartment());
192 const [trailerSlots, setTrailerSlots] = useState<readonly TrailerSlot[]>([]);
193
194 useLayoutEffect(() => {
195 latest.current = props;
196 });
197 // oxlint-disable react/exhaustive-effect-dependencies -- One view per mount; the effects below keep it controlled.
198 useLayoutEffect(() => {
199 let nextKey = 0;
200 const slots = {
201 add: (dom: HTMLElement, model: string) => {
202 const key = nextKey++;
203 setTrailerSlots((current) => [...current, { key, dom, model }]);
204 },
205 remove: (dom: HTMLElement) =>
206 setTrailerSlots((current) => current.filter((slot) => slot.dom !== dom)),
207 };
208 const instance = createMarkdownEditor({
209 preset: "document",
210 mentions: false,
211 preview: false,
212 doc: latest.current.value,
213 onChange: (text) => {
214 if (text !== latest.current.value && !latest.current.readOnly)
215 latest.current.onChange(text);
216 },
217 extensions: [
218 commitMarkdown,
219 livePreview({
220 blockWidgets: true,
221 exclude: assistedByTrailers,
222 }),
223 chipField({ chips: titleDecorations }),
224 chipField({
225 atomic: true,
226 chips: (state) =>
227 assistedByTrailers(state).map(({ from, to, model }) =>
228 Decoration.replace({ widget: new AssistedByWidget(model, slots) }).range(from, to),
229 ),
230 }),
231 editing.of([]),
232 commitTheme,
233 ],
234 });
235 editor.current = instance;
236 host.current?.append(instance.dom);
237 return () => {
238 editor.current = null;
239 instance.destroy();
240 };
241 }, []);
242 // oxlint-enable react/exhaustive-effect-dependencies
243
244 useEffect(() => {
245 editor.current?.view.dispatch({
246 effects: editing.reconfigure([
247 EditorState.readOnly.of(props.readOnly),
248 EditorView.editable.of(!props.readOnly),
249 EditorView.contentAttributes.of({
250 "aria-label": "Commit message",
251 "aria-readonly": String(props.readOnly),
252 }),
253 props.placeholder === undefined ? [] : placeholderText(props.placeholder),
254 ]),
255 });
256 }, [editing, props.placeholder, props.readOnly]);
257
258 useEffect(() => {
259 const view = editor.current?.view;
260 const current = view?.state.doc.toString();
261 if (!view || current === undefined || current === props.value) return;
262
263 // Replace only the differing middle so the selection maps through the change.
264 const next = props.value;
265 let from = 0;
266 while (from < current.length && from < next.length && current[from] === next[from]) from++;
267 let suffix = 0;
268 while (
269 suffix < current.length - from &&
270 suffix < next.length - from &&
271 current[current.length - 1 - suffix] === next[next.length - 1 - suffix]
272 )
273 suffix++;
274
275 view.dispatch({
276 changes: {
277 from,
278 to: current.length - suffix,
279 insert: next.slice(from, next.length - suffix),
280 },
281 annotations: Transaction.addToHistory.of(false),
282 });
283 }, [props.value]);
284
285 return (
286 <>
287 <div ref={host} data-commit-message-editor />
288 {trailerSlots.map(({ key, dom, model }) => {
289 const Glyph = trailerIcon(model);
290 return createPortal(<Glyph className="size-3 shrink-0" aria-hidden />, dom, String(key));
291 })}
292 </>
293 );
294}
apps/web/src/components/review/DiffSurface.tsx created+57
...@@ -0,0 +1,57 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { ScopedThreadRef } from "@t3tools/contracts";
3import type { DraftId } from "~/composerDraftStore";
4import { useParams } from "@tanstack/react-router";
5import * as Atom from "effect/reactivity/Atom";
6import * as AsyncResult from "effect/reactivity/AsyncResult";
7
8import { useProject, useThreadShell } from "~/state/entities";
9import { jjEnvironment } from "~/state/jj";
10import { resolveThreadRouteRef } from "~/threadRoutes";
11
12import DiffPanel from "../DiffPanel";
13import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "../DiffPanelShell";
14import { JjReviewPanel } from "./JjReviewPanel";
15
16const NO_STACK = Atom.make(AsyncResult.success(null));
17
18/** The right panel's diff surface: the jj review panel inside a jj workspace, T3's diff panel otherwise. */
19export default function DiffSurface(props: {
20 mode?: DiffPanelMode;
21 composerDraftTarget: ScopedThreadRef | DraftId;
22 workspaceMutationId: string | null;
23}) {
24 const mode = props.mode ?? "inline";
25 const threadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params) });
26 const thread = useThreadShell(threadRef);
27 const project = useProject(
28 thread?.projectId ? { environmentId: thread.environmentId, projectId: thread.projectId } : null,
29 );
30 const cwd = thread?.worktreePath ?? project?.workspaceRoot;
31 const stack = useAtomValue(
32 thread && cwd
33 ? jjEnvironment.stack({ environmentId: thread.environmentId, input: { cwd } })
34 : NO_STACK,
35 );
36
37 if (stack._tag === "Initial") {
38 return (
39 <DiffPanelShell mode={mode} header={null}>
40 <DiffPanelLoadingState label="Loading changes…" />
41 </DiffPanelShell>
42 );
43 }
44 if (stack._tag === "Success" && stack.value && thread && cwd) {
45 return (
46 <JjReviewPanel
47 mode={mode}
48 environmentId={thread.environmentId}
49 threadRef={threadRef}
50 cwd={cwd}
51 stack={stack.value}
52 composerDraftTarget={props.composerDraftTarget}
53 />
54 );
55 }
56 return <DiffPanel {...props} />;
57}
apps/web/src/components/review/JjReviewPanel.test.tsx created+142
...@@ -0,0 +1,142 @@
1// @vitest-environment jsdom
2import { EnvironmentId, ThreadId, type JjStack } from "@t3tools/contracts";
3import * as AsyncResult from "effect/reactivity/AsyncResult";
4import * as Cause from "effect/Cause";
5import { act, type ReactNode } from "react";
6import { createRoot, type Root } from "react-dom/client";
7import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
8
9import { JjReviewPanel } from "./JjReviewPanel";
10
11const state = vi.hoisted(() => ({
12 result: { _tag: "Initial" } as unknown,
13 diff: vi.fn(() => ({})),
14 refresh: vi.fn(),
15 queue: null as { pullRequests: Array<{ headSha: string; number: number }> } | null,
16}));
17
18vi.mock("@effect/atom-react", () => ({
19 useAtomValue: () => state.result,
20 useAtomRefresh: () => state.refresh,
21}));
22vi.mock("~/state/jj", () => ({ jjEnvironment: { diff: state.diff } }));
23vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
24vi.mock("~/state/entities", () => ({ useThreadShell: () => null }));
25vi.mock("~/state/mergebot", () => ({
26 mergebotEnvironment: {},
27 useMergebotQueue: () => state.queue,
28 useMergebotPullRequestStatus: () => null,
29}));
30vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
31vi.mock("~/hooks/useSettings", () => ({
32 useClientSettings: () => ({ diffLayout: "split", wordWrap: false }),
33 useUpdateClientSettings: () => vi.fn(),
34}));
35vi.mock("~/hooks/useLocalStorage", () => ({ useLocalStorage: () => [false, vi.fn()] }));
36vi.mock("~/hooks/usePullRequestLinking", () => ({ usePullRequestLinking: () => ({}) }));
37vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn() }));
38vi.mock("../DiffPanelShell", () => ({
39 DiffPanelShell: ({ header, children }: { header: ReactNode; children: ReactNode }) => (
40 <>
41 {header}
42 {children}
43 </>
44 ),
45 DiffPanelLoadingState: () => <div role="status">Loading changes</div>,
46}));
47vi.mock("../diffs/AnnotatableCodeView", () => ({
48 AnnotatableCodeView: () => <div data-code-view />,
49}));
50vi.mock("./CommitMessageEditor", () => ({ CommitMessageEditor: () => null }));
51vi.mock("./JjStackViews", () => ({ StackRail: () => null, StackStrip: () => null }));
52
53let container: HTMLDivElement;
54let root: Root;
55const environmentId = EnvironmentId.make("review-test");
56const stack: JjStack = {
57 workspaceRoot: "/workspace",
58 repository: null,
59 trunk: null,
60 changes: [],
61};
62
63beforeEach(() => {
64 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
65 state.queue = null;
66 state.diff.mockClear();
67 state.refresh.mockClear();
68 container = document.createElement("div");
69 document.body.append(container);
70 root = createRoot(container);
71});
72
73afterEach(() => {
74 act(() => root.unmount());
75 container.remove();
76 vi.unstubAllGlobals();
77});
78
79function render(value: JjStack) {
80 act(() =>
81 root.render(
82 <JjReviewPanel
83 mode="embedded"
84 environmentId={environmentId}
85 threadRef={null}
86 cwd="/workspace"
87 stack={value}
88 composerDraftTarget={{ environmentId, threadId: ThreadId.make("thread-test") }}
89 />,
90 ),
91 );
92}
93
94it("shows an empty stack without requesting a diff for invalid commit IDs", () => {
95 state.result = AsyncResult.success(null);
96 render(stack);
97 expect(state.diff).not.toHaveBeenCalled();
98 expect(container.textContent).toContain("No changes off trunk yet.");
99});
100
101const change = {
102 changeId: "change",
103 shortId: "chan",
104 commitId: "commit",
105 parentCommitIds: ["parent"],
106 description: "feat: review",
107 trailers: [],
108 bookmarks: [],
109 immutable: false,
110 conflict: false,
111 empty: false,
112 workingCopy: false,
113 additions: 1,
114 deletions: 0,
115 authoredAt: "2026-10-15T12:00:00Z",
116};
117
118it("shows a failed diff and lets the user retry the selected range", () => {
119 state.result = AsyncResult.failure(Cause.fail(new Error("Connection lost")));
120 render({
121 ...stack,
122 changes: [change],
123 });
124 expect(container.querySelector('[role="alert"]')?.textContent).toContain(
125 "Couldn’t load changes.",
126 );
127 expect(container.querySelector("[data-code-view]")).toBeNull();
128 const retry = [...container.querySelectorAll<HTMLButtonElement>("button")].find(
129 (button) => button.textContent === "Try again",
130 );
131 expect(retry).toBeDefined();
132 act(() => retry!.click());
133 expect(state.refresh).toHaveBeenCalledOnce();
134});
135
136it("does not offer to create a duplicate PR while an existing PR's live status loads", () => {
137 state.result = AsyncResult.success(null);
138 state.queue = { pullRequests: [{ headSha: change.commitId, number: 42 }] };
139 render({ ...stack, repository: "acme/project", changes: [change] });
140 expect(container.querySelector('[aria-label="More ways to open a PR"]')).toBeNull();
141 expect(container.textContent).not.toContain("Auto-merge");
142});
apps/web/src/components/review/JjReviewPanel.tsx created+644
...@@ -0,0 +1,644 @@
1import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
2import {
3 commitMessageToEditor,
4 editorToCommitMessage,
5 type EnvironmentId,
6 type JjChange,
7 type JjStack,
8 type MergebotPullRequestLiveStatus,
9 type ScopedThreadRef,
10} from "@t3tools/contracts";
11import { mergebotStatusPill } from "@t3tools/client-runtime/mergebot-status-pill";
12import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
13import {
14 ArrowUpIcon,
15 ChevronDownIcon,
16 Columns2Icon,
17 ExternalLinkIcon,
18 Rows2Icon,
19} from "lucide-react";
20import {
21 useCallback,
22 useEffect,
23 useLayoutEffect,
24 useMemo,
25 useRef,
26 useState,
27 type ReactNode,
28} from "react";
29
30import type { DraftId } from "~/composerDraftStore";
31import { useTheme } from "~/hooks/useTheme";
32import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
33import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink";
34import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";
35import {
36 buildFileDiffIdentityKey,
37 getRenderablePatch,
38 resolveDiffThemeName,
39 resolveFileDiffPath,
40} from "~/lib/diffRendering";
41import { useLocalStorage } from "~/hooks/useLocalStorage";
42import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
43import { useThreadShell } from "~/state/entities";
44import { jjEnvironment } from "~/state/jj";
45import {
46 mergebotEnvironment,
47 useMergebotPullRequestStatus,
48 useMergebotQueue,
49} from "~/state/mergebot";
50import { useAtomCommand } from "~/state/use-atom-command";
51import * as Schema from "effect/Schema";
52import * as Atom from "effect/reactivity/Atom";
53import * as AsyncResult from "effect/reactivity/AsyncResult";
54
55import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "../DiffPanelShell";
56import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
57import { AnnotatableCodeView } from "../diffs/AnnotatableCodeView";
58import { Button } from "../ui/button";
59import { Group, GroupSeparator } from "../ui/group";
60import { Menu, MenuItem, MenuItemLabel, MenuPopup, MenuTrigger } from "../ui/menu";
61import { CommitMessageEditor } from "./CommitMessageEditor";
62import { CommitTitle } from "./commitTitle";
63import { MergebotStatusPill } from "./MergebotStatusPill";
64import { StackRail, StackStrip } from "./JjStackViews";
65import {
66 changeTitle,
67 clampSelection,
68 newestPullRequestIndex,
69 pullRequestFor,
70 pullRequestText,
71 selectChange,
72 selectionRange,
73 visibleChanges,
74 type Selection,
75} from "./jjReview.logic";
76
77const SAVE_DELAY_MS = 800;
78const NO_DIFF = Atom.make(AsyncResult.initial());
79
80export function JjReviewPanel(props: {
81 mode: DiffPanelMode;
82 environmentId: EnvironmentId;
83 threadRef: ScopedThreadRef | null;
84 cwd: string;
85 stack: JjStack;
86 composerDraftTarget: ScopedThreadRef | DraftId;
87}) {
88 const { environmentId, cwd, stack } = props;
89 const { resolvedTheme } = useTheme();
90 const settings = useClientSettings();
91 const updateSettings = useUpdateClientSettings();
92 const [railOpen, setRailOpen] = useLocalStorage("t3.jjReview.railOpen", false, Schema.Boolean);
93 const changes = useMemo(() => visibleChanges(stack), [stack]);
94 const [rawSelection, setSelection] = useState<Selection>([0, 0]);
95 const [anchor, setAnchor] = useState(0);
96 const selection = clampSelection(rawSelection, changes.length);
97
98 const snapshot = useAtomCommand(jjEnvironment.snapshot, { reportFailure: false });
99 useEffect(() => {
100 // Reads never snapshot; opening or refocusing the panel picks up the agent's latest edits.
101 const refresh = () => void snapshot({ environmentId, input: { cwd } });
102 refresh();
103 window.addEventListener("focus", refresh);
104 return () => window.removeEventListener("focus", refresh);
105 }, [cwd, environmentId, snapshot]);
106
107 const repository = stack.repository;
108 const queue = useMergebotQueue(environmentId, repository ?? "");
109 const thread = useThreadShell(props.threadRef);
110 // Open PRs come from Mergebot's queue by head commit; a merged or closed PR this thread opened
111 // drops out of the queue, so its link still claims the change its bookmark points at.
112 const prByHeadSha = useMemo(() => {
113 const map = new Map(
114 (repository && queue?.pullRequests ? queue.pullRequests : []).map(
115 (pr) => [pr.headSha, pr.number] as const,
116 ),
117 );
118 const linked = thread ? resolveThreadCurrentPullRequestLink(thread.pullRequests ?? []) : null;
119 const head = linked?.snapshot?.headBranch;
120 const claimed = head && changes.find((change) => change.bookmarks.some((b) => b.name === head));
121 if (linked && claimed && ![...map.values()].includes(linked.number)) {
122 map.set(claimed.commitId, linked.number);
123 }
124 return map;
125 }, [changes, queue, repository, thread]);
126 const headIndex = newestPullRequestIndex(changes, prByHeadSha);
127 const pullRequestNumber =
128 headIndex >= 0 ? (prByHeadSha.get(changes[headIndex]!.commitId) ?? null) : null;
129 const pendingCount = headIndex > 0 ? headIndex : 0;
130
131 const range = selectionRange(changes, selection);
132 const diffAtom = range
133 ? jjEnvironment.diff({ environmentId, input: { cwd, ...range } })
134 : NO_DIFF;
135 const diffResult = useAtomValue(diffAtom);
136 const refreshDiff = useAtomRefresh(diffAtom);
137 const diff = diffResult._tag === "Success" ? diffResult.value : null;
138 const renderable = useMemo(
139 () => getRenderablePatch(diff?.diff, `jj:${diff?.diffHash ?? ""}`),
140 [diff?.diff, diff?.diffHash],
141 );
142 const files = useMemo(
143 () =>
144 renderable?.kind === "files"
145 ? renderable.files.map((fileDiff) => ({
146 fileDiff,
147 filePath: resolveFileDiffPath(fileDiff),
148 fileKey: buildFileDiffIdentityKey(fileDiff),
149 fileVersion: 0,
150 collapsed: false,
151 }))
152 : [],
153 [renderable],
154 );
155
156 const select = (index: number, extend: boolean) => {
157 setSelection(selectChange(anchor, index, extend));
158 if (!extend) setAnchor(index);
159 };
160 const selectAll = () => {
161 setSelection([0, changes.length - 1]);
162 setAnchor(0);
163 };
164
165 const selectedPullRequest = (() => {
166 const numbers = new Set<number | null>();
167 for (let index = selection[0]; index <= Math.min(selection[1], changes.length - 1); index++) {
168 numbers.add(pullRequestFor(changes, prByHeadSha, index));
169 }
170 const [only] = numbers;
171 return numbers.size === 1 && only != null ? only : null;
172 })();
173 const selectedChange = selection[0] === selection[1] ? changes[selection[0]] : undefined;
174
175 const render = (status: MergebotPullRequestLiveStatus | null) => {
176 const pill = status ? mergebotStatusPill(status) : null;
177 const header = (
178 <ReviewHeader
179 environmentId={environmentId}
180 threadRef={props.threadRef}
181 cwd={cwd}
182 repository={repository}
183 changes={changes}
184 pendingCount={pendingCount}
185 pullRequestNumber={pullRequestNumber}
186 status={status}
187 pill={pill}
188 diffLayout={settings.diffLayout}
189 onToggleLayout={() =>
190 updateSettings({ diffLayout: settings.diffLayout === "split" ? "stacked" : "split" })
191 }
192 />
193 );
194
195 const editor =
196 selectedPullRequest !== null && status && selectedPullRequest === status.number ? (
197 <PullRequestMessage
198 key={`pr:${status.number}`}
199 environmentId={environmentId}
200 repository={repository!}
201 number={status.number}
202 title={status.title}
203 body={status.body ?? ""}
204 />
205 ) : selectedChange ? (
206 <ChangeMessage
207 key={selectedChange.changeId}
208 environmentId={environmentId}
209 cwd={cwd}
210 change={selectedChange}
211 />
212 ) : (
213 <RangeSummary
214 changes={changes.slice(selection[0], selection[1] + 1)}
215 opensPullRequest={headIndex < 0}
216 />
217 );
218
219 if (changes.length === 0) {
220 return (
221 <DiffPanelShell mode={props.mode} header={header}>
222 <div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
223 No changes off {stack.trunk?.name ?? "trunk"} yet.
224 </div>
225 </DiffPanelShell>
226 );
227 }
228
229 return (
230 <DiffPanelShell mode={props.mode} header={header}>
231 <div className="flex min-h-0 flex-1">
232 {railOpen ? (
233 <StackRail
234 stack={stack}
235 changes={changes}
236 selection={selection}
237 pendingCount={pendingCount}
238 files={diff?.files ?? []}
239 onSelect={select}
240 onSelectAll={selectAll}
241 onClose={() => setRailOpen(false)}
242 onOpenFile={(path) =>
243 document
244 .querySelector(`[data-jj-review] [data-file-path="${CSS.escape(path)}"]`)
245 ?.scrollIntoView({ block: "start" })
246 }
247 />
248 ) : null}
249 <div data-jj-review className="flex min-h-0 min-w-0 flex-1 flex-col">
250 {railOpen ? null : (
251 <StackStrip
252 stack={stack}
253 changes={changes}
254 selection={selection}
255 onSelect={select}
256 onSelectAll={selectAll}
257 onOpenRail={() => setRailOpen(true)}
258 />
259 )}
260 {diffResult._tag === "Initial" ? (
261 <div className="flex min-h-0 flex-1 flex-col overflow-auto">
262 {editor}
263 <DiffPanelLoadingState label="Loading changes…" />
264 </div>
265 ) : diffResult._tag === "Failure" ? (
266 <div className="flex min-h-0 flex-1 flex-col overflow-auto">
267 {editor}
268 <div role="alert" className="flex flex-col items-center gap-2 p-6">
269 <p className="text-sm text-muted-foreground">Couldn’t load changes.</p>
270 <Button size="sm" variant="outline" onClick={refreshDiff}>
271 Try again
272 </Button>
273 </div>
274 </div>
275 ) : (
276 <AnnotatableCodeView
277 codeViewKey={`jj:${diff?.diffHash ?? "none"}`}
278 className="h-full min-h-0 overflow-auto"
279 files={files}
280 sectionId={`jj:${range?.toCommitId ?? ""}`}
281 sectionTitle={selectedChange ? changeTitle(selectedChange) || "Changes" : "Changes"}
282 composerDraftTarget={props.composerDraftTarget}
283 renderCodeViewHeader={() => editor}
284 renderHeaderFilenameSuffix={() => null}
285 renderHeaderPrefix={() => null}
286 onRevealSearchMatch={() => {}}
287 options={{
288 diffStyle: settings.diffLayout === "split" ? "split" : "unified",
289 lineDiffType: "none",
290 overflow: settings.wordWrap ? "wrap" : "scroll",
291 theme: resolveDiffThemeName(resolvedTheme),
292 preferredHighlighter: PREFERRED_HIGHLIGHTER,
293 themeType: resolvedTheme,
294 stickyHeaders: true,
295 }}
296 />
297 )}
298 </div>
299 </div>
300 </DiffPanelShell>
301 );
302 };
303
304 return pullRequestNumber !== null && repository ? (
305 <PullRequestStatusScope
306 environmentId={environmentId}
307 repository={repository}
308 number={pullRequestNumber}
309 >
310 {render}
311 </PullRequestStatusScope>
312 ) : (
313 render(null)
314 );
315}
316
317function PullRequestStatusScope(props: {
318 environmentId: EnvironmentId;
319 repository: string;
320 number: number;
321 children: (status: MergebotPullRequestLiveStatus | null) => ReactNode;
322}) {
323 return props.children(
324 useMergebotPullRequestStatus(props.environmentId, props.repository, props.number),
325 );
326}
327
328/** A range has no single message to edit; it shows what it spans and what a PR would open as. */
329function RangeSummary(props: { changes: readonly JjChange[]; opensPullRequest: boolean }) {
330 const additions = props.changes.reduce((sum, change) => sum + change.additions, 0);
331 const deletions = props.changes.reduce((sum, change) => sum + change.deletions, 0);
332 const title = pullRequestText(props.changes).title;
333 return (
334 <div className="border-b border-border/60 px-4 pt-3 pb-2.5">
335 <div className="text-xs text-muted-foreground">
336 {props.changes.length} changes ·{" "}
337 <span className="font-mono text-success-foreground">+{additions}</span>{" "}
338 <span className="font-mono text-error-foreground">−{deletions}</span>
339 {props.opensPullRequest && title ? " · opens as" : null}
340 </div>
341 {props.opensPullRequest && title ? (
342 <div className="mt-1 truncate text-sm font-semibold">
343 <CommitTitle title={title} />
344 </div>
345 ) : null}
346 </div>
347 );
348}
349
350/** Saves the latest edit after a pause; flushes on unmount so switching changes keeps the edit. */
351function useDebouncedSave(save: (value: string) => void) {
352 const pending = useRef<string | null>(null);
353 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
354 const saveRef = useRef(save);
355 useLayoutEffect(() => {
356 saveRef.current = save;
357 });
358 const flush = useCallback(() => {
359 if (timer.current) clearTimeout(timer.current);
360 timer.current = null;
361 if (pending.current !== null) saveRef.current(pending.current);
362 pending.current = null;
363 }, []);
364 useEffect(() => flush, [flush]);
365 return useCallback(
366 (value: string) => {
367 pending.current = value;
368 if (timer.current) clearTimeout(timer.current);
369 timer.current = setTimeout(flush, SAVE_DELAY_MS);
370 },
371 [flush],
372 );
373}
374
375function ChangeMessage(props: { environmentId: EnvironmentId; cwd: string; change: JjChange }) {
376 const { change } = props;
377 const describe = useAtomCommand(jjEnvironment.describe, { reportFailure: true });
378 const [value, setValue] = useState(() => commitMessageToEditor(change.description));
379 const loadedCommitId = useRef(change.commitId);
380 const saved = useRef(change.description);
381 useEffect(() => {
382 // Another writer (the agent, a terminal) changed the description: show theirs.
383 if (change.description !== saved.current) {
384 saved.current = change.description;
385 setValue(commitMessageToEditor(change.description));
386 }
387 loadedCommitId.current = change.commitId;
388 }, [change.commitId, change.description]);
389 const save = useDebouncedSave((text) => {
390 const message = editorToCommitMessage(text);
391 if (message === saved.current) return;
392 saved.current = message;
393 void describe({
394 environmentId: props.environmentId,
395 input: {
396 cwd: props.cwd,
397 changeId: change.changeId,
398 expectedCommitId: loadedCommitId.current,
399 message,
400 },
401 });
402 });
403 return (
404 <div className="border-b border-border/60 pb-1">
405 <CommitMessageEditor
406 value={value}
407 readOnly={change.immutable}
408 placeholder="type(scope): what changed"
409 onChange={(next) => {
410 setValue(next);
411 save(next);
412 }}
413 />
414 </div>
415 );
416}
417
418function PullRequestMessage(props: {
419 environmentId: EnvironmentId;
420 repository: string;
421 number: number;
422 title: string;
423 body: string;
424}) {
425 const update = useAtomCommand(mergebotEnvironment.updatePullRequest, { reportFailure: true });
426 const message = props.body ? `${props.title}\n\n${props.body}` : props.title;
427 const [value, setValue] = useState(() => commitMessageToEditor(message));
428 const save = useDebouncedSave((text) => {
429 const [title = "", ...rest] = editorToCommitMessage(text).split("\n");
430 if (!title.trim()) return;
431 void update({
432 environmentId: props.environmentId,
433 input: {
434 repository: props.repository,
435 number: props.number,
436 title,
437 body: rest.join("\n").trim(),
438 },
439 });
440 });
441 return (
442 <div className="border-b border-border/60 pb-1">
443 <CommitMessageEditor
444 value={value}
445 readOnly={false}
446 placeholder="PR title"
447 onChange={(next) => {
448 setValue(next);
449 save(next);
450 }}
451 />
452 </div>
453 );
454}
455
456function ReviewHeader(props: {
457 environmentId: EnvironmentId;
458 threadRef: ScopedThreadRef | null;
459 cwd: string;
460 repository: string | null;
461 changes: readonly JjChange[];
462 pendingCount: number;
463 pullRequestNumber: number | null;
464 status: MergebotPullRequestLiveStatus | null;
465 pill: ReturnType<typeof mergebotStatusPill>;
466 diffLayout: string;
467 onToggleLayout: () => void;
468}) {
469 const { environmentId, repository, changes, status } = props;
470 const push = useAtomCommand(jjEnvironment.push, { reportFailure: true });
471 const openPullRequest = useAtomCommand(mergebotEnvironment.openPullRequest, {
472 reportFailure: true,
473 });
474 const setAutoMerge = useAtomCommand(mergebotEnvironment.setAutoMerge, { reportFailure: true });
475 const updatePullRequest = useAtomCommand(mergebotEnvironment.updatePullRequest, {
476 reportFailure: true,
477 });
478 const linking = usePullRequestLinking(environmentId);
479 const openLink = useOpenChangeRequestLink(props.threadRef ?? undefined);
480 const [busy, setBusy] = useState<string | null>(null);
481
482 const newest = changes[0];
483 const undescribed = changes
484 .slice(0, props.pendingCount || changes.length)
485 .some((change) => !changeTitle(change));
486 const blocked = undescribed ? "Describe or squash the working copy first" : null;
487 const shipping = status && (status.lifecycle !== "open" || status.queueStatus === "merging");
488
489 const run = async (label: string, work: () => Promise<void>) => {
490 setBusy(label);
491 try {
492 await work();
493 } finally {
494 setBusy(null);
495 }
496 };
497
498 const open = (options: { autoMerge: boolean; draft: boolean }) =>
499 run(options.autoMerge ? "auto-merge" : "open", async () => {
500 if (!newest || !repository) return;
501 const text = pullRequestText(changes);
502 const pushed = await push({
503 environmentId,
504 input: {
505 cwd: props.cwd,
506 changeId: newest.changeId,
507 ...(text.baseChangeId ? { nameFrom: text.baseChangeId } : {}),
508 },
509 });
510 if (pushed._tag !== "Success") return;
511 const opened = await openPullRequest({
512 environmentId,
513 input: {
514 repository,
515 head: pushed.value.bookmark,
516 title: text.title,
517 body: text.body,
518 ...options,
519 },
520 });
521 if (opened._tag !== "Success" || !props.threadRef) return;
522 await linking
523 .changeLink(
524 props.threadRef,
525 `https://github.com/${repository}/pull/${opened.value.number}`,
526 true,
527 )
528 .catch(() => {});
529 });
530
531 const pushFollowUps = () =>
532 run("push", async () => {
533 if (!newest || !status) return;
534 await push({
535 environmentId,
536 input: { cwd: props.cwd, changeId: newest.changeId, bookmark: status.headBranch },
537 });
538 });
539
540 const toggleAutoMerge = () =>
541 run("auto-merge", async () => {
542 if (!status || !repository) return;
543 // The queue skips drafts, so asking a draft to auto-merge marks it ready first.
544 if (status.draft) {
545 const ready = await updatePullRequest({
546 environmentId,
547 input: { repository, number: status.number, draft: false },
548 });
549 if (ready._tag !== "Success") return;
550 }
551 await setAutoMerge({
552 environmentId,
553 input: { repository, number: status.number, enabled: !status.autoMerge },
554 });
555 });
556
557 return (
558 <div className="flex min-w-0 flex-1 items-center gap-1.5">
559 {status && props.pill ? (
560 <button
561 type="button"
562 className="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-0.5 hover:bg-accent"
563 onClick={(event) => openLink(event, status.url)}
564 >
565 <MergebotStatusPill pill={props.pill} status={status} />
566 <span className="font-mono text-xs text-muted-foreground">#{status.number}</span>
567 <ExternalLinkIcon className="size-3 text-muted-foreground" />
568 </button>
569 ) : null}
570 <span className="flex-1" />
571 {repository && props.pullRequestNumber === null && newest ? (
572 <Group>
573 <Button
574 size="xs"
575 disabled={busy !== null || blocked !== null}
576 title={blocked ?? undefined}
577 onClick={() => void open({ autoMerge: true, draft: false })}
578 >
579 <PullRequestGlyph.merged />
580 {busy === "auto-merge" ? "Opening…" : "Auto-merge"}
581 </Button>
582 <GroupSeparator />
583 <Menu>
584 <MenuTrigger
585 render={
586 <Button
587 size="icon-xs"
588 aria-label="More ways to open a PR"
589 disabled={busy !== null || blocked !== null}
590 />
591 }
592 >
593 <ChevronDownIcon />
594 </MenuTrigger>
595 <MenuPopup align="end">
596 <MenuItem onClick={() => void open({ autoMerge: false, draft: false })}>
597 <MenuItemLabel>Open PR</MenuItemLabel>
598 </MenuItem>
599 <MenuItem onClick={() => void open({ autoMerge: false, draft: true })}>
600 <MenuItemLabel>Open as draft</MenuItemLabel>
601 </MenuItem>
602 </MenuPopup>
603 </Menu>
604 </Group>
605 ) : null}
606 {status && !shipping && props.pendingCount > 0 ? (
607 <Button
608 size="xs"
609 disabled={busy !== null || blocked !== null}
610 title={blocked ?? undefined}
611 onClick={() => void pushFollowUps()}
612 >
613 <ArrowUpIcon />
614 {busy === "push" ? "Pushing…" : `Push ${props.pendingCount}`}
615 </Button>
616 ) : null}
617 {status && !shipping ? (
618 <Button
619 size="xs"
620 variant={props.pendingCount > 0 || status.autoMerge ? "outline" : "default"}
621 disabled={busy !== null}
622 onClick={() => void toggleAutoMerge()}
623 >
624 {status.autoMerge ? (
625 "Cancel auto-merge"
626 ) : (
627 <>
628 <PullRequestGlyph.merged />
629 Auto-merge
630 </>
631 )}
632 </Button>
633 ) : null}
634 <Button
635 size="icon-xs"
636 variant="ghost"
637 aria-label={props.diffLayout === "split" ? "Unified diff" : "Split diff"}
638 onClick={props.onToggleLayout}
639 >
640 {props.diffLayout === "split" ? <Rows2Icon /> : <Columns2Icon />}
641 </Button>
642 </div>
643 );
644}
apps/web/src/components/review/JjStackViews.tsx created+318
...@@ -0,0 +1,318 @@
1import type { JjChange, JjStack } from "@t3tools/contracts";
2import { LockIcon, PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react";
3import { useLayoutEffect, useRef, type MouseEvent } from "react";
4
5import { cn } from "~/lib/utils";
6import { ClaudeAI, OpenAI } from "../Icons";
7import { Button } from "../ui/button";
8import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
9import { CommitTitle, parseCommitTitle, type CommitTitleTone } from "./commitTitle";
10import { changeTitle, type Selection } from "./jjReview.logic";
11
12const NODE_TONE: Record<CommitTitleTone | "none", string> = {
13 add: "text-success-foreground",
14 fix: "text-error-foreground",
15 other: "text-info-foreground",
16 wip: "text-warning-foreground",
17 none: "text-muted-foreground",
18};
19
20const changeTone = (change: JjChange) =>
21 parseCommitTitle(changeTitle(change)).prefix?.tone ?? "none";
22
23/** jj's own glyphs: `@` for the working copy, a diamond when immutable, a circle otherwise. */
24export function ChangeNode({ change, className }: { change: JjChange; className?: string }) {
25 if (change.workingCopy) {
26 return (
27 <span
28 className={cn(
29 "font-mono text-xs leading-none font-bold text-success-foreground",
30 className,
31 )}
32 >
33 @
34 </span>
35 );
36 }
37 const tone = change.conflict ? "text-error-foreground" : NODE_TONE[changeTone(change)];
38 return (
39 <svg viewBox="0 0 12 12" className={cn("size-3", tone, className)} aria-hidden>
40 {change.immutable ? (
41 <path d="M6 .8 11.2 6 6 11.2.8 6Z" fill="currentColor" />
42 ) : (
43 <circle
44 cx="6"
45 cy="6"
46 r="4.2"
47 fill="var(--background)"
48 stroke="currentColor"
49 strokeWidth="1.6"
50 />
51 )}
52 </svg>
53 );
54}
55
56function TrunkNode() {
57 return (
58 <svg viewBox="0 0 12 12" className="size-3 text-muted-foreground" aria-hidden>
59 <path
60 d="M6 1.2 10.8 6 6 10.8 1.2 6Z"
61 fill="var(--background)"
62 stroke="currentColor"
63 strokeWidth="1.4"
64 />
65 </svg>
66 );
67}
68
69function LineCounts({ additions, deletions }: { additions: number; deletions: number }) {
70 return (
71 <span className="font-mono text-3xs leading-3 tabular-nums">
72 <span className="text-success-foreground">+{additions}</span>{" "}
73 <span className="text-error-foreground">−{deletions}</span>
74 </span>
75 );
76}
77
78export function modelGlyph(model: string) {
79 if (model.startsWith("claude")) return <ClaudeAI className="size-3" aria-hidden />;
80 if (/^(gpt|chatgpt|o\d)/.test(model)) return <OpenAI className="size-3" aria-hidden />;
81 return null;
82}
83
84const inSelection = (selection: Selection, index: number) =>
85 index >= selection[0] && index <= selection[1];
86
87const extendsSelection = (event: MouseEvent) => event.shiftKey || event.metaKey || event.ctrlKey;
88
89/** One icon per change, oldest to newest, trunk first; line counts under each, the newest change named. */
90export function StackStrip(props: {
91 stack: JjStack;
92 changes: readonly JjChange[];
93 selection: Selection;
94 onSelect: (index: number, extend: boolean) => void;
95 onSelectAll: () => void;
96 onOpenRail: () => void;
97}) {
98 const { stack, changes, selection } = props;
99 const scroller = useRef<HTMLDivElement>(null);
100 const newestSelected = selection[0];
101 useLayoutEffect(() => {
102 if (changes.length === 0) return;
103 scroller.current
104 ?.querySelector(`[data-index="${newestSelected}"]`)
105 ?.scrollIntoView({ block: "nearest", inline: "nearest" });
106 }, [changes, newestSelected]);
107 const newest = changes[0];
108 const allSelected = selection[0] === 0 && selection[1] === changes.length - 1;
109
110 return (
111 <div className="flex shrink-0 items-center gap-1 border-b border-border/60 py-1.5 ps-2 pe-1.5">
112 <Button
113 size="icon-xs"
114 variant="ghost"
115 aria-label="Show stack and files"
116 aria-expanded={false}
117 onClick={props.onOpenRail}
118 >
119 <PanelLeftOpenIcon />
120 </Button>
121 <div
122 ref={scroller}
123 className="flex max-w-[70%] shrink-0 items-stretch overflow-x-auto [scrollbar-width:none]"
124 >
125 {stack.trunk ? (
126 <div
127 className="flex shrink-0 flex-col items-center gap-1 px-1.5 py-1"
128 aria-label={stack.trunk.name}
129 >
130 <TrunkNode />
131 <span className="text-3xs leading-3 whitespace-nowrap text-muted-foreground">
132 {stack.trunk.behind > 0 ? `${stack.trunk.behind} behind` : "trunk"}
133 </span>
134 </div>
135 ) : null}
136 {changes
137 .map((change, index) => ({ change, index }))
138 .toReversed()
139 .map(({ change, index }) => {
140 const selected = inSelection(selection, index);
141 return (
142 <Tooltip key={change.changeId}>
143 <TooltipTrigger
144 render={
145 <button
146 type="button"
147 data-index={index}
148 aria-label={changeTitle(change) || "(no description)"}
149 aria-pressed={selected}
150 onClick={(event) => props.onSelect(index, extendsSelection(event))}
151 className={cn(
152 "relative flex shrink-0 flex-col items-center gap-1 px-1.5 py-1 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
153 "before:absolute before:top-[calc(0.25rem+5.5px)] before:right-1/2 before:-left-1/2 before:h-px before:bg-border",
154 selected ? "bg-primary/14" : "rounded-md hover:bg-accent",
155 selected && !inSelection(selection, index + 1) && "rounded-s-md",
156 selected && !inSelection(selection, index - 1) && "rounded-e-md",
157 )}
158 />
159 }
160 >
161 <ChangeNode change={change} className="relative" />
162 <LineCounts additions={change.additions} deletions={change.deletions} />
163 </TooltipTrigger>
164 <TooltipPopup side="bottom">
165 {changeTitle(change) ? (
166 <CommitTitle title={changeTitle(change)} />
167 ) : (
168 "(no description)"
169 )}
170 </TooltipPopup>
171 </Tooltip>
172 );
173 })}
174 </div>
175 {newest ? (
176 <span className="min-w-0 flex-1 truncate px-1.5 text-xs">
177 {changeTitle(newest) ? (
178 <CommitTitle title={changeTitle(newest)} />
179 ) : (
180 <span className="text-muted-foreground italic">(no description)</span>
181 )}
182 </span>
183 ) : null}
184 {changes.length > 1 ? (
185 <Button size="xs" variant={allSelected ? "secondary" : "ghost"} onClick={props.onSelectAll}>
186 All
187 </Button>
188 ) : null}
189 </div>
190 );
191}
192
193/** The vertical stack and the selection's files, shown when the panel has room. */
194export function StackRail(props: {
195 stack: JjStack;
196 changes: readonly JjChange[];
197 selection: Selection;
198 pendingCount: number;
199 files: ReadonlyArray<{ path: string; additions: number; deletions: number }>;
200 onSelect: (index: number, extend: boolean) => void;
201 onSelectAll: () => void;
202 onClose: () => void;
203 onOpenFile: (path: string) => void;
204}) {
205 const { stack, changes, selection } = props;
206 return (
207 <div className="flex w-56 shrink-0 flex-col border-e border-border/60 bg-muted/40">
208 <div className="flex h-9 shrink-0 items-center gap-1.5 ps-3 pe-1.5">
209 <span className="text-2xs font-semibold tracking-wide text-muted-foreground uppercase">
210 Stack
211 </span>
212 <span className="text-2xs text-muted-foreground">{changes.length}</span>
213 <span className="flex-1" />
214 <Button size="xs" variant="outline" onClick={props.onSelectAll}>
215 Review all
216 </Button>
217 <Button
218 size="icon-xs"
219 variant="ghost"
220 aria-label="Hide stack and files"
221 aria-expanded={true}
222 onClick={props.onClose}
223 >
224 <PanelLeftCloseIcon />
225 </Button>
226 </div>
227 <div className="relative shrink-0 pb-1 before:absolute before:top-3 before:bottom-4 before:left-[22px] before:w-px before:bg-border">
228 {changes.map((change, index) => {
229 const selected = inSelection(selection, index);
230 const model = change.trailers.find((t) => t.key === "Assisted-by")?.value;
231 const bookmark = change.bookmarks[0];
232 return (
233 <button
234 key={change.changeId}
235 type="button"
236 aria-pressed={selected}
237 onClick={(event) => props.onSelect(index, extendsSelection(event))}
238 className={cn(
239 "relative mx-1.5 grid w-[calc(100%-0.75rem)] grid-cols-[18px_minmax(0,1fr)] gap-1.5 px-2 py-1.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
240 selected ? "bg-primary/14" : "hover:bg-accent",
241 selected && !inSelection(selection, index - 1) && "rounded-t-md",
242 selected && !inSelection(selection, index + 1) && "rounded-b-md",
243 !selected && "rounded-md",
244 )}
245 >
246 <span className="flex justify-center pt-1">
247 <ChangeNode change={change} className="relative" />
248 </span>
249 <span className="min-w-0">
250 <span className="block truncate text-xs leading-4.5">
251 {changeTitle(change) ? (
252 <CommitTitle title={changeTitle(change)} />
253 ) : (
254 <span className="text-muted-foreground italic">(no description)</span>
255 )}
256 </span>
257 <span className="mt-0.5 flex items-center gap-1.5 overflow-hidden text-2xs whitespace-nowrap text-muted-foreground">
258 <span className="font-mono text-3xs">
259 <b className="font-bold text-foreground">{change.shortId[0]}</b>
260 {change.shortId.slice(1)}
261 </span>
262 <LineCounts additions={change.additions} deletions={change.deletions} />
263 {bookmark ? (
264 <span className="truncate text-success-foreground">{bookmark.name}</span>
265 ) : null}
266 {index < props.pendingCount ? (
267 <span className="text-warning-foreground">not pushed</span>
268 ) : null}
269 {change.immutable ? (
270 <LockIcon className="size-2.5 shrink-0" aria-label="Immutable" />
271 ) : null}
272 {model ? modelGlyph(model) : null}
273 </span>
274 </span>
275 </button>
276 );
277 })}
278 {stack.trunk ? (
279 <div className="mx-1.5 grid grid-cols-[18px_minmax(0,1fr)] items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground">
280 <span className="relative flex justify-center">
281 <TrunkNode />
282 </span>
283 <span className="truncate">
284 <span className="font-mono">{stack.trunk.name}</span>
285 {stack.trunk.behind > 0 ? ` · ${stack.trunk.behind} behind` : ""}
286 </span>
287 </div>
288 ) : null}
289 </div>
290 <div className="flex min-h-0 flex-1 flex-col border-t border-border/60">
291 <div className="flex h-8 shrink-0 items-center gap-1.5 px-3 text-2xs text-muted-foreground">
292 <span className="font-semibold tracking-wide uppercase">Files</span>
293 <span>{props.files.length}</span>
294 </div>
295 <div className="min-h-0 overflow-y-auto px-1.5 pb-2">
296 {props.files.map((file) => {
297 const slash = file.path.lastIndexOf("/");
298 return (
299 <button
300 key={file.path}
301 type="button"
302 aria-label={file.path}
303 onClick={() => props.onOpenFile(file.path)}
304 className="flex h-6 w-full items-center gap-1.5 rounded-md px-1.5 text-left text-xs hover:bg-accent"
305 >
306 <span className="min-w-0 flex-1 truncate">
307 <span className="text-muted-foreground">{file.path.slice(0, slash + 1)}</span>
308 {file.path.slice(slash + 1)}
309 </span>
310 <LineCounts additions={file.additions} deletions={file.deletions} />
311 </button>
312 );
313 })}
314 </div>
315 </div>
316 </div>
317 );
318}
apps/web/src/components/review/MergebotStatusPill.tsx created+119
...@@ -0,0 +1,119 @@
1import type { MergebotPullRequestLiveStatus } from "@t3tools/contracts";
2import type {
3 MergebotStatusPill as Pill,
4 MergebotStatusPillTone,
5} from "@t3tools/client-runtime/mergebot-status-pill";
6import {
7 CircleCheckIcon,
8 CircleDashedIcon,
9 CircleXIcon,
10 FlaskConicalIcon,
11 GlobeIcon,
12 HourglassIcon,
13 RocketIcon,
14 TriangleAlertIcon,
15 type LucideIcon,
16} from "lucide-react";
17
18import { useNowMinute } from "~/hooks/useNowMinute";
19import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
20import { cn } from "~/lib/utils";
21
22const TONE: Record<MergebotStatusPillTone, string> = {
23 yellow: "bg-yellow-500/15 text-yellow-700 dark:text-yellow-400",
24 red: "bg-error/12 text-error-foreground",
25 orange: "bg-orange-500/12 text-orange-700 dark:text-orange-400",
26 blue: "bg-info/12 text-info-foreground",
27 green: "bg-success/12 text-success-foreground",
28 muted: "bg-muted text-muted-foreground",
29};
30
31const ICON: Record<Exclude<Pill["kind"], "running-ci">, LucideIcon> = {
32 rebasing: PullRequestGlyph.pullRequest,
33 "ci-failed": CircleXIcon,
34 "merge-conflicts": TriangleAlertIcon,
35 "draft-passes-ci": CircleDashedIcon,
36 ready: CircleCheckIcon,
37 queued: HourglassIcon,
38 merging: PullRequestGlyph.merged,
39 merged: PullRequestGlyph.merged,
40 deploying: RocketIcon,
41 "deploy-failed": RocketIcon,
42 "on-staging": FlaskConicalIcon,
43 shipped: GlobeIcon,
44};
45
46/** A still arc of finished checks; nothing on the pill repaints on its own. */
47function ChecksProgress({ completed, total }: { completed: number; total: number }) {
48 const radius = 8;
49 const circumference = 2 * Math.PI * radius;
50 const filled = total > 0 ? (completed / total) * circumference : 0;
51 return (
52 <svg viewBox="0 0 24 24" className="size-3 shrink-0" aria-hidden>
53 <circle
54 cx="12"
55 cy="12"
56 r={radius}
57 fill="none"
58 stroke="currentColor"
59 strokeOpacity={0.3}
60 strokeWidth={3}
61 />
62 {filled > 0 ? (
63 <circle
64 cx="12"
65 cy="12"
66 r={radius}
67 fill="none"
68 stroke="currentColor"
69 strokeWidth={3}
70 strokeLinecap="round"
71 strokeDasharray={`${filled} ${circumference}`}
72 transform="rotate(-90 12 12)"
73 />
74 ) : null}
75 </svg>
76 );
77}
78
79function minutesUntil(etaAt: string, now: string) {
80 const minutes = Math.round((Date.parse(etaAt) - Date.parse(now)) / 60_000);
81 return minutes < 1 ? "<1m" : `~${minutes}m`;
82}
83
84export function MergebotStatusPill({
85 pill,
86 status,
87 className,
88}: {
89 pill: Pill;
90 status: MergebotPullRequestLiveStatus;
91 className?: string;
92}) {
93 const now = useNowMinute();
94 const Icon = pill.kind === "running-ci" ? null : ICON[pill.kind];
95 return (
96 <span
97 className={cn(
98 "inline-flex min-w-0 shrink-0 items-center gap-1 rounded-full py-px ps-1.5 pe-2 text-xs leading-4 font-medium whitespace-nowrap",
99 TONE[pill.tone],
100 className,
101 )}
102 >
103 {Icon ? (
104 <Icon
105 className={cn("size-3 shrink-0", pill.kind === "shipped" && "text-success-foreground")}
106 />
107 ) : (
108 <ChecksProgress
109 completed={status.checks?.completed ?? 0}
110 total={status.checks?.total ?? 0}
111 />
112 )}
113 <span className="truncate">{pill.label}</span>
114 {pill.etaAt ? (
115 <span className="font-normal tabular-nums opacity-60">{minutesUntil(pill.etaAt, now)}</span>
116 ) : null}
117 </span>
118 );
119}
apps/web/src/components/review/ThreadMergebotPill.tsx created+27
...@@ -0,0 +1,27 @@
1import { mergebotStatusPill } from "@t3tools/client-runtime/mergebot-status-pill";
2import { isMergebotRepository, type EnvironmentId } from "@t3tools/contracts";
3
4import { useServerConfigs } from "~/state/entities";
5import { useMergebotPullRequestStatus } from "~/state/mergebot";
6
7import { MergebotStatusPill } from "./MergebotStatusPill";
8
9/** True when the environment routes this repository's PRs through Mergebot. */
10export function useIsMergebotRepository(
11 environmentId: EnvironmentId,
12 repository: string | undefined,
13) {
14 const configuration = useServerConfigs().get(environmentId)?.mergebot;
15 return repository !== undefined && isMergebotRepository(configuration, repository);
16}
17
18/** The row-3 status pill for a thread's current PR. */
19export function ThreadMergebotPill(props: {
20 environmentId: EnvironmentId;
21 repository: string;
22 number: number;
23}) {
24 const status = useMergebotPullRequestStatus(props.environmentId, props.repository, props.number);
25 const pill = status ? mergebotStatusPill(status) : null;
26 return status && pill ? <MergebotStatusPill pill={pill} status={status} /> : null;
27}
apps/web/src/components/review/commitTitle.test.ts created+27
...@@ -0,0 +1,27 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { parseCommitTitle } from "./commitTitle";
4
5describe("parseCommitTitle", () => {
6 it("finds issue keys, the conventional prefix, and its scope", () => {
7 const title = "[ENG-311, ENG-312] fix(api)!: retry flaky uploads";
8 const parsed = parseCommitTitle(title);
9 expect(parsed.issues).toEqual(["ENG-311", "ENG-312"]);
10 expect(parsed.prefix?.tone).toBe("fix");
11 expect(title.slice(parsed.prefix!.from, parsed.prefix!.to)).toBe("fix(api)!:");
12 expect(title.slice(parsed.scope!.from, parsed.scope!.to)).toBe("(api)");
13 expect(title.slice(parsed.restFrom)).toBe(" retry flaky uploads");
14 });
15
16 it("uses Mergebot's tones: features green, fixes red, anything else blue", () => {
17 expect(parseCommitTitle("infra: cache browsers").prefix?.tone).toBe("add");
18 expect(parseCommitTitle("hotpatch: revert").prefix?.tone).toBe("fix");
19 expect(parseCommitTitle("chore: follow-up changes").prefix?.tone).toBe("other");
20 });
21
22 it("marks WIP without a scope highlight and leaves plain titles alone", () => {
23 expect(parseCommitTitle("WIP(ui): try layouts").prefix?.tone).toBe("wip");
24 expect(parseCommitTitle("WIP(ui): try layouts").scope).toBeNull();
25 expect(parseCommitTitle("Fix the build").prefix).toBeNull();
26 });
27});
apps/web/src/components/review/commitTitle.tsx created+90
...@@ -0,0 +1,90 @@
1import { cn } from "~/lib/utils";
2
3export const ISSUE_PREFIX = /^\[[A-Z][A-Z0-9]*-\d+(?:,\s*[A-Z][A-Z0-9]*-\d+)*\]\s*/;
4export const CONVENTIONAL_TYPE = /^([A-Za-z]+)(\([^()]*\))?!?:/;
5
6export type CommitTitleTone = "add" | "fix" | "other" | "wip";
7
8/** The same tone table as Mergebot's commit-title.ts, so both UIs color a title alike. */
9export function commitTypeTone(type: string): CommitTitleTone {
10 switch (type.toLowerCase()) {
11 case "wip":
12 return "wip";
13 case "feat":
14 case "feature":
15 case "aura":
16 case "infra":
17 return "add";
18 case "fix":
19 case "hotfix":
20 case "hotpatch":
21 return "fix";
22 default:
23 return "other";
24 }
25}
26
27export interface ParsedCommitTitle {
28 readonly issues: readonly string[];
29 /** Offsets into the title; `prefix` covers `type(scope)!:`. */
30 readonly prefix: {
31 readonly from: number;
32 readonly to: number;
33 readonly tone: CommitTitleTone;
34 } | null;
35 readonly scope: { readonly from: number; readonly to: number } | null;
36 readonly restFrom: number;
37}
38
39export function parseCommitTitle(title: string): ParsedCommitTitle {
40 const issueMatch = ISSUE_PREFIX.exec(title);
41 const typeStart = issueMatch ? issueMatch[0].length : 0;
42 const issues = issueMatch ? (issueMatch[0].match(/[A-Z][A-Z0-9]*-\d+/g) ?? []) : [];
43 const conventional = CONVENTIONAL_TYPE.exec(title.slice(typeStart));
44 if (!conventional) return { issues, prefix: null, scope: null, restFrom: typeStart };
45 const [prefix, type = "", scope] = conventional;
46 const tone = commitTypeTone(type);
47 return {
48 issues,
49 prefix: { from: typeStart, to: typeStart + prefix.length, tone },
50 scope:
51 scope && tone !== "wip"
52 ? { from: typeStart + type.length, to: typeStart + type.length + scope.length }
53 : null,
54 restFrom: typeStart + prefix.length,
55 };
56}
57
58const TONE_CLASS: Record<CommitTitleTone, string> = {
59 add: "text-success-foreground",
60 fix: "text-error-foreground",
61 other: "text-info-foreground",
62 wip: "rounded-[3px] bg-warning px-0.5 text-background",
63};
64
65/** A commit title with its issue keys dimmed and its conventional prefix colored. */
66export function CommitTitle({ title, className }: { title: string; className?: string }) {
67 const parsed = parseCommitTitle(title);
68 const { prefix, scope } = parsed;
69 return (
70 <span className={cn("truncate", className)}>
71 {parsed.issues.length > 0 ? (
72 <span className="me-1 text-muted-foreground">{parsed.issues.join(", ")}</span>
73 ) : null}
74 {prefix ? (
75 <span className={TONE_CLASS[prefix.tone]}>
76 {scope ? (
77 <>
78 {title.slice(prefix.from, scope.from)}
79 <span className="opacity-65">{title.slice(scope.from, scope.to)}</span>
80 {title.slice(scope.to, prefix.to)}
81 </>
82 ) : (
83 title.slice(prefix.from, prefix.to)
84 )}
85 </span>
86 ) : null}
87 {title.slice(parsed.restFrom)}
88 </span>
89 );
90}
apps/web/src/components/review/jjReview.logic.test.ts created+96
...@@ -0,0 +1,96 @@
1import type { JjChange } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 clampSelection,
6 newestPullRequestIndex,
7 pullRequestFor,
8 pullRequestText,
9 selectChange,
10 selectionRange,
11 visibleChanges,
12} from "./jjReview.logic";
13
14const change = (id: string, description: string, overrides: Partial<JjChange> = {}): JjChange => ({
15 changeId: id,
16 shortId: id.slice(0, 4),
17 commitId: `${id}-commit`,
18 parentCommitIds: [`${id}-parent`],
19 description,
20 trailers: [...description.matchAll(/^Assisted-by: (.+)$/gm)].map((m) => ({
21 key: "Assisted-by",
22 value: m[1]!,
23 })),
24 bookmarks: [],
25 immutable: false,
26 conflict: false,
27 empty: false,
28 workingCopy: false,
29 additions: 1,
30 deletions: 0,
31 authoredAt: "2026-10-15T12:00:00-07:00",
32 ...overrides,
33});
34
35// Newest first, like the stack.
36const followUp = change("follow", "chore: follow-up changes\n\nAssisted-by: gpt-6.1-sol");
37const feature = change(
38 "feature",
39 "feat(reader): keep scroll\n\nRestore offsets per tab.\n\nAssisted-by: claude-opus-5.5",
40);
41const fix = change("fixit", "fix(api): retry uploads");
42
43describe("selection", () => {
44 it("selects one change, or extends a contiguous range from the anchor", () => {
45 expect(selectChange(1, 2, false)).toEqual([2, 2]);
46 expect(selectChange(2, 0, true)).toEqual([0, 2]);
47 });
48
49 it("clamps a selection when the stack shrinks", () => {
50 expect(clampSelection([2, 3], 2)).toEqual([1, 1]);
51 expect(clampSelection([0, 0], 0)).toEqual([0, 0]);
52 });
53
54 it("diffs a range from the oldest change's parent to the newest change", () => {
55 expect(selectionRange([followUp, feature], [0, 1])).toEqual({
56 fromCommitId: "feature-parent",
57 toCommitId: "follow-commit",
58 });
59 expect(
60 selectionRange([change("root", "x", { parentCommitIds: [] })], [0, 0])?.fromCommitId,
61 ).toBe("0".repeat(40));
62 });
63
64 it("hides an empty working copy but keeps one with changes", () => {
65 const stack = { workspaceRoot: "/w", repository: null, trunk: null };
66 expect(
67 visibleChanges({
68 ...stack,
69 changes: [change("wc", "", { workingCopy: true, empty: true }), feature],
70 }),
71 ).toEqual([feature]);
72 expect(
73 visibleChanges({ ...stack, changes: [change("wc", "", { workingCopy: true }), feature] }),
74 ).toHaveLength(2);
75 });
76});
77
78describe("pull requests", () => {
79 const prs = new Map([["feature-commit", 80]]);
80
81 it("puts a change in the PR of the nearest head at or above it", () => {
82 const changes = [followUp, feature, fix];
83 expect(pullRequestFor(changes, prs, 2)).toBe(80);
84 expect(pullRequestFor(changes, prs, 1)).toBe(80);
85 expect(pullRequestFor(changes, prs, 0)).toBeNull();
86 expect(newestPullRequestIndex(changes, prs)).toBe(1);
87 });
88
89 it("titles a PR after its oldest non-follow-up change and credits every model", () => {
90 expect(pullRequestText([followUp, feature])).toEqual({
91 title: "feat(reader): keep scroll",
92 body: "Restore offsets per tab.\n\nAssisted-by: gpt-6.1-sol\nAssisted-by: claude-opus-5.5",
93 baseChangeId: "feature",
94 });
95 });
96});
apps/web/src/components/review/jjReview.logic.ts created+89
...@@ -0,0 +1,89 @@
1import type { JjChange, JjStack, VcsStatusResult } from "@t3tools/contracts";
2
3export function supportsWorkspaceChanges(
4 status: Pick<VcsStatusResult, "isRepo" | "isJjWorkspace">,
5) {
6 return status.isRepo || status.isJjWorkspace === true;
7}
8
9export const FOLLOW_UP_TITLE = "chore: follow-up changes";
10/** jj's root commit, the parent of a change with no parents. */
11const ROOT_COMMIT_ID = "0".repeat(40);
12
13export const changeTitle = (change: JjChange) => change.description.split("\n", 1)[0] ?? "";
14
15/** The changes the panel lists, newest first: the stack without an empty working copy. */
16export function visibleChanges(stack: JjStack): JjChange[] {
17 return stack.changes.filter((change) => !(change.workingCopy && change.empty));
18}
19
20/** Inclusive index range into `visibleChanges`, newest first. */
21export type Selection = readonly [newest: number, oldest: number];
22
23/** Click selects one change; shift- or cmd-click extends a contiguous range from the anchor. */
24export function selectChange(anchor: number, index: number, extend: boolean): Selection {
25 return extend ? [Math.min(anchor, index), Math.max(anchor, index)] : [index, index];
26}
27
28export function clampSelection(selection: Selection, count: number): Selection {
29 if (count === 0) return [0, 0];
30 const newest = Math.min(selection[0], count - 1);
31 return [newest, Math.min(Math.max(selection[1], newest), count - 1)];
32}
33
34/** From the oldest selected change's first parent to the newest selected change. */
35export function selectionRange(changes: readonly JjChange[], selection: Selection) {
36 const newest = changes[selection[0]];
37 const oldest = changes[selection[1]];
38 if (!newest || !oldest) return null;
39 return {
40 fromCommitId: oldest.parentCommitIds[0] ?? ROOT_COMMIT_ID,
41 toCommitId: newest.commitId,
42 };
43}
44
45/** The PR a change ships in: the nearest change at or above it whose commit heads a PR. */
46export function pullRequestFor(
47 changes: readonly JjChange[],
48 prByHeadSha: ReadonlyMap<string, number>,
49 index: number,
50): number | null {
51 for (let i = Math.min(index, changes.length - 1); i >= 0; i--) {
52 const number = prByHeadSha.get(changes[i]!.commitId);
53 if (number !== undefined) return number;
54 }
55 return null;
56}
57
58/** Index of the newest change heading a PR, or -1. Changes above it are not on the PR yet. */
59export function newestPullRequestIndex(
60 changes: readonly JjChange[],
61 prByHeadSha: ReadonlyMap<string, number>,
62): number {
63 return changes.findIndex((change) => prByHeadSha.has(change.commitId));
64}
65
66/** PR text: the oldest change that is not a follow-up, with every change's Assisted-by trailers. */
67export function pullRequestText(changes: readonly JjChange[]) {
68 const base =
69 changes.findLast((change) => changeTitle(change) && changeTitle(change) !== FOLLOW_UP_TITLE) ??
70 changes[0];
71 const models = [
72 ...new Set(
73 changes.flatMap((change) =>
74 change.trailers.filter((t) => t.key === "Assisted-by").map((t) => t.value),
75 ),
76 ),
77 ];
78 const [title = "", ...rest] = (base?.description ?? "").split("\n");
79 const prose = rest
80 .join("\n")
81 .replace(/(^|\n)Assisted-by: .+/g, "")
82 .trim();
83 const credits = models.map((model) => `Assisted-by: ${model}`).join("\n");
84 return {
85 title,
86 body: [prose, credits].filter(Boolean).join("\n\n"),
87 baseChangeId: base?.changeId,
88 };
89}
apps/web/src/components/sidebar/SidebarChrome.tsx+29-15
...@@ -23,6 +23,7 @@ import { NewWorkWorkspaceButton } from "./NewWorkWorkspaceButton";...@@ -23,6 +23,7 @@ import { NewWorkWorkspaceButton } from "./NewWorkWorkspaceButton";
23import { AppMenu } from "./AppMenu";23import { AppMenu } from "./AppMenu";
24import { SidebarMergebotButton } from "./SidebarMergebotButton";24import { SidebarMergebotButton } from "./SidebarMergebotButton";
25import { SidebarUsageButton } from "./SidebarUsageButton";25import { SidebarUsageButton } from "./SidebarUsageButton";
26import { SidebarDeploymentsButton } from "./SidebarDeploymentsButton";
2627
27export const SidebarChromeHeader = memo(function SidebarChromeHeader({28export const SidebarChromeHeader = memo(function SidebarChromeHeader({
28 isElectron,29 isElectron,
...@@ -38,29 +39,33 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({...@@ -38,29 +39,33 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
3839
39 return (40 return (
40 // The titlebar row, not a padded SidebarHeader: it aligns to the window controls.41 // The titlebar row, not a padded SidebarHeader: it aligns to the window controls.
42 // Its height already spaces the first group below, so it absorbs that group's top inset.
41 <div43 <div
42 className={cn(44 className={cn(
43 "relative flex h-[var(--workspace-topbar-height)] shrink-0 flex-row items-center gap-2 pr-3",45 "relative -mb-(--sidebar-content-inset) flex h-[var(--workspace-topbar-height)] shrink-0 flex-row items-center pr-(--sidebar-content-inset)",
44 isElectron && "drag-region",46 isElectron && "drag-region",
45 )}47 )}
46 >48 >
47 {/* Match the fixed control's inset and border compensation inside the sheet's safe area. */}49 {/* Match the fixed control's top inset inside the sheet's safe area. */}
48 <div className="relative top-[calc(var(--workspace-controls-top)-env(safe-area-inset-top))] z-10 ml-[calc(var(--workspace-controls-left)+1px-env(safe-area-inset-left))] flex shrink-0 items-center md:hidden">50 <div className="relative top-[calc(var(--workspace-controls-top)-env(safe-area-inset-top))] z-10 flex min-w-0 flex-1 items-center gap-2">
49 <div className="flex items-center [-webkit-app-region:no-drag]">51 {/* Match the fixed control's inset and border compensation. */}
50 <AppMenu />52 <div className="ml-[calc(var(--workspace-controls-left)+1px-env(safe-area-inset-left))] flex shrink-0 items-center md:hidden">
53 <div className="flex items-center [-webkit-app-region:no-drag]">
54 <AppMenu />
55 </div>
56 <SidebarTrigger aria-label="Toggle main sidebar" />
51 </div>57 </div>
52 <SidebarTrigger aria-label="Toggle main sidebar" />58 {pillLabel ? (
53 <div className="ml-2">59 <div className="ml-[var(--workspace-titlebar-content-left)] max-md:ml-0 flex h-7 min-w-0 items-center">
60 <Badge data-environment-identification="pill" size="sm" variant="secondary">
61 {pillLabel}
62 </Badge>
63 </div>
64 ) : null}
65 <div className="ml-auto flex shrink-0 items-center [-webkit-app-region:no-drag]">
54 <NewWorkWorkspaceButton />66 <NewWorkWorkspaceButton />
55 </div>67 </div>
56 </div>68 </div>
57 {pillLabel ? (
58 <div className="relative z-10 ml-[calc(var(--workspace-titlebar-content-left)+2.25rem)] max-md:ml-0 flex h-7 min-w-0 items-center">
59 <Badge data-environment-identification="pill" size="sm" variant="secondary">
60 {pillLabel}
61 </Badge>
62 </div>
63 ) : null}
64 </div>69 </div>
65 );70 );
66});71});
...@@ -101,7 +106,16 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {...@@ -101,7 +106,16 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
101 return (106 return (
102 <SidebarFooter>107 <SidebarFooter>
103 <SidebarThreadUndoNotice />108 <SidebarThreadUndoNotice />
104 <SidebarUsageButton />109 <div className="flex items-start gap-1">
110 <div className="min-w-0 flex-1">
111 <SidebarUsageButton />
112 </div>
113 <div className="shrink-0 pt-1">
114 <SidebarMenu>
115 <SidebarDeploymentsButton />
116 </SidebarMenu>
117 </div>
118 </div>
105 <SidebarUtilityMenu />119 <SidebarUtilityMenu />
106 </SidebarFooter>120 </SidebarFooter>
107 );121 );
apps/web/src/components/sidebar/SidebarDeploymentsButton.tsx created+82
...@@ -0,0 +1,82 @@
1import type { EnvironmentId, MergebotDeployments } from "@t3tools/contracts";
2import { useNavigate, useParams } from "@tanstack/react-router";
3import { RocketIcon } from "lucide-react";
4
5import { cn } from "../../lib/utils";
6import { useRightPanelStore } from "../../rightPanelStore";
7import { usePrimaryEnvironmentId } from "../../state/environments";
8import { resolveThreadRouteRef } from "../../threadRoutes";
9import { ConfiguredDeployments } from "../deployments/ConfiguredDeployments";
10import { deploymentActivity, stagingOnlyCount } from "../deployments/deployments.logic";
11import { Badge } from "../ui/badge";
12import { SidebarMenuButton, SidebarMenuItem, useSidebar } from "../ui/sidebar";
13import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
14
15const ACTIVITY_DOT = {
16 healthy: "bg-success",
17 deploying: "bg-warning",
18 failed: "bg-destructive",
19} as const;
20
21/** Opens Deployments in the current thread's right panel, or as a page without a thread. */
22export function SidebarDeploymentsButton() {
23 const threadRef = useParams({ strict: false, select: resolveThreadRouteRef });
24 const primaryEnvironmentId = usePrimaryEnvironmentId();
25 const environmentId = threadRef?.environmentId ?? primaryEnvironmentId;
26
27 if (!environmentId) return null;
28
29 return (
30 <ConfiguredDeployments environmentId={environmentId}>
31 {(found) =>
32 found.length > 0 ? <DeploymentsButton environmentId={environmentId} found={found} /> : null
33 }
34 </ConfiguredDeployments>
35 );
36}
37
38function DeploymentsButton(props: {
39 environmentId: EnvironmentId;
40 found: readonly MergebotDeployments[];
41}) {
42 const navigate = useNavigate();
43 const threadRef = useParams({ strict: false, select: resolveThreadRouteRef });
44 const { isMobile, setOpenMobile } = useSidebar();
45
46 const pending = props.found.reduce((total, entry) => total + stagingOnlyCount(entry), 0);
47 const activity = deploymentActivity(props.found);
48 const label = pending > 0 ? `Deployments, ${pending} on staging only` : "Deployments";
49
50 const open = () => {
51 if (threadRef) useRightPanelStore.getState().open(threadRef, "deployments");
52 else void navigate({ to: "/deployments" });
53 if (isMobile) setOpenMobile(false);
54 };
55
56 return (
57 <SidebarMenuItem className="shrink-0">
58 <Tooltip>
59 <TooltipTrigger
60 render={
61 <SidebarMenuButton aria-label={label} onClick={open} size="icon">
62 <RocketIcon aria-hidden="true" />
63 <span
64 aria-hidden
65 className={cn(
66 "absolute right-1 bottom-1 size-1.5 rounded-full",
67 ACTIVITY_DOT[activity],
68 )}
69 />
70 {pending > 0 ? (
71 <Badge aria-hidden className="absolute -top-0.5 -right-0.5" size="sm">
72 {pending}
73 </Badge>
74 ) : null}
75 </SidebarMenuButton>
76 }
77 />
78 <TooltipPopup side="top">{label}</TooltipPopup>
79 </Tooltip>
80 </SidebarMenuItem>
81 );
82}
apps/web/src/components/sidebar/mainAppLocation.ts+2-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { useLocation, useNavigate } from "@tanstack/react-router";1import { useLocation, useNavigate } from "@tanstack/react-router";
2import { useCallback, useEffect } from "react";2import { useCallback, useEffect } from "react";
33
4// Settings, Usage, and Pull Requests replace the sidebar utility row with a4// Settings, Usage, Deployments, and Pull Requests replace the sidebar utility row with a
5// Back button. Everything else is the main app. Legacy `/projects/<key>` links5// Back button. Everything else is the main app. Legacy `/projects/<key>` links
6// redirect into settings, so they count too and are never remembered.6// redirect into settings, so they count too and are never remembered.
7export function isSidebarUtilityPage(pathname: string) {7export function isSidebarUtilityPage(pathname: string) {
...@@ -10,6 +10,7 @@ export function isSidebarUtilityPage(pathname: string) {...@@ -10,6 +10,7 @@ export function isSidebarUtilityPage(pathname: string) {
10 pathname.startsWith("/settings/") ||10 pathname.startsWith("/settings/") ||
11 pathname.startsWith("/projects/") ||11 pathname.startsWith("/projects/") ||
12 pathname === "/usage" ||12 pathname === "/usage" ||
13 pathname === "/deployments" ||
13 pathname === "/pull-requests"14 pathname === "/pull-requests"
14 );15 );
15}16}
apps/web/src/rightPanelStore.ts+4
...@@ -30,6 +30,7 @@ const RIGHT_PANEL_KINDS = [...@@ -30,6 +30,7 @@ const RIGHT_PANEL_KINDS = [
30 "terminal",30 "terminal",
31 "pull-request",31 "pull-request",
32 "pull-requests",32 "pull-requests",
33 "deployments",
33] as const;34] as const;
34export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];35export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];
3536
...@@ -53,6 +54,7 @@ export type RightPanelSurface =...@@ -53,6 +54,7 @@ export type RightPanelSurface =
53 splitDirection?: "horizontal" | "vertical";54 splitDirection?: "horizontal" | "vertical";
54 }55 }
55 | { id: "diff"; kind: "diff" }56 | { id: "diff"; kind: "diff" }
57 | { id: "deployments"; kind: "deployments" }
56 | { id: "files"; kind: "files" }58 | { id: "files"; kind: "files" }
57 | {59 | {
58 id: `file:${string}` | `attachment:${string}`;60 id: `file:${string}` | `attachment:${string}`;
...@@ -230,6 +232,8 @@ const singletonSurface = (...@@ -230,6 +232,8 @@ const singletonSurface = (
230 return { id: "pull-requests", kind };232 return { id: "pull-requests", kind };
231 case "device":233 case "device":
232 return { id: "device", kind };234 return { id: "device", kind };
235 case "deployments":
236 return { id: "deployments", kind };
233 }237 }
234};238};
235239
apps/web/src/routeTree.gen.ts+21
...@@ -13,6 +13,7 @@ import { Route as WelcomeRouteImport } from './routes/welcome'...@@ -13,6 +13,7 @@ import { Route as WelcomeRouteImport } from './routes/welcome'
13import { Route as UsageRouteImport } from './routes/usage'13import { Route as UsageRouteImport } from './routes/usage'
14import { Route as SettingsRouteImport } from './routes/settings'14import { Route as SettingsRouteImport } from './routes/settings'
15import { Route as PairRouteImport } from './routes/pair'15import { Route as PairRouteImport } from './routes/pair'
16import { Route as DeploymentsRouteImport } from './routes/deployments'
16import { Route as ConnectAgentRouteImport } from './routes/connect-agent'17import { Route as ConnectAgentRouteImport } from './routes/connect-agent'
17import { Route as ConnectRouteImport } from './routes/connect'18import { Route as ConnectRouteImport } from './routes/connect'
18import { Route as ChatRouteImport } from './routes/_chat'19import { Route as ChatRouteImport } from './routes/_chat'
...@@ -57,6 +58,11 @@ const PairRoute = PairRouteImport.update({...@@ -57,6 +58,11 @@ const PairRoute = PairRouteImport.update({
57 path: '/pair',58 path: '/pair',
58 getParentRoute: () => rootRouteImport,59 getParentRoute: () => rootRouteImport,
59} as any)60} as any)
61const DeploymentsRoute = DeploymentsRouteImport.update({
62 id: '/deployments',
63 path: '/deployments',
64 getParentRoute: () => rootRouteImport,
65} as any)
60const ConnectAgentRoute = ConnectAgentRouteImport.update({66const ConnectAgentRoute = ConnectAgentRouteImport.update({
61 id: '/connect-agent',67 id: '/connect-agent',
62 path: '/connect-agent',68 path: '/connect-agent',
...@@ -178,6 +184,7 @@ export interface FileRoutesByFullPath {...@@ -178,6 +184,7 @@ export interface FileRoutesByFullPath {
178 '/': typeof ChatIndexRoute184 '/': typeof ChatIndexRoute
179 '/connect': typeof ConnectRoute185 '/connect': typeof ConnectRoute
180 '/connect-agent': typeof ConnectAgentRoute186 '/connect-agent': typeof ConnectAgentRoute
187 '/deployments': typeof DeploymentsRoute
181 '/pair': typeof PairRoute188 '/pair': typeof PairRoute
182 '/settings': typeof SettingsRouteWithChildren189 '/settings': typeof SettingsRouteWithChildren
183 '/usage': typeof UsageRoute190 '/usage': typeof UsageRoute
...@@ -205,6 +212,7 @@ export interface FileRoutesByFullPath {...@@ -205,6 +212,7 @@ export interface FileRoutesByFullPath {
205export interface FileRoutesByTo {212export interface FileRoutesByTo {
206 '/connect': typeof ConnectRoute213 '/connect': typeof ConnectRoute
207 '/connect-agent': typeof ConnectAgentRoute214 '/connect-agent': typeof ConnectAgentRoute
215 '/deployments': typeof DeploymentsRoute
208 '/pair': typeof PairRoute216 '/pair': typeof PairRoute
209 '/settings': typeof SettingsRouteWithChildren217 '/settings': typeof SettingsRouteWithChildren
210 '/usage': typeof UsageRoute218 '/usage': typeof UsageRoute
...@@ -235,6 +243,7 @@ export interface FileRoutesById {...@@ -235,6 +243,7 @@ export interface FileRoutesById {
235 '/_chat': typeof ChatRouteWithChildren243 '/_chat': typeof ChatRouteWithChildren
236 '/connect': typeof ConnectRoute244 '/connect': typeof ConnectRoute
237 '/connect-agent': typeof ConnectAgentRoute245 '/connect-agent': typeof ConnectAgentRoute
246 '/deployments': typeof DeploymentsRoute
238 '/pair': typeof PairRoute247 '/pair': typeof PairRoute
239 '/settings': typeof SettingsRouteWithChildren248 '/settings': typeof SettingsRouteWithChildren
240 '/usage': typeof UsageRoute249 '/usage': typeof UsageRoute
...@@ -266,6 +275,7 @@ export interface FileRouteTypes {...@@ -266,6 +275,7 @@ export interface FileRouteTypes {
266 | '/'275 | '/'
267 | '/connect'276 | '/connect'
268 | '/connect-agent'277 | '/connect-agent'
278 | '/deployments'
269 | '/pair'279 | '/pair'
270 | '/settings'280 | '/settings'
271 | '/usage'281 | '/usage'
...@@ -293,6 +303,7 @@ export interface FileRouteTypes {...@@ -293,6 +303,7 @@ export interface FileRouteTypes {
293 to:303 to:
294 | '/connect'304 | '/connect'
295 | '/connect-agent'305 | '/connect-agent'
306 | '/deployments'
296 | '/pair'307 | '/pair'
297 | '/settings'308 | '/settings'
298 | '/usage'309 | '/usage'
...@@ -322,6 +333,7 @@ export interface FileRouteTypes {...@@ -322,6 +333,7 @@ export interface FileRouteTypes {
322 | '/_chat'333 | '/_chat'
323 | '/connect'334 | '/connect'
324 | '/connect-agent'335 | '/connect-agent'
336 | '/deployments'
325 | '/pair'337 | '/pair'
326 | '/settings'338 | '/settings'
327 | '/usage'339 | '/usage'
...@@ -352,6 +364,7 @@ export interface RootRouteChildren {...@@ -352,6 +364,7 @@ export interface RootRouteChildren {
352 ChatRoute: typeof ChatRouteWithChildren364 ChatRoute: typeof ChatRouteWithChildren
353 ConnectRoute: typeof ConnectRoute365 ConnectRoute: typeof ConnectRoute
354 ConnectAgentRoute: typeof ConnectAgentRoute366 ConnectAgentRoute: typeof ConnectAgentRoute
367 DeploymentsRoute: typeof DeploymentsRoute
355 PairRoute: typeof PairRoute368 PairRoute: typeof PairRoute
356 SettingsRoute: typeof SettingsRouteWithChildren369 SettingsRoute: typeof SettingsRouteWithChildren
357 UsageRoute: typeof UsageRoute370 UsageRoute: typeof UsageRoute
...@@ -389,6 +402,13 @@ declare module '@tanstack/react-router' {...@@ -389,6 +402,13 @@ declare module '@tanstack/react-router' {
389 preLoaderRoute: typeof PairRouteImport402 preLoaderRoute: typeof PairRouteImport
390 parentRoute: typeof rootRouteImport403 parentRoute: typeof rootRouteImport
391 }404 }
405 '/deployments': {
406 id: '/deployments'
407 path: '/deployments'
408 fullPath: '/deployments'
409 preLoaderRoute: typeof DeploymentsRouteImport
410 parentRoute: typeof rootRouteImport
411 }
392 '/connect-agent': {412 '/connect-agent': {
393 id: '/connect-agent'413 id: '/connect-agent'
394 path: '/connect-agent'414 path: '/connect-agent'
...@@ -613,6 +633,7 @@ const rootRouteChildren: RootRouteChildren = {...@@ -613,6 +633,7 @@ const rootRouteChildren: RootRouteChildren = {
613 ChatRoute: ChatRouteWithChildren,633 ChatRoute: ChatRouteWithChildren,
614 ConnectRoute: ConnectRoute,634 ConnectRoute: ConnectRoute,
615 ConnectAgentRoute: ConnectAgentRoute,635 ConnectAgentRoute: ConnectAgentRoute,
636 DeploymentsRoute: DeploymentsRoute,
616 PairRoute: PairRoute,637 PairRoute: PairRoute,
617 SettingsRoute: SettingsRouteWithChildren,638 SettingsRoute: SettingsRouteWithChildren,
618 UsageRoute: UsageRoute,639 UsageRoute: UsageRoute,
apps/web/src/routes/deployments.tsx created+44
...@@ -0,0 +1,44 @@
1import { createFileRoute, redirect } from "@tanstack/react-router";
2
3import { DeploymentsPanel } from "../components/deployments/DeploymentsPanel";
4import { SidebarInset } from "../components/ui/sidebar";
5import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../components/WorkspaceBreadcrumb";
6import { WorkspacePageContainer } from "../components/WorkspacePageContainer";
7import { WorkspacePageHeader } from "../components/WorkspacePageHeader";
8import { isElectron } from "../env";
9import { useEscapeToGoBack } from "../hooks/useNavigateBack";
10import { usePrimaryEnvironmentId } from "../state/environments";
11
12function DeploymentsPage() {
13 useEscapeToGoBack();
14 const environmentId = usePrimaryEnvironmentId();
15
16 return (
17 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none isolate">
18 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
19 <WorkspacePageHeader electron={isElectron}>
20 <WorkspaceBreadcrumb ariaLabel="Deployments breadcrumb" className="min-w-0">
21 <WorkspaceBreadcrumbItem current>
22 <h1>Deployments</h1>
23 </WorkspaceBreadcrumbItem>
24 </WorkspaceBreadcrumb>
25 </WorkspacePageHeader>
26 <WorkspacePageContainer className="min-h-0 flex-1 pb-0">
27 {environmentId ? <DeploymentsPanel environmentId={environmentId} /> : null}
28 </WorkspacePageContainer>
29 </div>
30 </SidebarInset>
31 );
32}
33
34export const Route = createFileRoute("/deployments")({
35 beforeLoad: async ({ context }) => {
36 if (
37 context.authGateState.status !== "authenticated" &&
38 context.authGateState.status !== "hosted-static"
39 ) {
40 throw redirect({ to: "/pair", replace: true });
41 }
42 },
43 component: DeploymentsPage,
44});
apps/web/src/state/jj.ts created+36
...@@ -0,0 +1,36 @@
1import { WS_METHODS } from "@t3tools/contracts";
2import {
3 createEnvironmentRpcCommand,
4 createEnvironmentRpcQueryAtomFamily,
5 createEnvironmentRpcSubscriptionAtomFamily,
6} from "@t3tools/client-runtime/state/runtime";
7import { connectionAtomRuntime } from "../connection/runtime";
8
9export const jjEnvironment = {
10 stack: createEnvironmentRpcSubscriptionAtomFamily(connectionAtomRuntime, {
11 label: "jj:stack",
12 tag: WS_METHODS.jjSubscribeStack,
13 }),
14 // A range of commit ids never changes, so a cached diff stays valid.
15 diff: createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, {
16 label: "jj:diff",
17 tag: WS_METHODS.jjDiff,
18 staleTimeMs: 60 * 60_000,
19 }),
20 snapshot: createEnvironmentRpcCommand(connectionAtomRuntime, {
21 label: "jj:snapshot",
22 tag: WS_METHODS.jjSnapshot,
23 }),
24 describe: createEnvironmentRpcCommand(connectionAtomRuntime, {
25 label: "jj:describe",
26 tag: WS_METHODS.jjDescribe,
27 }),
28 squash: createEnvironmentRpcCommand(connectionAtomRuntime, {
29 label: "jj:squash",
30 tag: WS_METHODS.jjSquash,
31 }),
32 push: createEnvironmentRpcCommand(connectionAtomRuntime, {
33 label: "jj:push",
34 tag: WS_METHODS.jjPush,
35 }),
36};
apps/web/src/state/mergebot.ts+55-4
...@@ -1,14 +1,65 @@...@@ -1,14 +1,65 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { createMergebotQueueAtomFamily } from "@t3tools/client-runtime/state/pull-requests";2import {
3import type { EnvironmentId } from "@t3tools/contracts";3 createMergebotDeploymentsAtomFamily,
4 createMergebotPullRequestStatusAtomFamily,
5 createMergebotQueueAtomFamily,
6} from "@t3tools/client-runtime/state/pull-requests";
7import { createEnvironmentRpcCommand } from "@t3tools/client-runtime/state/runtime";
8import { WS_METHODS, type EnvironmentId } from "@t3tools/contracts";
9import type { AsyncResult } from "effect/reactivity";
4import { environmentCatalog } from "../connection/catalog";10import { environmentCatalog } from "../connection/catalog";
5import { connectionAtomRuntime } from "../connection/runtime";11import { connectionAtomRuntime } from "../connection/runtime";
612
7const mergebotQueue = createMergebotQueueAtomFamily(connectionAtomRuntime);13const mergebotQueue = createMergebotQueueAtomFamily(connectionAtomRuntime);
14const mergebotPullRequestStatus = createMergebotPullRequestStatusAtomFamily(connectionAtomRuntime);
15const mergebotDeployments = createMergebotDeploymentsAtomFamily(connectionAtomRuntime);
816
9export function useMergebotQueue(environmentId: EnvironmentId, repository: string) {17/** A live Mergebot value, or null while disconnected, loading, or failed. */
10 const result = useAtomValue(mergebotQueue({ environmentId, input: { repository } }));18function useConnectedValue<A, E>(
19 environmentId: EnvironmentId,
20 result: AsyncResult.AsyncResult<A, E>,
21) {
11 const connection = useAtomValue(environmentCatalog.stateAtom(environmentId));22 const connection = useAtomValue(environmentCatalog.stateAtom(environmentId));
12 if (connection._tag !== "Success" || connection.value.phase !== "connected") return null;23 if (connection._tag !== "Success" || connection.value.phase !== "connected") return null;
13 return result._tag === "Success" ? result.value : null;24 return result._tag === "Success" ? result.value : null;
14}25}
26
27export function useMergebotQueue(environmentId: EnvironmentId, repository: string) {
28 const result = useAtomValue(mergebotQueue({ environmentId, input: { repository } }));
29 return useConnectedValue(environmentId, result);
30}
31
32export function useMergebotPullRequestStatus(
33 environmentId: EnvironmentId,
34 repository: string,
35 number: number,
36) {
37 const result = useAtomValue(
38 mergebotPullRequestStatus({ environmentId, input: { repository, number } }),
39 );
40 return useConnectedValue(environmentId, result);
41}
42
43export function useMergebotDeployments(environmentId: EnvironmentId, repository: string) {
44 const result = useAtomValue(mergebotDeployments({ environmentId, input: { repository } }));
45 return useConnectedValue(environmentId, result);
46}
47
48export const mergebotEnvironment = {
49 openPullRequest: createEnvironmentRpcCommand(connectionAtomRuntime, {
50 label: "environment-data:mergebot:open-pull-request",
51 tag: WS_METHODS.mergebotOpenPullRequest,
52 }),
53 setAutoMerge: createEnvironmentRpcCommand(connectionAtomRuntime, {
54 label: "environment-data:mergebot:set-auto-merge",
55 tag: WS_METHODS.mergebotSetAutoMerge,
56 }),
57 updatePullRequest: createEnvironmentRpcCommand(connectionAtomRuntime, {
58 label: "environment-data:mergebot:update-pull-request",
59 tag: WS_METHODS.mergebotUpdatePullRequest,
60 }),
61 deployProduction: createEnvironmentRpcCommand(connectionAtomRuntime, {
62 label: "environment-data:mergebot:deploy-production",
63 tag: WS_METHODS.mergebotDeployProduction,
64 }),
65};
packages/client-runtime/package.json+4
...@@ -43,6 +43,10 @@...@@ -43,6 +43,10 @@
43 "types": "./src/projectFaviconCache.ts",43 "types": "./src/projectFaviconCache.ts",
44 "default": "./src/projectFaviconCache.ts"44 "default": "./src/projectFaviconCache.ts"
45 },45 },
46 "./mergebot-status-pill": {
47 "types": "./src/mergebotStatusPill.ts",
48 "default": "./src/mergebotStatusPill.ts"
49 },
46 "./user-message": {50 "./user-message": {
47 "types": "./src/userMessage.ts",51 "types": "./src/userMessage.ts",
48 "default": "./src/userMessage.ts"52 "default": "./src/userMessage.ts"
packages/client-runtime/src/mergebotStatusPill.test.ts created+130
...@@ -0,0 +1,130 @@
1import type { MergebotPullRequestLiveStatus } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3import { mergebotStatusPill } from "./mergebotStatusPill.ts";
4
5const eta = "2026-10-15T12:00:00Z";
6const open: MergebotPullRequestLiveStatus = {
7 number: 12,
8 url: "https://mergebot.example/work/app/pull/12",
9 title: "Ship change",
10 body: null,
11 headSha: "head",
12 headBranch: "feature",
13 lifecycle: "open",
14 draft: false,
15 autoMerge: false,
16 ciStatus: "passed",
17 checks: { completed: 14, total: 14, failed: 0 },
18 hasConflicts: false,
19 queueStatus: "not_queued",
20 position: null,
21 mergeEtaAt: null,
22 deploy: null,
23};
24const merged = (deploy: MergebotPullRequestLiveStatus["deploy"]) => ({
25 ...open,
26 lifecycle: "merged" as const,
27 deploy,
28});
29const queued = { ...open, autoMerge: true, queueStatus: "queued" as const, mergeEtaAt: eta };
30
31describe("mergebotStatusPill", () => {
32 it.each<[string, MergebotPullRequestLiveStatus, ReturnType<typeof mergebotStatusPill>]>([
33 [
34 "running CI with counts and a queue ETA",
35 {
36 ...queued,
37 ciStatus: "pending",
38 queueStatus: "waiting_ci",
39 position: 3,
40 checks: { completed: 3, total: 14, failed: 0 },
41 },
42 { kind: "running-ci", label: "Running CI (3/14)", tone: "yellow", etaAt: eta },
43 ],
44 [
45 "running CI before checks load",
46 { ...open, ciStatus: "pending", checks: null },
47 { kind: "running-ci", label: "Running CI", tone: "yellow", etaAt: null },
48 ],
49 [
50 "rebasing",
51 { ...queued, queueStatus: "updating_branch", position: 1 },
52 { kind: "rebasing", label: "Rebasing", tone: "yellow", etaAt: eta },
53 ],
54 [
55 "CI failed",
56 { ...open, ciStatus: "failed", checks: { completed: 14, total: 14, failed: 2 } },
57 { kind: "ci-failed", label: "CI Failed", tone: "red", etaAt: null },
58 ],
59 [
60 "CI failed in the queue",
61 { ...queued, queueStatus: "ci_failed", position: 2 },
62 { kind: "ci-failed", label: "CI Failed", tone: "red", etaAt: null },
63 ],
64 [
65 "merge conflicts outrank failing CI",
66 { ...open, ciStatus: "failed", hasConflicts: true },
67 { kind: "merge-conflicts", label: "Merge Conflicts", tone: "orange", etaAt: null },
68 ],
69 [
70 "draft passing CI",
71 { ...open, draft: true },
72 { kind: "draft-passes-ci", label: "Draft Passes CI", tone: "blue", etaAt: null },
73 ],
74 ["ready to merge", open, { kind: "ready", label: "Ready to merge", tone: "blue", etaAt: null }],
75 [
76 "unknown conflicts still read as ready",
77 { ...open, hasConflicts: null },
78 { kind: "ready", label: "Ready to merge", tone: "blue", etaAt: null },
79 ],
80 [
81 "queued behind another PR",
82 { ...queued, position: 2 },
83 { kind: "queued", label: "Queued #2", tone: "blue", etaAt: eta },
84 ],
85 [
86 "first in the queue with passing CI",
87 { ...queued, position: 1 },
88 { kind: "merging", label: "Merging", tone: "green", etaAt: eta },
89 ],
90 [
91 "merging",
92 { ...queued, queueStatus: "merging", position: 1 },
93 { kind: "merging", label: "Merging", tone: "green", etaAt: eta },
94 ],
95 [
96 "merged, waiting for the next deploy",
97 merged({ state: "waiting", environment: "staging", etaAt: null }),
98 { kind: "merged", label: "Merged", tone: "green", etaAt: null },
99 ],
100 [
101 "merged without deploy data",
102 merged(null),
103 { kind: "merged", label: "Merged", tone: "green", etaAt: null },
104 ],
105 [
106 "deploying",
107 merged({ state: "deploying", environment: "staging", etaAt: eta }),
108 { kind: "deploying", label: "Deploying", tone: "green", etaAt: eta },
109 ],
110 [
111 "deploy failed",
112 merged({ state: "failed", environment: "production", etaAt: null }),
113 { kind: "deploy-failed", label: "Deploy Failed", tone: "red", etaAt: null },
114 ],
115 [
116 "on staging",
117 merged({ state: "staging", environment: "staging", etaAt: null }),
118 { kind: "on-staging", label: "On Staging", tone: "green", etaAt: null },
119 ],
120 [
121 "shipped to production",
122 merged({ state: "production", environment: "production", etaAt: null }),
123 { kind: "shipped", label: "Shipped to Prod", tone: "muted", etaAt: null },
124 ],
125 ["closed", { ...open, lifecycle: "closed" }, null],
126 ["unknown CI outside the queue", { ...open, ciStatus: null }, null],
127 ])("%s", (_, status, expected) => {
128 expect(mergebotStatusPill(status)).toEqual(expected);
129 });
130});
packages/client-runtime/src/mergebotStatusPill.ts created+88
...@@ -0,0 +1,88 @@
1import type { MergebotPullRequestLiveStatus } from "@t3tools/contracts";
2
3export type MergebotStatusPillTone = "yellow" | "red" | "orange" | "blue" | "green" | "muted";
4
5export interface MergebotStatusPill {
6 readonly kind:
7 | "running-ci"
8 | "rebasing"
9 | "ci-failed"
10 | "merge-conflicts"
11 | "draft-passes-ci"
12 | "ready"
13 | "queued"
14 | "merging"
15 | "merged"
16 | "deploying"
17 | "deploy-failed"
18 | "on-staging"
19 | "shipped";
20 readonly label: string;
21 readonly tone: MergebotStatusPillTone;
22 readonly etaAt: string | null;
23}
24
25/**
26 * One pill for a PR's place in the Mergebot lifecycle, or null when nothing is worth showing.
27 * Blockers outrank progress: conflicts and failures show even while the queue holds the PR.
28 */
29export function mergebotStatusPill(
30 status: MergebotPullRequestLiveStatus,
31): MergebotStatusPill | null {
32 if (status.lifecycle === "closed") return null;
33
34 if (status.lifecycle === "merged") {
35 switch (status.deploy?.state) {
36 case "deploying":
37 return { kind: "deploying", label: "Deploying", tone: "green", etaAt: status.deploy.etaAt };
38 case "failed":
39 return { kind: "deploy-failed", label: "Deploy Failed", tone: "red", etaAt: null };
40 case "staging":
41 return { kind: "on-staging", label: "On Staging", tone: "green", etaAt: null };
42 case "production":
43 return { kind: "shipped", label: "Shipped to Prod", tone: "muted", etaAt: null };
44 default:
45 return { kind: "merged", label: "Merged", tone: "green", etaAt: null };
46 }
47 }
48
49 const merging: MergebotStatusPill = {
50 kind: "merging",
51 label: "Merging",
52 tone: "green",
53 etaAt: status.mergeEtaAt,
54 };
55 if (status.queueStatus === "merging") return merging;
56 if (status.hasConflicts === true)
57 return { kind: "merge-conflicts", label: "Merge Conflicts", tone: "orange", etaAt: null };
58 if (status.ciStatus === "failed" || status.queueStatus === "ci_failed")
59 return { kind: "ci-failed", label: "CI Failed", tone: "red", etaAt: null };
60 if (status.queueStatus === "updating_branch")
61 return { kind: "rebasing", label: "Rebasing", tone: "yellow", etaAt: status.mergeEtaAt };
62
63 if (status.ciStatus === "pending" || status.queueStatus === "waiting_ci") {
64 const counts = status.checks?.total
65 ? ` (${status.checks.completed}/${status.checks.total})`
66 : "";
67 return {
68 kind: "running-ci",
69 label: `Running CI${counts}`,
70 tone: "yellow",
71 etaAt: status.mergeEtaAt,
72 };
73 }
74
75 if (status.position !== null && status.position >= 2)
76 return {
77 kind: "queued",
78 label: `Queued #${status.position}`,
79 tone: "blue",
80 etaAt: status.mergeEtaAt,
81 };
82 if (status.ciStatus !== "passed") return null;
83 // The head of the queue with passing CI is merging, whatever its queue status says.
84 if (status.position === 1) return merging;
85 if (status.draft)
86 return { kind: "draft-passes-ci", label: "Draft Passes CI", tone: "blue", etaAt: null };
87 return { kind: "ready", label: "Ready to merge", tone: "blue", etaAt: null };
88}
packages/client-runtime/src/rpc/client.ts+3
...@@ -69,6 +69,9 @@ export type EnvironmentSubscriptionRpcTag =...@@ -69,6 +69,9 @@ export type EnvironmentSubscriptionRpcTag =
69 | typeof WS_METHODS.subscribeResourceTelemetry69 | typeof WS_METHODS.subscribeResourceTelemetry
70 | typeof WS_METHODS.pullRequestsSubscribeRefreshes70 | typeof WS_METHODS.pullRequestsSubscribeRefreshes
71 | typeof WS_METHODS.mergebotSubscribe71 | typeof WS_METHODS.mergebotSubscribe
72 | typeof WS_METHODS.mergebotSubscribePullRequestStatus
73 | typeof WS_METHODS.mergebotSubscribeDeployments
74 | typeof WS_METHODS.jjSubscribeStack
72 | typeof WS_METHODS.subscribeVcsStatus75 | typeof WS_METHODS.subscribeVcsStatus
73 | typeof WS_METHODS.subscribeWorktreeSetup76 | typeof WS_METHODS.subscribeWorktreeSetup
74 | typeof WS_METHODS.subscribeProjectClones77 | typeof WS_METHODS.subscribeProjectClones
packages/client-runtime/src/state/commandPermissions.test.ts+34
...@@ -86,6 +86,40 @@ describe("command permissions", () => {...@@ -86,6 +86,40 @@ describe("command permissions", () => {
86 ),86 ),
87 );87 );
8888
89 it.effect("checks destination source-control grants for Jujutsu and Mergebot actions", () =>
90 Effect.scoped(
91 Effect.gen(function* () {
92 const registry = yield* setup;
93 registry.set(
94 sessions(env),
95 AsyncResult.success({
96 ...grant(false),
97 scopes: [AuthSourceControlWriteScope],
98 permissions: [AuthSourceControlWriteScope],
99 }),
100 );
101 registry.set(sessions(other), AsyncResult.success(grant(true)));
102 for (const method of [
103 WS_METHODS.jjDescribe,
104 WS_METHODS.jjSquash,
105 WS_METHODS.jjPush,
106 WS_METHODS.mergebotOpenPullRequest,
107 WS_METHODS.mergebotSetAutoMerge,
108 WS_METHODS.mergebotUpdatePullRequest,
109 WS_METHODS.mergebotDeployProduction,
110 ]) {
111 const command = createCommandPermissions(runtime, method);
112 expect(registry.get(command.permissionAtom(env))).toBe(true);
113 yield* command.authorize(registry, env);
114 expect(registry.get(command.permissionAtom(other))).toBe(false);
115 expect((yield* command.authorize(registry, other).pipe(Effect.flip))._tag).toBe(
116 "EnvironmentAuthorizationError",
117 );
118 }
119 }),
120 ),
121 );
122
89 it.effect("checks destination grants before creating a managed workspace", () =>123 it.effect("checks destination grants before creating a managed workspace", () =>
90 Effect.scoped(124 Effect.scoped(
91 Effect.gen(function* () {125 Effect.gen(function* () {
packages/client-runtime/src/state/pullRequests.ts+18
...@@ -628,3 +628,21 @@ export function createMergebotQueueAtomFamily<R, E>(...@@ -628,3 +628,21 @@ export function createMergebotQueueAtomFamily<R, E>(
628 tag: WS_METHODS.mergebotSubscribe,628 tag: WS_METHODS.mergebotSubscribe,
629 });629 });
630}630}
631
632export function createMergebotPullRequestStatusAtomFamily<R, E>(
633 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
634) {
635 return createEnvironmentRpcSubscriptionAtomFamily(runtime, {
636 label: "mergebot:pull-request-status",
637 tag: WS_METHODS.mergebotSubscribePullRequestStatus,
638 });
639}
640
641export function createMergebotDeploymentsAtomFamily<R, E>(
642 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
643) {
644 return createEnvironmentRpcSubscriptionAtomFamily(runtime, {
645 label: "mergebot:deployments",
646 tag: WS_METHODS.mergebotSubscribeDeployments,
647 });
648}
packages/contracts/src/clientRpcPermissions.ts+7
...@@ -23,6 +23,13 @@ export const CLIENT_GUARDED_RPC_SCOPES = {...@@ -23,6 +23,13 @@ export const CLIENT_GUARDED_RPC_SCOPES = {
23 // Endpoint checks exist to configure exports, so they need the same grant as saving them.23 // Endpoint checks exist to configure exports, so they need the same grant as saving them.
24 [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope,24 [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope,
25 [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope,25 [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope,
26 [WS_METHODS.mergebotOpenPullRequest]: AuthSourceControlWriteScope,
27 [WS_METHODS.mergebotSetAutoMerge]: AuthSourceControlWriteScope,
28 [WS_METHODS.mergebotUpdatePullRequest]: AuthSourceControlWriteScope,
29 [WS_METHODS.mergebotDeployProduction]: AuthSourceControlWriteScope,
30 [WS_METHODS.jjDescribe]: AuthSourceControlWriteScope,
31 [WS_METHODS.jjSquash]: AuthSourceControlWriteScope,
32 [WS_METHODS.jjPush]: AuthSourceControlWriteScope,
26 [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope,33 [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope,
27 [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope,34 [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope,
28 [WS_METHODS.pullRequestsUpdateComment]: AuthSourceControlWriteScope,35 [WS_METHODS.pullRequestsUpdateComment]: AuthSourceControlWriteScope,
packages/contracts/src/git.ts+1
...@@ -225,6 +225,7 @@ const VcsStatusChangeRequest = Schema.Struct({...@@ -225,6 +225,7 @@ const VcsStatusChangeRequest = Schema.Struct({
225225
226const VcsStatusLocalShape = {226const VcsStatusLocalShape = {
227 isRepo: Schema.Boolean,227 isRepo: Schema.Boolean,
228 isJjWorkspace: Schema.optional(Schema.Boolean),
228 sourceControlProvider: Schema.optional(SourceControlProviderInfo),229 sourceControlProvider: Schema.optional(SourceControlProviderInfo),
229 hasPrimaryRemote: Schema.Boolean,230 hasPrimaryRemote: Schema.Boolean,
230 isDefaultRef: Schema.Boolean,231 isDefaultRef: Schema.Boolean,
packages/contracts/src/index.ts+1
...@@ -68,6 +68,7 @@ export * from "./clientRpcPermissions.ts";...@@ -68,6 +68,7 @@ export * from "./clientRpcPermissions.ts";
68export * from "./devServer.ts";68export * from "./devServer.ts";
69export * from "./managedWorkspaces.ts";69export * from "./managedWorkspaces.ts";
70export * from "./mergebot.ts";70export * from "./mergebot.ts";
71export * from "./jj.ts";
71export * from "./scriptProgress.ts";72export * from "./scriptProgress.ts";
72export * from "./desktopTerminal.ts";73export * from "./desktopTerminal.ts";
7374
packages/contracts/src/jj.test.ts created+35
...@@ -0,0 +1,35 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { commitMessageToEditor, editorToCommitMessage } from "./jj.ts";
4
5describe("commit message editor form", () => {
6 it.each([
7 ["title only", "feat: title", "feat: title"],
8 ["title and body", "feat: title\n\nbody", "feat: title\nbody"],
9 ["body with blank lines", "fix: x\n\none\n\ntwo\n\n\nthree", "fix: x\none\n\ntwo\n\n\nthree"],
10 [
11 "trailers",
12 "feat: x\n\nbody\n\nAssisted-by: claude-opus-5.5\nAssisted-by: gpt-6.1-sol",
13 "feat: x\nbody\n\nAssisted-by: claude-opus-5.5\nAssisted-by: gpt-6.1-sol",
14 ],
15 ["empty", "", ""],
16 ])("round-trips %s", (_, description, editor) => {
17 expect(commitMessageToEditor(description)).toBe(editor);
18 expect(editorToCommitMessage(editor)).toBe(description);
19 });
20
21 it("drops trailing newlines and whitespace", () => {
22 expect(commitMessageToEditor("feat: title\n\nbody\n")).toBe("feat: title\nbody");
23 expect(editorToCommitMessage("feat: title\nbody \n\n")).toBe("feat: title\n\nbody");
24 expect(editorToCommitMessage("feat: title\n")).toBe("feat: title");
25 });
26
27 it("collapses blank lines typed between title and body into one separator", () => {
28 expect(editorToCommitMessage("feat: title\n\n\nbody")).toBe("feat: title\n\nbody");
29 });
30
31 it("keeps a body whose title is empty", () => {
32 expect(editorToCommitMessage("\nbody")).toBe("\n\nbody");
33 expect(commitMessageToEditor("\n\nbody")).toBe("\nbody");
34 });
35});
packages/contracts/src/jj.ts created+131
...@@ -0,0 +1,131 @@
1import * as Schema from "effect/Schema";
2import { TrimmedNonEmptyString } from "./baseSchemas.ts";
3import { ReviewDiffPreviewSource } from "./review.ts";
4
5export const JjStackInput = Schema.Struct({ cwd: TrimmedNonEmptyString });
6export type JjStackInput = typeof JjStackInput.Type;
7
8export const JjTrailer = Schema.Struct({ key: Schema.String, value: Schema.String });
9export type JjTrailer = typeof JjTrailer.Type;
10
11export const JjBookmark = Schema.Struct({
12 name: TrimmedNonEmptyString,
13 /** True when a tracked remote bookmark points at this same commit. */
14 pushed: Schema.Boolean,
15});
16export type JjBookmark = typeof JjBookmark.Type;
17
18export const JjChange = Schema.Struct({
19 changeId: TrimmedNonEmptyString,
20 /** Shortest unique change-id prefix, at least four characters. */
21 shortId: TrimmedNonEmptyString,
22 commitId: TrimmedNonEmptyString,
23 parentCommitIds: Schema.Array(TrimmedNonEmptyString),
24 description: Schema.String,
25 trailers: Schema.Array(JjTrailer),
26 bookmarks: Schema.Array(JjBookmark),
27 immutable: Schema.Boolean,
28 conflict: Schema.Boolean,
29 empty: Schema.Boolean,
30 workingCopy: Schema.Boolean,
31 additions: Schema.Number,
32 deletions: Schema.Number,
33 authoredAt: Schema.String,
34});
35export type JjChange = typeof JjChange.Type;
36
37export const JjStack = Schema.Struct({
38 workspaceRoot: TrimmedNonEmptyString,
39 /** `owner/name` of the GitHub push remote, when there is one. */
40 repository: Schema.NullOr(Schema.String),
41 trunk: Schema.NullOr(
42 Schema.Struct({
43 name: Schema.String,
44 commitId: TrimmedNonEmptyString,
45 /** Trunk commits the stack's ancestry lacks. */
46 behind: Schema.Number,
47 }),
48 ),
49 /** Newest first. Empty changes are omitted, except the working copy. */
50 changes: Schema.Array(JjChange),
51});
52export type JjStack = typeof JjStack.Type;
53
54export const JjDiffInput = Schema.Struct({
55 cwd: TrimmedNonEmptyString,
56 fromCommitId: TrimmedNonEmptyString,
57 toCommitId: TrimmedNonEmptyString,
58 ignoreWhitespace: Schema.optionalKey(Schema.Boolean),
59});
60export type JjDiffInput = typeof JjDiffInput.Type;
61
62export const JjDiffResult = ReviewDiffPreviewSource;
63export type JjDiffResult = typeof JjDiffResult.Type;
64
65export const JjDescribeInput = Schema.Struct({
66 cwd: TrimmedNonEmptyString,
67 changeId: TrimmedNonEmptyString,
68 /** The commit the editor loaded; a concurrent rewrite with a different message is refused. */
69 expectedCommitId: TrimmedNonEmptyString,
70 message: Schema.String,
71});
72export type JjDescribeInput = typeof JjDescribeInput.Type;
73
74export const JjChangeInput = Schema.Struct({
75 cwd: TrimmedNonEmptyString,
76 changeId: TrimmedNonEmptyString,
77});
78export type JjChangeInput = typeof JjChangeInput.Type;
79
80export const JjPushInput = Schema.Struct({
81 cwd: TrimmedNonEmptyString,
82 /** The newest change the pushed bookmark should point at. */
83 changeId: TrimmedNonEmptyString,
84 /** Move and push this bookmark; omitted creates one from the repository's push-bookmark template. */
85 bookmark: Schema.optionalKey(TrimmedNonEmptyString),
86 /** The change whose description names a new bookmark, usually the PR's first change. */
87 nameFrom: Schema.optionalKey(TrimmedNonEmptyString),
88});
89export type JjPushInput = typeof JjPushInput.Type;
90
91export const JjPushResult = Schema.Struct({
92 bookmark: TrimmedNonEmptyString,
93 remote: Schema.String,
94});
95export type JjPushResult = typeof JjPushResult.Type;
96
97export class JjError extends Schema.TaggedError<JjError>()("JjError", {
98 operation: Schema.String,
99 detail: Schema.String,
100}) {
101 override get message(): string {
102 return `jj ${this.operation} failed: ${this.detail}`;
103 }
104}
105
106/** Another writer changed the description after the editor loaded it. */
107export class JjDescriptionConflictError extends Schema.TaggedError<JjDescriptionConflictError>()(
108 "JjDescriptionConflictError",
109 { changeId: Schema.String, description: Schema.String, commitId: Schema.String },
110) {
111 override get message(): string {
112 return "This change's description was edited elsewhere.";
113 }
114}
115
116/** Splits jj's `title\n\nbody` into what the editor shows: title on line 1, body from line 2. */
117export function commitMessageToEditor(description: string): string {
118 const trimmed = description.replace(/\n+$/, "");
119 const newline = trimmed.indexOf("\n");
120 if (newline === -1) return trimmed;
121 return `${trimmed.slice(0, newline)}\n${trimmed.slice(newline + 1).replace(/^\n/, "")}`;
122}
123
124/** Inverse of `commitMessageToEditor`: restores the blank line between title and body. */
125export function editorToCommitMessage(text: string): string {
126 const trimmed = text.replace(/\s+$/, "");
127 const newline = trimmed.indexOf("\n");
128 if (newline === -1) return trimmed;
129 const body = trimmed.slice(newline + 1).replace(/^\n+/, "");
130 return body ? `${trimmed.slice(0, newline)}\n\n${body}` : trimmed.slice(0, newline);
131}
packages/contracts/src/mergebot.ts+117
...@@ -154,3 +154,120 @@ export function parseMergebotPullRequestUrl(...@@ -154,3 +154,120 @@ export function parseMergebotPullRequestUrl(
154 ? { host: "github.com", repository, number }154 ? { host: "github.com", repository, number }
155 : null;155 : null;
156}156}
157
158export const MergebotOpenPullRequestInput = Schema.Struct({
159 repository: TrimmedNonEmptyString,
160 head: TrimmedNonEmptyString,
161 base: Schema.optionalKey(TrimmedNonEmptyString),
162 title: TrimmedNonEmptyString,
163 body: Schema.String,
164 draft: Schema.Boolean,
165 autoMerge: Schema.Boolean,
166});
167export type MergebotOpenPullRequestInput = typeof MergebotOpenPullRequestInput.Type;
168
169export const MergebotOpenPullRequestResult = Schema.Struct({
170 number: PositiveInt,
171 url: Schema.String,
172});
173export type MergebotOpenPullRequestResult = typeof MergebotOpenPullRequestResult.Type;
174
175export const MergebotSetAutoMergeInput = Schema.Struct({
176 repository: TrimmedNonEmptyString,
177 number: PositiveInt,
178 enabled: Schema.Boolean,
179});
180export type MergebotSetAutoMergeInput = typeof MergebotSetAutoMergeInput.Type;
181
182export const MergebotUpdatePullRequestInput = Schema.Struct({
183 repository: TrimmedNonEmptyString,
184 number: PositiveInt,
185 title: Schema.optionalKey(TrimmedNonEmptyString),
186 body: Schema.optionalKey(Schema.String),
187 draft: Schema.optionalKey(Schema.Boolean),
188});
189export type MergebotUpdatePullRequestInput = typeof MergebotUpdatePullRequestInput.Type;
190
191export class MergebotOperationError extends Schema.TaggedError<MergebotOperationError>()(
192 "MergebotOperationError",
193 { operation: Schema.String, status: Schema.NullOr(Schema.Number), detail: Schema.String },
194) {
195 override get message(): string {
196 return `Mergebot could not ${this.operation}: ${this.detail}`;
197 }
198}
199
200/** Everything a status pill needs for one PR, composed from Mergebot's overview, detail, and deploy data. */
201export const MergebotPullRequestLiveStatus = Schema.Struct({
202 number: PositiveInt,
203 url: Schema.String,
204 title: Schema.String,
205 body: Schema.NullOr(Schema.String),
206 headSha: Schema.String,
207 headBranch: Schema.String,
208 lifecycle: Schema.Literals(["open", "closed", "merged"]),
209 draft: Schema.Boolean,
210 autoMerge: Schema.Boolean,
211 ciStatus: Schema.NullOr(Schema.Literals(["passed", "pending", "failed", "unknown"])),
212 /** From the detail channel; null until it loads. */
213 checks: Schema.NullOr(
214 Schema.Struct({ completed: Schema.Number, total: Schema.Number, failed: Schema.Number }),
215 ),
216 hasConflicts: Schema.NullOr(Schema.Boolean),
217 queueStatus: MergebotPullRequestStatus.fields.queueStatus,
218 position: Schema.NullOr(PositiveInt),
219 mergeEtaAt: Schema.NullOr(Schema.String),
220 deploy: Schema.NullOr(
221 Schema.Struct({
222 state: Schema.Literals(["waiting", "deploying", "staging", "production", "failed"]),
223 environment: Schema.NullOr(Schema.Literals(["staging", "production"])),
224 etaAt: Schema.NullOr(Schema.String),
225 }),
226 ),
227});
228export type MergebotPullRequestLiveStatus = typeof MergebotPullRequestLiveStatus.Type;
229
230export const MergebotDeploymentEnvironment = Schema.Struct({
231 sha: Schema.String,
232 deployedSha: Schema.NullOr(Schema.String),
233 status: Schema.Literals(["passed", "pending", "failed", "unknown"]),
234 currentStep: Schema.NullOr(Schema.String),
235 etaAt: Schema.NullOr(Schema.String),
236 checksUrl: Schema.String,
237});
238export type MergebotDeploymentEnvironment = typeof MergebotDeploymentEnvironment.Type;
239
240export const MergebotMergedCommit = Schema.Struct({
241 sha: Schema.String,
242 pullRequestNumber: Schema.NullOr(PositiveInt),
243 authorLogin: Schema.NullOr(Schema.String),
244 authorName: Schema.NullOr(Schema.String),
245 authorAvatarUrl: Schema.NullOr(Schema.String),
246 message: Schema.String,
247 mergedAt: Schema.String,
248 url: Schema.String,
249 labels: Schema.Array(PullRequestLabel),
250 status: Schema.Literals(["default", "recent", "hotfix"]),
251 isProductionHead: Schema.Boolean,
252});
253export type MergebotMergedCommit = typeof MergebotMergedCommit.Type;
254
255export const MergebotDeployments = Schema.Struct({
256 repository: TrimmedNonEmptyString,
257 url: Schema.String,
258 defaultBranch: Schema.String,
259 productionBranch: Schema.NullOr(Schema.String),
260 stagingUrl: Schema.NullOr(Schema.String),
261 productionUrl: Schema.NullOr(Schema.String),
262 staging: Schema.NullOr(MergebotDeploymentEnvironment),
263 production: Schema.NullOr(MergebotDeploymentEnvironment),
264 /** Ordered by default-branch history, newest first. */
265 mergedCommits: Schema.Array(MergebotMergedCommit),
266});
267export type MergebotDeployments = typeof MergebotDeployments.Type;
268
269export const MergebotDeployProductionInput = Schema.Struct({
270 repository: TrimmedNonEmptyString,
271 sha: TrimmedNonEmptyString,
272});
273export type MergebotDeployProductionInput = typeof MergebotDeployProductionInput.Type;
packages/contracts/src/rpc.ts+131-1
...@@ -16,7 +16,31 @@ import {...@@ -16,7 +16,31 @@ import {
16 ManagedWorkspaceArchiveInput,16 ManagedWorkspaceArchiveInput,
17 ManagedWorkspaceArchived,17 ManagedWorkspaceArchived,
18} from "./managedWorkspaces.ts";18} from "./managedWorkspaces.ts";
19import { MergebotQueueInput, MergebotRepoQueue } from "./mergebot.ts";19import {
20 MergebotDeployProductionInput,
21 MergebotDeployments,
22 MergebotOpenPullRequestInput,
23 MergebotOpenPullRequestResult,
24 MergebotOperationError,
25 MergebotPullRequestInput,
26 MergebotPullRequestLiveStatus,
27 MergebotQueueInput,
28 MergebotRepoQueue,
29 MergebotSetAutoMergeInput,
30 MergebotUpdatePullRequestInput,
31} from "./mergebot.ts";
32import {
33 JjChangeInput,
34 JjDescribeInput,
35 JjDescriptionConflictError,
36 JjDiffInput,
37 JjDiffResult,
38 JjError,
39 JjPushInput,
40 JjPushResult,
41 JjStack,
42 JjStackInput,
43} from "./jj.ts";
20import { DevServerTarget, DevServerStatus, DevServerError } from "./devServer.ts";44import { DevServerTarget, DevServerStatus, DevServerError } from "./devServer.ts";
21import {45import {
22 OrchestrationV2SearchThreadError,46 OrchestrationV2SearchThreadError,
...@@ -560,6 +584,18 @@ export const WS_METHODS = {...@@ -560,6 +584,18 @@ export const WS_METHODS = {
560 // Pull request methods584 // Pull request methods
561 mergebotQueue: "mergebot.queue",585 mergebotQueue: "mergebot.queue",
562 mergebotSubscribe: "mergebot.subscribe",586 mergebotSubscribe: "mergebot.subscribe",
587 mergebotOpenPullRequest: "mergebot.openPullRequest",
588 mergebotSetAutoMerge: "mergebot.setAutoMerge",
589 mergebotUpdatePullRequest: "mergebot.updatePullRequest",
590 mergebotSubscribePullRequestStatus: "mergebot.subscribePullRequestStatus",
591 mergebotSubscribeDeployments: "mergebot.subscribeDeployments",
592 mergebotDeployProduction: "mergebot.deployProduction",
593 jjSubscribeStack: "jj.subscribeStack",
594 jjSnapshot: "jj.snapshot",
595 jjDiff: "jj.diff",
596 jjDescribe: "jj.describe",
597 jjSquash: "jj.squash",
598 jjPush: "jj.push",
563 pullRequestsList: "pullRequests.list",599 pullRequestsList: "pullRequests.list",
564 pullRequestsListStats: "pullRequests.listStats",600 pullRequestsListStats: "pullRequests.listStats",
565 pullRequestsSummary: "pullRequests.summary",601 pullRequestsSummary: "pullRequests.summary",
...@@ -1019,6 +1055,88 @@ const WsMergebotSubscribeRpc = Rpc.make(WS_METHODS.mergebotSubscribe, {...@@ -1019,6 +1055,88 @@ const WsMergebotSubscribeRpc = Rpc.make(WS_METHODS.mergebotSubscribe, {
1019 stream: true,1055 stream: true,
1020});1056});
10211057
1058const MergebotRpcError = Schema.Union([MergebotOperationError, EnvironmentAuthorizationError]);
1059
1060const WsMergebotOpenPullRequestRpc = Rpc.make(WS_METHODS.mergebotOpenPullRequest, {
1061 payload: MergebotOpenPullRequestInput,
1062 success: MergebotOpenPullRequestResult,
1063 error: MergebotRpcError,
1064});
1065
1066const WsMergebotSetAutoMergeRpc = Rpc.make(WS_METHODS.mergebotSetAutoMerge, {
1067 payload: MergebotSetAutoMergeInput,
1068 success: Schema.Void,
1069 error: MergebotRpcError,
1070});
1071
1072const WsMergebotUpdatePullRequestRpc = Rpc.make(WS_METHODS.mergebotUpdatePullRequest, {
1073 payload: MergebotUpdatePullRequestInput,
1074 success: Schema.Void,
1075 error: MergebotRpcError,
1076});
1077
1078const WsMergebotSubscribePullRequestStatusRpc = Rpc.make(
1079 WS_METHODS.mergebotSubscribePullRequestStatus,
1080 {
1081 payload: MergebotPullRequestInput,
1082 success: Schema.NullOr(MergebotPullRequestLiveStatus),
1083 error: EnvironmentAuthorizationError,
1084 stream: true,
1085 },
1086);
1087
1088const WsMergebotSubscribeDeploymentsRpc = Rpc.make(WS_METHODS.mergebotSubscribeDeployments, {
1089 payload: MergebotQueueInput,
1090 success: Schema.NullOr(MergebotDeployments),
1091 error: EnvironmentAuthorizationError,
1092 stream: true,
1093});
1094
1095const WsMergebotDeployProductionRpc = Rpc.make(WS_METHODS.mergebotDeployProduction, {
1096 payload: MergebotDeployProductionInput,
1097 success: Schema.Void,
1098 error: MergebotRpcError,
1099});
1100
1101const JjRpcError = Schema.Union([JjError, EnvironmentAuthorizationError]);
1102
1103const WsJjSubscribeStackRpc = Rpc.make(WS_METHODS.jjSubscribeStack, {
1104 payload: JjStackInput,
1105 success: Schema.NullOr(JjStack),
1106 error: JjRpcError,
1107 stream: true,
1108});
1109
1110const WsJjSnapshotRpc = Rpc.make(WS_METHODS.jjSnapshot, {
1111 payload: JjStackInput,
1112 success: Schema.Void,
1113 error: JjRpcError,
1114});
1115
1116const WsJjDiffRpc = Rpc.make(WS_METHODS.jjDiff, {
1117 payload: JjDiffInput,
1118 success: JjDiffResult,
1119 error: JjRpcError,
1120});
1121
1122const WsJjDescribeRpc = Rpc.make(WS_METHODS.jjDescribe, {
1123 payload: JjDescribeInput,
1124 success: Schema.Struct({ commitId: Schema.String }),
1125 error: Schema.Union([JjError, JjDescriptionConflictError, EnvironmentAuthorizationError]),
1126});
1127
1128const WsJjSquashRpc = Rpc.make(WS_METHODS.jjSquash, {
1129 payload: JjChangeInput,
1130 success: Schema.Void,
1131 error: JjRpcError,
1132});
1133
1134const WsJjPushRpc = Rpc.make(WS_METHODS.jjPush, {
1135 payload: JjPushInput,
1136 success: JjPushResult,
1137 error: JjRpcError,
1138});
1139
1022const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, {1140const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, {
1023 payload: PullRequestListInput,1141 payload: PullRequestListInput,
1024 success: PullRequestListResult,1142 success: PullRequestListResult,
...@@ -2066,6 +2184,18 @@ export const WsRpcGroup = RpcGroup.make(...@@ -2066,6 +2184,18 @@ export const WsRpcGroup = RpcGroup.make(
2066 WsCloudInstallRelayClientRpc,2184 WsCloudInstallRelayClientRpc,
2067 WsMergebotQueueRpc,2185 WsMergebotQueueRpc,
2068 WsMergebotSubscribeRpc,2186 WsMergebotSubscribeRpc,
2187 WsMergebotOpenPullRequestRpc,
2188 WsMergebotSetAutoMergeRpc,
2189 WsMergebotUpdatePullRequestRpc,
2190 WsMergebotSubscribePullRequestStatusRpc,
2191 WsMergebotSubscribeDeploymentsRpc,
2192 WsMergebotDeployProductionRpc,
2193 WsJjSubscribeStackRpc,
2194 WsJjSnapshotRpc,
2195 WsJjDiffRpc,
2196 WsJjDescribeRpc,
2197 WsJjSquashRpc,
2198 WsJjPushRpc,
2069 WsPullRequestsListRpc,2199 WsPullRequestsListRpc,
2070 WsPullRequestsListStatsRpc,2200 WsPullRequestsListStatsRpc,
2071 WsPullRequestsSummaryRpc,2201 WsPullRequestsSummaryRpc,
packages/shared/src/git.test.ts+1
...@@ -259,6 +259,7 @@ describe("applyGitStatusStreamEvent", () => {...@@ -259,6 +259,7 @@ describe("applyGitStatusStreamEvent", () => {
259 it("preserves local-only fields when applying a remote update", () => {259 it("preserves local-only fields when applying a remote update", () => {
260 const current: VcsStatusResult = {260 const current: VcsStatusResult = {
261 isRepo: true,261 isRepo: true,
262 isJjWorkspace: true,
262 sourceControlProvider: {263 sourceControlProvider: {
263 kind: SourceControlProviderKind.make("github"),264 kind: SourceControlProviderKind.make("github"),
264 name: "GitHub",265 name: "GitHub",
packages/shared/src/git.ts+1
...@@ -388,6 +388,7 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult {...@@ -388,6 +388,7 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult {
388function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult {388function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult {
389 return {389 return {
390 isRepo: status.isRepo,390 isRepo: status.isRepo,
391 ...(status.isJjWorkspace === undefined ? {} : { isJjWorkspace: status.isJjWorkspace }),
391 ...(status.sourceControlProvider392 ...(status.sourceControlProvider
392 ? { sourceControlProvider: status.sourceControlProvider }393 ? { sourceControlProvider: status.sourceControlProvider }
393 : {}),394 : {}),