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", () => {
166166 });
167167
168168 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 ]) {
170175 expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope);
171176 }
172177 });
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
174196 it("separates preview control from observation", () => {
175197 for (const method of [
176198 WS_METHODS.previewOpen,
apps/server/src/auth/RpcAuthorization.ts+5
......@@ -126,6 +126,11 @@ export const RPC_REQUIRED_SCOPES = {
126126 [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope,
127127 [WS_METHODS.mergebotQueue]: AuthOrchestrationReadScope,
128128 [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,
129134 [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,
130135 [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope,
131136 [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope,
apps/server/src/git/GitManager.test.ts+6
......@@ -927,6 +927,12 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
927927 mergebot: {
928928 configuration: { origin: "https://mergebot.example", repositories: ["work/app"] },
929929 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"),
930936 watchQueue: () => Stream.empty,
931937 streamChanges: Stream.empty,
932938 readPullRequest: () => Effect.sync(() => value),
apps/server/src/git/GitWorkflowService.test.ts+32
......@@ -8,6 +8,7 @@ import { VcsRepositoryDetectionError } from "@t3tools/contracts";
88
99import * as NodeServices from "@effect/platform-node/NodeServices";
1010import * as FileSystem from "effect/FileSystem";
11import * as Path from "effect/Path";
1112
1213import * as ServerConfig from "../config.ts";
1314import * as VcsProcess from "../vcs/VcsProcess.ts";
......@@ -27,6 +28,7 @@ function layer(input: {
2728 ),
2829 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
2930 Layer.provide(Layer.mock(GitManager.GitManager)({})),
31 Layer.provide(NodeServices.layer),
3032 );
3133}
3234
......@@ -86,6 +88,35 @@ describe("GitWorkflowService", () => {
8688 ),
8789 );
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
89120 it.effect("returns an empty full status when no VCS repository is detected", () =>
90121 Effect.gen(function* () {
91122 const workflow = yield* GitWorkflowService.GitWorkflowService;
......@@ -136,6 +167,7 @@ describe("GitWorkflowService", () => {
136167 status,
137168 }),
138169 ),
170 Layer.provide(NodeServices.layer),
139171 );
140172
141173 return Effect.gen(function* () {
apps/server/src/git/GitWorkflowService.ts+40-30
......@@ -1,6 +1,9 @@
11import * as Context from "effect/Context";
22import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import { mergeGitStatusParts } from "@t3tools/shared/git";
47
58import {
69 GitManagerError,
......@@ -127,32 +130,6 @@ export class GitWorkflowService extends Context.Service<
127130 }
128131>()("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
156133function nonRepositoryListRefs(): VcsListRefsResult {
157134 return {
158135 refs: [],
......@@ -168,6 +145,37 @@ export const make = Effect.gen(function* () {
168145 const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
169146 const git = yield* GitVcsDriver.GitVcsDriver;
170147 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
172180 const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* (
173181 operation: string,
......@@ -331,15 +339,17 @@ export const make = Effect.gen(function* () {
331339 status: (input) =>
332340 detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe(
333341 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 ),
335347 ),
336348 ),
337349 localStatus: (input) =>
338350 detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe(
339351 Effect.flatMap((isGitRepository) =>
340 isGitRepository
341 ? gitManager.localStatus(input)
342 : Effect.succeed(nonRepositoryLocalStatus()),
352 isGitRepository ? gitManager.localStatus(input) : nonGitLocalStatus(input.cwd),
343353 ),
344354 ),
345355 remoteStatus: (input, options) =>
apps/server/src/mergebot/MergebotQueue.test.ts+270-10
......@@ -51,6 +51,16 @@ const check = {
5151 startedAt: "2026-10-14T10:00:00Z",
5252 completedAt: "2026-10-14T10:01:00Z",
5353};
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};
5464const repoChannel = { kind: "repo_overview", repoId: repository };
5565const detailChannel = { kind: "pull_request_detail", repoId: repository, prNumber: 123 };
5666const repoSnapshot = {
......@@ -59,8 +69,13 @@ const repoSnapshot = {
5969 channel: repoChannel,
6070 channelKey: `repo_overview:${repository}`,
6171 payload: {
62 entities: { repos: { [repository]: { processing: true } }, pullRequests: { [pr.id]: pr } },
63 view: { repoId: repository, queuePrIds: [pr.id] },
72 entities: {
73 repos: { [repository]: repo },
74 users: {},
75 pullRequests: { [pr.id]: pr },
76 mergedCommits: {},
77 },
78 view: { repoId: repository, queuePrIds: [pr.id], mergedCommitIds: [] },
6479 },
6580};
6681const detailSnapshot = {
......@@ -122,11 +137,26 @@ function harness(configurationText: string | null = JSON.stringify(configuration
122137 const sockets: TestSocket[] = [];
123138 const exchanges: Array<{ url: string; authorization: string | undefined }> = [];
124139 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 });
125148 const requests: Array<{ url: string; options?: Socket.WebSocketConstructorOptions }> = [];
126149 return {
127150 sockets,
128151 requests,
129152 exchanges,
153 mutations,
154 respondWith: (handler: typeof respond) => {
155 respond = handler;
156 },
157 setSession: (value: string) => {
158 session = value;
159 },
130160 setExchangeStatus: (status: number) => {
131161 exchangeStatus = status;
132162 },
......@@ -143,13 +173,26 @@ function harness(configurationText: string | null = JSON.stringify(configuration
143173 Layer.succeed(
144174 HttpClient.HttpClient,
145175 HttpClient.make((request) => {
146 exchanges.push({ url: request.url, authorization: request.headers.authorization });
147 return Effect.succeed(
148 HttpClientResponse.fromWeb(
149 request,
150 Response.json({ token: "sealed-mergebot-session" }, { status: exchangeStatus }),
151 ),
152 );
176 if (request.url.endsWith("/api/auth/github/token")) {
177 exchanges.push({ url: request.url, authorization: request.headers.authorization });
178 return Effect.succeed(
179 HttpClientResponse.fromWeb(
180 request,
181 Response.json({ token: session }, { status: exchangeStatus }),
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)));
153196 }),
154197 ),
155198 Layer.succeed(HostProcess.HomeDirectory, "/test"),
......@@ -553,7 +596,7 @@ describe("MergebotQueue", () => {
553596 ...repoSnapshot.payload,
554597 entities: {
555598 ...repoSnapshot.payload.entities,
556 repos: { [repository]: { processing: false } },
599 repos: { [repository]: { ...repo, processing: false } },
557600 },
558601 },
559602 });
......@@ -664,4 +707,221 @@ describe("MergebotQueue", () => {
664707 }).pipe(Effect.provide(h.layer));
665708 },
666709 );
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 });
667927});
apps/server/src/mergebot/MergebotQueue.ts+294-75
......@@ -1,11 +1,19 @@
11import {
22 MergebotConfiguration,
3 MergebotDeployments,
4 MergebotOpenPullRequestResult,
5 MergebotOperationError,
36 MergebotPullRequestCheck,
7 type MergebotDeployProductionInput,
8 type MergebotOpenPullRequestInput,
49 MergebotPullRequestDetails,
10 MergebotPullRequestLiveStatus,
511 MergebotPullRequestStatus,
612 type MergebotPullRequestInput,
713 type MergebotQueueInput,
814 MergebotRepoQueue,
15 type MergebotSetAutoMergeInput,
16 type MergebotUpdatePullRequestInput,
917 PullRequestActor,
1018} from "@t3tools/contracts";
1119import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials";
......@@ -25,19 +33,8 @@ import * as Schema from "effect/Schema";
2533import * as Stream from "effect/Stream";
2634import * as Socket from "effect/socket/Socket";
2735import { 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});
4138const Comment = Schema.Struct({
4239 id: Schema.String,
4340 kind: Schema.Literals(["comment", "review", "review_comment"]),
......@@ -55,13 +52,6 @@ const DetailChannel = Schema.Struct({
5552 repoId: Schema.String,
5653 prNumber: Schema.Number,
5754});
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});
6555const Message = Schema.Union([
6656 Schema.Struct({
6757 type: Schema.Literal("snapshot"),
......@@ -98,6 +88,12 @@ const Message = Schema.Union([
9888const decodeMessage = Schema.decodeEffect(Schema.fromJsonString(Message));
9989const encodeStatus = Schema.encodeSync(MergebotPullRequestStatus);
10090const 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);
10197const decodeSession = Schema.decodeUnknownEffect(
10298 Schema.Struct({
10399 token: Schema.String.check(Schema.isMinLength(1)),
......@@ -125,6 +121,24 @@ export class MergebotQueue extends Context.Service<
125121 readonly repository: string;
126122 readonly headBranch: string;
127123 }) => 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>;
128142 readonly streamChanges: Stream.Stream<Change>;
129143 }
130144>()("t3/mergebot/MergebotQueue") {}
......@@ -165,7 +179,7 @@ const make = Effect.gen(function* () {
165179 const constructSocket = yield* Socket.WebSocketConstructor;
166180 const scope = yield* Effect.scope;
167181 const changes = yield* PubSub.unbounded<Change>();
168 const repos = new Map<string, typeof RepoPayload.Type | null>();
182 const repos = new Map<string, RepoPayload | null>();
169183 const details = new Map<string, MergebotPullRequestDetails | null>();
170184 const channels = new Map<string, Channel>();
171185 const observations = new Map<string, number>();
......@@ -175,6 +189,9 @@ const make = Effect.gen(function* () {
175189 let started = false;
176190 let account: string | undefined;
177191 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
179196 const repositoryFor = (repository: string) =>
180197 configuration?.repositories.find((repo) => repo.toLowerCase() === repository.toLowerCase());
......@@ -268,10 +285,7 @@ const make = Effect.gen(function* () {
268285 yield* invalidate(message.channelKey);
269286 return;
270287 }
271 repos.set(repository, {
272 entities: { repos: entities.repos, pullRequests: entities.pullRequests },
273 view,
274 });
288 repos.set(repository, { entities, view });
275289 for (const record of pullRequestRecords) {
276290 const key = channelKey({
277291 kind: "pull_request_detail",
......@@ -351,16 +365,13 @@ const make = Effect.gen(function* () {
351365 yield* PubSub.publish(changes, { repository, number: message.channel.prNumber });
352366 });
353367
354 const connect = Effect.gen(function* () {
355 if (!configuration) return;
356 const credential = yield* credentials.get("github.com");
357 if (account !== undefined && account !== credential.fingerprint) {
358 yield* invalidate();
359 }
360 account = credential.fingerprint;
368 const exchange = Effect.fnUntraced(function* (
369 origin: string,
370 credential: GitHubCredentials.GitHubCredential,
371 ) {
361372 const response = yield* http
362373 .execute(
363 HttpClientRequest.post(`${configuration.origin}/api/auth/github/token`).pipe(
374 HttpClientRequest.post(`${origin}/api/auth/github/token`).pipe(
364375 HttpClientRequest.setHeader(
365376 "Authorization",
366377 `Bearer ${Redacted.value(credential.token)}`,
......@@ -376,8 +387,19 @@ const make = Effect.gen(function* () {
376387 if (response.status === 401) yield* credentials.invalidate("github.com");
377388 return yield* new MergebotAuthenticationError({ status: response.status });
378389 }
379 const session = yield* response.json.pipe(Effect.flatMap(decodeSession));
380 const token = Redacted.make(session.token);
390 const { token } = yield* response.json.pipe(Effect.flatMap(decodeSession));
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);
381403 const socket = yield* Socket.fromWebSocket(
382404 Effect.acquireRelease(
383405 Effect.sync(() => {
......@@ -432,7 +454,7 @@ const make = Effect.gen(function* () {
432454 const releaseIdleChannels = Effect.gen(function* () {
433455 const now = yield* Clock.currentTimeMillis;
434456 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;
436458 lastReadAt.delete(key);
437459 channels.delete(key);
438460 details.delete(key);
......@@ -498,21 +520,9 @@ const make = Effect.gen(function* () {
498520 return (yield* authorize()) === fingerprint ? value : null;
499521 });
500522
501 const readPullRequest = Effect.fn("MergebotQueue.readPullRequest")(function* (
502 input: MergebotPullRequestInput,
503 ) {
504 const repository = repositoryFor(input.repository);
505 if (!repository) return null;
506 const fingerprint = yield* authorize();
507 if (fingerprint === null) return null;
508 yield* start();
509 const channel = {
510 kind: "pull_request_detail",
511 repoId: repository,
512 prNumber: input.number,
513 } as const;
523 const subscribeDetail = Effect.fnUntraced(function* (repository: string, number: number) {
524 const channel = { kind: "pull_request_detail", repoId: repository, prNumber: number } as const;
514525 const key = channelKey(channel);
515 lastReadAt.set(key, yield* Clock.currentTimeMillis);
516526 if (!channels.has(key)) {
517527 channels.set(key, channel);
518528 if (writer)
......@@ -521,46 +531,189 @@ const make = Effect.gen(function* () {
521531 .pipe(Effect.orElseSucceed(() => undefined));
522532 }
523533 yield* retryChannel(key);
524 return yield* waitFor(fingerprint, () => details.get(key));
534 return key;
525535 });
526536
527 const read = Effect.fn("MergebotQueue.read")(function* (input: MergebotQueueInput) {
528 const repository = repositoryFor(input.repository);
537 /** Resolves a configured repository for the current account and keeps its overview live. */
538 const openOverview = Effect.fnUntraced(function* (input: string) {
539 const repository = repositoryFor(input);
529540 if (!repository) return null;
530541 const fingerprint = yield* authorize();
531542 if (fingerprint === null) return null;
532543 yield* start();
533544 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 });
535705 });
536706
537707 return MergebotQueue.of({
538708 configuration,
539709 read,
540 watchQueue: (input) =>
541 Stream.unwrap(
542 Effect.gen(function* () {
543 const subscription = yield* PubSub.subscribe(changes);
544 return Stream.concat(
545 Stream.fromEffect(read(input)),
546 Stream.fromSubscription(subscription).pipe(
547 Stream.filter(
548 (change) => change.repository.toLowerCase() === input.repository.toLowerCase(),
549 ),
550 Stream.mapEffect(() => read(input)),
551 ),
552 );
553 }),
554 ).pipe(Stream.changesWith(queueEquivalence)),
710 watchQueue: (input) => watch(input.repository, read(input), queueEquivalence),
555711 readPullRequest,
556712 findPullRequest: Effect.fn("MergebotQueue.findPullRequest")(function* (input) {
557 const repository = repositoryFor(input.repository);
558 if (!repository) return null;
559 const fingerprint = yield* authorize();
560 if (fingerprint === null) return null;
561 yield* start();
562 yield* retryChannel(channelKey({ kind: "repo_overview", repoId: repository }));
563 const queue = yield* waitFor(fingerprint, () => queueFor(repository));
713 const opened = yield* openOverview(input.repository);
714 if (!opened) return null;
715 const { repository } = opened;
716 const queue = yield* waitFor(opened.fingerprint, () => queueFor(repository));
564717 if (!queue || queue.status !== "ready") return null;
565718 const matches = Object.values(repos.get(repository)?.entities.pullRequests ?? {})
566719 .filter((pr) => pr.headBranch === input.headBranch)
......@@ -572,6 +725,72 @@ const make = Effect.gen(function* () {
572725 const match = matches?.[0];
573726 return match ? yield* readPullRequest({ repository, number: match.number }) : null;
574727 }),
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 }),
575794 streamChanges: Stream.fromPubSub(changes),
576795 });
577796});
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 = {
101101 [WS_METHODS.cloudInstallRelayClient]: "cloud",
102102 [WS_METHODS.mergebotQueue]: "mergebot",
103103 [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",
104116 [WS_METHODS.pullRequestsList]: "pull-requests",
105117 [WS_METHODS.pullRequestsListStats]: "pull-requests",
106118 [WS_METHODS.pullRequestsSummary]: "pull-requests",
apps/server/src/orchestration-v2/PullRequestWatchReactor.test.ts+6
......@@ -299,6 +299,12 @@ it.effect(
299299 mergebot: {
300300 configuration: { origin: "https://mergebot.example", repositories: [key.repository] },
301301 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"),
302308 watchQueue: () => Stream.empty,
303309 findPullRequest: () => Effect.succeed(null),
304310 readPullRequest: () =>
apps/server/src/pullRequest/PullRequestService.test.ts+6
......@@ -7959,6 +7959,12 @@ it.effect(
79597959 mergebot: {
79607960 configuration: { origin: "https://mergebot.example", repositories: ["work/app"] },
79617961 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"),
79627968 watchQueue: () => Stream.empty,
79637969 readPullRequest: () => Effect.sync(() => value),
79647970 findPullRequest: () => Effect.succeed(null),
apps/server/src/server.ts+2
......@@ -126,6 +126,7 @@ import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
126126import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
127127import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
128128import * as VcsProcess from "./vcs/VcsProcess.ts";
129import * as JjStackService from "./vcs/JjStackService.ts";
129130import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
130131import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
131132import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
......@@ -658,6 +659,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
658659 Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)),
659660 Layer.provideMerge(layerServerSettings),
660661 Layer.provideMerge(MergebotQueue.layer),
662 Layer.provideMerge(JjStackService.layer),
661663 // The asset route uses the registry's GitHub credential for private PR media, which the
662664 // built-in drivers' layer provides alongside the registry.
663665 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
188188import * as PortScanner from "./preview/PortScanner.ts";
189189import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
190190import * as MergebotQueue from "./mergebot/MergebotQueue.ts";
191import * as JjStackService from "./vcs/JjStackService.ts";
191192import * as DevServer from "./workspace/DevServer.ts";
192193import * as ManagedWorkspaces from "./workspace/ManagedWorkspaces.ts";
193194import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
......@@ -1233,6 +1234,7 @@ const layerWsRpc = (
12331234 const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup;
12341235 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
12351236 const devServer = yield* DevServer.DevServer;
1237 const jjStack = yield* JjStackService.JjStackService;
12361238 const managedWorkspaces = yield* ManagedWorkspaces.ManagedWorkspaces;
12371239 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
12381240 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
......@@ -2419,6 +2421,13 @@ const layerWsRpc = (
24192421 ),
24202422 [WS_METHODS.mergebotQueue]: (input) => mergebotQueue.read(input),
24212423 [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),
24222431 [WS_METHODS.pullRequestsList]: (input) => pullRequests.list(input),
24232432 [WS_METHODS.pullRequestsListStats]: (input) => pullRequests.listStats(input),
24242433 [WS_METHODS.pullRequestsRoutingIdentity]: (input) => pullRequests.routingIdentity(input),
......@@ -2611,6 +2620,12 @@ const layerWsRpc = (
26112620 [WS_METHODS.managedWorkspacesArchiveCompleted]: (input) =>
26122621 managedWorkspaces.archiveCompleted(input),
26132622 [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),
26142629 [WS_METHODS.devServerStart]: (input) => devServer.start(input),
26152630 [WS_METHODS.projectsReadFile]: (input) =>
26162631 workspaceFileSystem.readFile(input).pipe(
apps/web/src/closedViewStore.ts+1
......@@ -63,6 +63,7 @@ const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => {
6363 case "diff":
6464 case "files":
6565 case "pull-requests":
66 case "deployments":
6667 return surface.id === surface.kind;
6768 case "preview":
6869 return surface.resourceId === null || typeof surface.resourceId === "string";
apps/web/src/components/AppSidebarLayout.tsx-8
......@@ -35,7 +35,6 @@ import LegacyThreadSidebar from "./LegacySidebar";
3535import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration";
3636import ThreadSidebar from "./Sidebar";
3737import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
38import { NewWorkWorkspaceButton } from "./sidebar/NewWorkWorkspaceButton";
3938import { AppMenu } from "./sidebar/AppMenu";
4039import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
4140import { MainAppLocationTracker } from "./sidebar/mainAppLocation";
......@@ -54,7 +53,6 @@ import {
5453 SidebarRail,
5554 SidebarTrigger,
5655 useSidebar,
57 useSidebarVisibility,
5856} from "./ui/sidebar";
5957import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
6058
......@@ -85,7 +83,6 @@ function SidebarControl() {
8583 const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" });
8684 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
8785 const { toggleSidebar, isMobile, mobileSheetMounted } = useSidebar();
88 const isSidebarVisible = useSidebarVisibility();
8986 const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", {
9087 context: { usagePageOpen },
9188 });
......@@ -156,11 +153,6 @@ function SidebarControl() {
156153 Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""}
157154 </TooltipPopup>
158155 </Tooltip>
159 {isSidebarVisible ? (
160 <div className="pointer-events-auto ml-2 [-webkit-app-region:no-drag]">
161 <NewWorkWorkspaceButton />
162 </div>
163 ) : null}
164156 </div>
165157 );
166158}
apps/web/src/components/ChatView.tsx+16-6
......@@ -1,3 +1,4 @@
1import { supportsWorkspaceChanges } from "./review/jjReview.logic";
12import { ThreadFind, ThreadFindCanvas, type ThreadFindControls } from "./chat/ThreadFindProvider";
23import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar";
34import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner";
......@@ -718,7 +719,10 @@ function useDraftHeroLayoutTransition(
718719const PreviewPanel = lazy(() =>
719720 import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })),
720721);
721const DiffPanel = lazy(() => import("./DiffPanel"));
722const DiffPanel = lazy(() => import("./review/DiffSurface"));
723const DeploymentsPanel = lazy(() =>
724 import("./deployments/DeploymentsPanel").then((module) => ({ default: module.DeploymentsPanel })),
725);
722726const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) =>
723727 settings.browserAutoShowFloatingPreview;
724728const DevicePanel = lazy(() =>
......@@ -4287,6 +4291,8 @@ export default function ChatView(props: ChatViewProps) {
42874291 }
42884292 }, [environmentId, gitStatusCwd, liveIsGitRepo]);
42894293 const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true;
4294 const workspaceChangesAvailable =
4295 isServerThread && supportsWorkspaceChanges({ ...gitStatusQuery.data, isRepo: isGitRepo });
42904296 // When context is enabled, keep a hidden, off-flow strip mounted so the composer
42914297 // can measure whether its relocated controls fit. The visible chrome remains
42924298 // content-driven: Git/environment context or controls that actually fit.
......@@ -5554,11 +5560,11 @@ export default function ChatView(props: ChatViewProps) {
55545560 [activeThreadRef, canOperatePreview, openPreview],
55555561 );
55565562 const addDiffSurface = useCallback(() => {
5557 if (!activeThreadRef || !isServerThread || !isGitRepo) return;
5563 if (!activeThreadRef || !workspaceChangesAvailable) return;
55585564 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch");
55595565 useRightPanelStore.getState().open(activeThreadRef, "diff");
55605566 onDiffPanelOpen?.();
5561 }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]);
5567 }, [activeThreadRef, workspaceChangesAvailable, onDiffPanelOpen]);
55625568 const openChangesFromThreadPanel = useCallback(() => {
55635569 addDiffSurface();
55645570 }, [addDiffSurface]);
......@@ -11278,6 +11284,10 @@ export default function ChatView(props: ChatViewProps) {
1127811284 />
1127911285 ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? (
1128011286 <ThreadPullRequestsPanel threadRef={activeThreadRef} />
11287 ) : renderedRightPanelSurface?.kind === "deployments" && activeThreadRef ? (
11288 <Suspense fallback={null}>
11289 <DeploymentsPanel environmentId={activeThreadRef.environmentId} />
11290 </Suspense>
1128111291 ) : renderedRightPanelSurface?.kind === "device" ? (
1128211292 <Suspense fallback={null}>
1128311293 <DevicePanel
......@@ -11382,7 +11392,7 @@ export default function ChatView(props: ChatViewProps) {
1138211392 ? { onCheckoutPullRequestRequest: openPullRequestDialog }
1138311393 : {}),
1138411394 onComposerFocusRequest: scheduleComposerFocus,
11385 ...(isServerThread && isGitRepo ? { onOpenChanges: openChangesFromThreadPanel } : {}),
11395 ...(workspaceChangesAvailable ? { onOpenChanges: openChangesFromThreadPanel } : {}),
1138611396 onRunProjectScript: runProjectScript,
1138711397 onAddProjectScript: saveProjectScript,
1138811398 onUpdateProjectScript: updateProjectScript,
......@@ -12192,7 +12202,7 @@ export default function ChatView(props: ChatViewProps) {
1219212202 onAddDevice={addDeviceSurface}
1219312203 browserAvailable={canOperatePreview && browserAvailable}
1219412204 terminalAvailable={activeProject !== null && canOperateTerminal}
12195 diffAvailable={isServerThread && isGitRepo}
12205 diffAvailable={workspaceChangesAvailable}
1219612206 filesAvailable={activeProject !== null}
1219712207 pullRequestAvailable={pullRequestSurfaceAvailable}
1219812208 pullRequestsAvailable={pullRequestsSurfaceAvailable}
......@@ -12251,7 +12261,7 @@ export default function ChatView(props: ChatViewProps) {
1225112261 onAddDevice={addDeviceSurface}
1225212262 browserAvailable={canOperatePreview && browserAvailable}
1225312263 terminalAvailable={activeProject !== null && canOperateTerminal}
12254 diffAvailable={isServerThread && isGitRepo}
12264 diffAvailable={workspaceChangesAvailable}
1225512265 filesAvailable={activeProject !== null}
1225612266 pullRequestAvailable={pullRequestSurfaceAvailable}
1225712267 pullRequestsAvailable={pullRequestsSurfaceAvailable}
apps/web/src/components/GitActionsControl.lifecycle.test.ts+32-1
......@@ -11,6 +11,7 @@ const state = vi.hoisted(() => ({
1111 toasts: [] as { type?: string; description?: string; actionProps?: { onClick: () => void } }[],
1212 scopes: new Set<string>(),
1313 primaryScopes: new Set<string>(),
14 isRepo: true,
1415}));
1516
1617vi.mock("@effect/atom-react", () => ({
......@@ -40,7 +41,7 @@ vi.mock("~/state/query", () => ({
4041 useEnvironmentQuery: () => ({
4142 data: {
4243 sourceControlProviders: [],
43 isRepo: true,
44 isRepo: state.isRepo,
4445 refName: "main",
4546 isDefaultRef: false,
4647 hasPrimaryRemote: true,
......@@ -191,6 +192,7 @@ beforeEach(() => {
191192 state.primaryScopes = new Set([AuthSourceControlWriteScope]);
192193 state.actions = [];
193194 state.toasts = [];
195 state.isRepo = true;
194196});
195197
196198afterEach(async () => {
......@@ -200,6 +202,35 @@ afterEach(async () => {
200202});
201203
202204describe("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
203234 it("runs a retained PR toast in the thread that produced it", async () => {
204235 const renderer = await renderActions("first", "/first");
205236 await clickQuickAction(renderer);
apps/web/src/components/GitActionsControl.tsx+26-29
......@@ -14,15 +14,7 @@ import type {
1414 GitStackedAction,
1515 VcsStatusResult,
1616} from "@t3tools/contracts";
17import {
18 type MouseEvent,
19 useCallback,
20 useEffect,
21 useEffectEvent,
22 useMemo,
23 useRef,
24 useState,
25} from "react";
17import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
2618import {
2719 CheckIcon,
2820 ChevronDownIcon,
......@@ -1073,7 +1065,30 @@ export default function GitActionsControl({
10731065 </>
10741066 );
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
10781093 return (
10791094 <>
......@@ -1248,25 +1263,7 @@ export default function GitActionsControl({
12481263 </ActionGroup>
12491264 )}
12501265
1251 {isPanel && isRepo ? (
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}
1266 {changesControl}
12701267
12711268 <Dialog
12721269 open={isCommitDialogOpen}
apps/web/src/components/RightPanelTabs.keyboard.test.tsx+16
......@@ -9,6 +9,7 @@ import {
99} from "@t3tools/shared/keybindings";
1010
1111import { RightPanelTabs } from "./RightPanelTabs";
12import { supportsWorkspaceChanges } from "./review/jjReview.logic";
1213
1314vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }) }));
1415vi.mock("~/browser/browserDefaults", () => ({ useBrowserDefaults: () => ({ profiles: [] }) }));
......@@ -110,6 +111,21 @@ async function press(key: string, options: KeyboardEventInit = {}) {
110111}
111112
112113describe("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
113129 it.each(["MacIntel", "Win32", "Linux x86_64"])(
114130 "opens the menu on %s and chooses a tab",
115131 async (platform) => {
apps/web/src/components/RightPanelTabs.tsx+7-2
......@@ -35,6 +35,7 @@ import {
3535 Globe2,
3636 Plus,
3737 TerminalSquare,
38 RocketIcon,
3839} from "lucide-react";
3940import { Volume2, VolumeOff } from "lucide";
4041import {
......@@ -174,7 +175,7 @@ const SURFACE_DISABLED_REASONS = {
174175 browser: "Browser previews are only available in The Knife desktop app.",
175176 terminal: "Terminal surfaces are only available from a project thread.",
176177 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.",
178179 pullRequest: "This thread's branch has no pull request yet.",
179180 pullRequests: "No linked pull requests are available for this thread.",
180181 device: "Devices are only available from a thread.",
......@@ -197,7 +198,7 @@ const SURFACE_UNAVAILABLE_HINTS = {
197198 browser: "Only available in the desktop app.",
198199 terminal: "Available when a project is open.",
199200 files: "Available when a project is open.",
200 diff: "Available for Git repositories.",
201 diff: "Available for Git or Jujutsu workspaces.",
201202 pullRequest: "No pull request on this branch yet.",
202203 pullRequests: "No linked pull requests available.",
203204 device: "Available from a thread.",
......@@ -618,6 +619,8 @@ function surfaceTitle(
618619 return `#${surface.number}`;
619620 case "pull-requests":
620621 return "Pull requests";
622 case "deployments":
623 return "Deployments";
621624 case "device":
622625 return surface.title ?? surface.target?.name ?? "Device";
623626 case "preview": {
......@@ -701,6 +704,8 @@ function SurfaceIcon({
701704 );
702705 case "pull-requests":
703706 return <PullRequestGlyph.link className="size-3 shrink-0" />;
707 case "deployments":
708 return <RocketIcon className="size-3 shrink-0" />;
704709 case "device":
705710 return surface.target?.platform === "ios" ? (
706711 <AppleIcon className="size-3 shrink-0" />
apps/web/src/components/Sidebar.tsx+15-1
......@@ -105,6 +105,7 @@ import {
105105} from "react";
106106import { useParams, useRouter } from "@tanstack/react-router";
107107
108import { ThreadMergebotPill, useIsMergebotRepository } from "./review/ThreadMergebotPill";
108109import { useRightPanelStore } from "../rightPanelStore";
109110import {
110111 isAtomCommandInterrupted,
......@@ -1299,6 +1300,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12991300 const currentLinkedPr = supportsMultiplePullRequests
13001301 ? resolveThreadCurrentPullRequestLink(thread.pullRequests)
13011302 : null;
1303 const mergebotPullRequest = useIsMergebotRepository(
1304 thread.environmentId,
1305 currentLinkedPr?.repository,
1306 )
1307 ? currentLinkedPr
1308 : null;
13021309
13031310 // Same semantics as the legacy sidebar (never-visited counts as read):
13041311 // switching sidebars must not light up every historical thread as unread.
......@@ -2201,6 +2208,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
22012208 ) : null}
22022209 </div>
22032210 <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}
22042218 {/* Always the branch. The plan step used to take this slot while
22052219 working, but it truncated to a half-sentence and dropped the
22062220 branch, so the row lost its most stable identifier. */}
......@@ -2226,7 +2240,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
22262240 <span className="flex-1" />
22272241 )}
22282242 {terminalStatusIcon}
2229 {prBadge}
2243 {mergebotPullRequest ? null : prBadge}
22302244 {diff ? (
22312245 <span className="shrink-0 font-mono">
22322246 <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 {
8686 options: StyledDiffCodeViewOptions<DiffCommentAnnotationGroup>;
8787 viewerRef?: Ref<AnnotatableCodeViewHandle>;
8888 className?: string;
89 renderCodeViewHeader?: () => ReactNode;
8990 renderCodeViewFooter?: () => ReactNode;
9091 unsafeCSSExtra?: string;
9192 renderHeaderMetadata?: (fileDiff: FileDiffMetadata) => ReactNode;
......@@ -112,6 +113,7 @@ export function AnnotatableCodeView({
112113 options,
113114 viewerRef,
114115 className,
116 renderCodeViewHeader,
115117 renderCodeViewFooter,
116118 unsafeCSSExtra,
117119 renderHeaderMetadata,
......@@ -261,6 +263,7 @@ export function AnnotatableCodeView({
261263 item.type === "diff" ? renderHeaderMetadata(item.fileDiff) : null,
262264 }
263265 : {})}
266 {...(renderCodeViewHeader ? { renderCodeViewHeader } : {})}
264267 {...(renderCodeViewFooter ? { renderCodeViewFooter } : {})}
265268 items={items}
266269 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";
2323import { AppMenu } from "./AppMenu";
2424import { SidebarMergebotButton } from "./SidebarMergebotButton";
2525import { SidebarUsageButton } from "./SidebarUsageButton";
26import { SidebarDeploymentsButton } from "./SidebarDeploymentsButton";
2627
2728export const SidebarChromeHeader = memo(function SidebarChromeHeader({
2829 isElectron,
......@@ -38,29 +39,33 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
3839
3940 return (
4041 // 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.
4143 <div
4244 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)",
4446 isElectron && "drag-region",
4547 )}
4648 >
47 {/* Match the fixed control's inset and border compensation 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">
49 <div className="flex items-center [-webkit-app-region:no-drag]">
50 <AppMenu />
49 {/* Match the fixed control's top inset inside the sheet's safe area. */}
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">
51 {/* Match the fixed control's inset and border compensation. */}
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" />
5157 </div>
52 <SidebarTrigger aria-label="Toggle main sidebar" />
53 <div className="ml-2">
58 {pillLabel ? (
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]">
5466 <NewWorkWorkspaceButton />
5567 </div>
5668 </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}
6469 </div>
6570 );
6671});
......@@ -101,7 +106,16 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
101106 return (
102107 <SidebarFooter>
103108 <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>
105119 <SidebarUtilityMenu />
106120 </SidebarFooter>
107121 );
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 @@
11import { useLocation, useNavigate } from "@tanstack/react-router";
22import { useCallback, useEffect } from "react";
33
4// Settings, Usage, and Pull Requests replace the sidebar utility row with a
4// Settings, Usage, Deployments, and Pull Requests replace the sidebar utility row with a
55// Back button. Everything else is the main app. Legacy `/projects/<key>` links
66// redirect into settings, so they count too and are never remembered.
77export function isSidebarUtilityPage(pathname: string) {
......@@ -10,6 +10,7 @@ export function isSidebarUtilityPage(pathname: string) {
1010 pathname.startsWith("/settings/") ||
1111 pathname.startsWith("/projects/") ||
1212 pathname === "/usage" ||
13 pathname === "/deployments" ||
1314 pathname === "/pull-requests"
1415 );
1516}
apps/web/src/rightPanelStore.ts+4
......@@ -30,6 +30,7 @@ const RIGHT_PANEL_KINDS = [
3030 "terminal",
3131 "pull-request",
3232 "pull-requests",
33 "deployments",
3334] as const;
3435export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];
3536
......@@ -53,6 +54,7 @@ export type RightPanelSurface =
5354 splitDirection?: "horizontal" | "vertical";
5455 }
5556 | { id: "diff"; kind: "diff" }
57 | { id: "deployments"; kind: "deployments" }
5658 | { id: "files"; kind: "files" }
5759 | {
5860 id: `file:${string}` | `attachment:${string}`;
......@@ -230,6 +232,8 @@ const singletonSurface = (
230232 return { id: "pull-requests", kind };
231233 case "device":
232234 return { id: "device", kind };
235 case "deployments":
236 return { id: "deployments", kind };
233237 }
234238};
235239
apps/web/src/routeTree.gen.ts+21
......@@ -13,6 +13,7 @@ import { Route as WelcomeRouteImport } from './routes/welcome'
1313import { Route as UsageRouteImport } from './routes/usage'
1414import { Route as SettingsRouteImport } from './routes/settings'
1515import { Route as PairRouteImport } from './routes/pair'
16import { Route as DeploymentsRouteImport } from './routes/deployments'
1617import { Route as ConnectAgentRouteImport } from './routes/connect-agent'
1718import { Route as ConnectRouteImport } from './routes/connect'
1819import { Route as ChatRouteImport } from './routes/_chat'
......@@ -57,6 +58,11 @@ const PairRoute = PairRouteImport.update({
5758 path: '/pair',
5859 getParentRoute: () => rootRouteImport,
5960} as any)
61const DeploymentsRoute = DeploymentsRouteImport.update({
62 id: '/deployments',
63 path: '/deployments',
64 getParentRoute: () => rootRouteImport,
65} as any)
6066const ConnectAgentRoute = ConnectAgentRouteImport.update({
6167 id: '/connect-agent',
6268 path: '/connect-agent',
......@@ -178,6 +184,7 @@ export interface FileRoutesByFullPath {
178184 '/': typeof ChatIndexRoute
179185 '/connect': typeof ConnectRoute
180186 '/connect-agent': typeof ConnectAgentRoute
187 '/deployments': typeof DeploymentsRoute
181188 '/pair': typeof PairRoute
182189 '/settings': typeof SettingsRouteWithChildren
183190 '/usage': typeof UsageRoute
......@@ -205,6 +212,7 @@ export interface FileRoutesByFullPath {
205212export interface FileRoutesByTo {
206213 '/connect': typeof ConnectRoute
207214 '/connect-agent': typeof ConnectAgentRoute
215 '/deployments': typeof DeploymentsRoute
208216 '/pair': typeof PairRoute
209217 '/settings': typeof SettingsRouteWithChildren
210218 '/usage': typeof UsageRoute
......@@ -235,6 +243,7 @@ export interface FileRoutesById {
235243 '/_chat': typeof ChatRouteWithChildren
236244 '/connect': typeof ConnectRoute
237245 '/connect-agent': typeof ConnectAgentRoute
246 '/deployments': typeof DeploymentsRoute
238247 '/pair': typeof PairRoute
239248 '/settings': typeof SettingsRouteWithChildren
240249 '/usage': typeof UsageRoute
......@@ -266,6 +275,7 @@ export interface FileRouteTypes {
266275 | '/'
267276 | '/connect'
268277 | '/connect-agent'
278 | '/deployments'
269279 | '/pair'
270280 | '/settings'
271281 | '/usage'
......@@ -293,6 +303,7 @@ export interface FileRouteTypes {
293303 to:
294304 | '/connect'
295305 | '/connect-agent'
306 | '/deployments'
296307 | '/pair'
297308 | '/settings'
298309 | '/usage'
......@@ -322,6 +333,7 @@ export interface FileRouteTypes {
322333 | '/_chat'
323334 | '/connect'
324335 | '/connect-agent'
336 | '/deployments'
325337 | '/pair'
326338 | '/settings'
327339 | '/usage'
......@@ -352,6 +364,7 @@ export interface RootRouteChildren {
352364 ChatRoute: typeof ChatRouteWithChildren
353365 ConnectRoute: typeof ConnectRoute
354366 ConnectAgentRoute: typeof ConnectAgentRoute
367 DeploymentsRoute: typeof DeploymentsRoute
355368 PairRoute: typeof PairRoute
356369 SettingsRoute: typeof SettingsRouteWithChildren
357370 UsageRoute: typeof UsageRoute
......@@ -389,6 +402,13 @@ declare module '@tanstack/react-router' {
389402 preLoaderRoute: typeof PairRouteImport
390403 parentRoute: typeof rootRouteImport
391404 }
405 '/deployments': {
406 id: '/deployments'
407 path: '/deployments'
408 fullPath: '/deployments'
409 preLoaderRoute: typeof DeploymentsRouteImport
410 parentRoute: typeof rootRouteImport
411 }
392412 '/connect-agent': {
393413 id: '/connect-agent'
394414 path: '/connect-agent'
......@@ -613,6 +633,7 @@ const rootRouteChildren: RootRouteChildren = {
613633 ChatRoute: ChatRouteWithChildren,
614634 ConnectRoute: ConnectRoute,
615635 ConnectAgentRoute: ConnectAgentRoute,
636 DeploymentsRoute: DeploymentsRoute,
616637 PairRoute: PairRoute,
617638 SettingsRoute: SettingsRouteWithChildren,
618639 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 @@
11import { useAtomValue } from "@effect/atom-react";
2import { createMergebotQueueAtomFamily } from "@t3tools/client-runtime/state/pull-requests";
3import type { EnvironmentId } from "@t3tools/contracts";
2import {
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";
410import { environmentCatalog } from "../connection/catalog";
511import { connectionAtomRuntime } from "../connection/runtime";
612
713const mergebotQueue = createMergebotQueueAtomFamily(connectionAtomRuntime);
14const mergebotPullRequestStatus = createMergebotPullRequestStatusAtomFamily(connectionAtomRuntime);
15const mergebotDeployments = createMergebotDeploymentsAtomFamily(connectionAtomRuntime);
816
9export function useMergebotQueue(environmentId: EnvironmentId, repository: string) {
10 const result = useAtomValue(mergebotQueue({ environmentId, input: { repository } }));
17/** A live Mergebot value, or null while disconnected, loading, or failed. */
18function useConnectedValue<A, E>(
19 environmentId: EnvironmentId,
20 result: AsyncResult.AsyncResult<A, E>,
21) {
1122 const connection = useAtomValue(environmentCatalog.stateAtom(environmentId));
1223 if (connection._tag !== "Success" || connection.value.phase !== "connected") return null;
1324 return result._tag === "Success" ? result.value : null;
1425}
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 @@
4343 "types": "./src/projectFaviconCache.ts",
4444 "default": "./src/projectFaviconCache.ts"
4545 },
46 "./mergebot-status-pill": {
47 "types": "./src/mergebotStatusPill.ts",
48 "default": "./src/mergebotStatusPill.ts"
49 },
4650 "./user-message": {
4751 "types": "./src/userMessage.ts",
4852 "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 =
6969 | typeof WS_METHODS.subscribeResourceTelemetry
7070 | typeof WS_METHODS.pullRequestsSubscribeRefreshes
7171 | typeof WS_METHODS.mergebotSubscribe
72 | typeof WS_METHODS.mergebotSubscribePullRequestStatus
73 | typeof WS_METHODS.mergebotSubscribeDeployments
74 | typeof WS_METHODS.jjSubscribeStack
7275 | typeof WS_METHODS.subscribeVcsStatus
7376 | typeof WS_METHODS.subscribeWorktreeSetup
7477 | typeof WS_METHODS.subscribeProjectClones
packages/client-runtime/src/state/commandPermissions.test.ts+34
......@@ -86,6 +86,40 @@ describe("command permissions", () => {
8686 ),
8787 );
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
89123 it.effect("checks destination grants before creating a managed workspace", () =>
90124 Effect.scoped(
91125 Effect.gen(function* () {
packages/client-runtime/src/state/pullRequests.ts+18
......@@ -628,3 +628,21 @@ export function createMergebotQueueAtomFamily<R, E>(
628628 tag: WS_METHODS.mergebotSubscribe,
629629 });
630630}
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 = {
2323 // Endpoint checks exist to configure exports, so they need the same grant as saving them.
2424 [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope,
2525 [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,
2633 [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope,
2734 [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope,
2835 [WS_METHODS.pullRequestsUpdateComment]: AuthSourceControlWriteScope,
packages/contracts/src/git.ts+1
......@@ -225,6 +225,7 @@ const VcsStatusChangeRequest = Schema.Struct({
225225
226226const VcsStatusLocalShape = {
227227 isRepo: Schema.Boolean,
228 isJjWorkspace: Schema.optional(Schema.Boolean),
228229 sourceControlProvider: Schema.optional(SourceControlProviderInfo),
229230 hasPrimaryRemote: Schema.Boolean,
230231 isDefaultRef: Schema.Boolean,
packages/contracts/src/index.ts+1
......@@ -68,6 +68,7 @@ export * from "./clientRpcPermissions.ts";
6868export * from "./devServer.ts";
6969export * from "./managedWorkspaces.ts";
7070export * from "./mergebot.ts";
71export * from "./jj.ts";
7172export * from "./scriptProgress.ts";
7273export * 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(
154154 ? { host: "github.com", repository, number }
155155 : null;
156156}
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 {
1616 ManagedWorkspaceArchiveInput,
1717 ManagedWorkspaceArchived,
1818} 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";
2044import { DevServerTarget, DevServerStatus, DevServerError } from "./devServer.ts";
2145import {
2246 OrchestrationV2SearchThreadError,
......@@ -560,6 +584,18 @@ export const WS_METHODS = {
560584 // Pull request methods
561585 mergebotQueue: "mergebot.queue",
562586 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",
563599 pullRequestsList: "pullRequests.list",
564600 pullRequestsListStats: "pullRequests.listStats",
565601 pullRequestsSummary: "pullRequests.summary",
......@@ -1019,6 +1055,88 @@ const WsMergebotSubscribeRpc = Rpc.make(WS_METHODS.mergebotSubscribe, {
10191055 stream: true,
10201056});
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
10221140const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, {
10231141 payload: PullRequestListInput,
10241142 success: PullRequestListResult,
......@@ -2066,6 +2184,18 @@ export const WsRpcGroup = RpcGroup.make(
20662184 WsCloudInstallRelayClientRpc,
20672185 WsMergebotQueueRpc,
20682186 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,
20692199 WsPullRequestsListRpc,
20702200 WsPullRequestsListStatsRpc,
20712201 WsPullRequestsSummaryRpc,
packages/shared/src/git.test.ts+1
......@@ -259,6 +259,7 @@ describe("applyGitStatusStreamEvent", () => {
259259 it("preserves local-only fields when applying a remote update", () => {
260260 const current: VcsStatusResult = {
261261 isRepo: true,
262 isJjWorkspace: true,
262263 sourceControlProvider: {
263264 kind: SourceControlProviderKind.make("github"),
264265 name: "GitHub",
packages/shared/src/git.ts+1
......@@ -388,6 +388,7 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult {
388388function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult {
389389 return {
390390 isRepo: status.isRepo,
391 ...(status.isJjWorkspace === undefined ? {} : { isJjWorkspace: status.isJjWorkspace }),
391392 ...(status.sourceControlProvider
392393 ? { sourceControlProvider: status.sourceControlProvider }
393394 : {}),