From df271d779ac3c09fbcb75b04c3bbb7791b7edb0b Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 15 Oct 2026 14:27:26 -0700 Subject: [PATCH] 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 --- apps/server/src/auth/RpcAuthorization.test.ts | 24 +- apps/server/src/auth/RpcAuthorization.ts | 5 + apps/server/src/git/GitManager.test.ts | 6 + .../server/src/git/GitWorkflowService.test.ts | 32 + apps/server/src/git/GitWorkflowService.ts | 70 +- .../server/src/mergebot/MergebotQueue.test.ts | 280 +++++++- apps/server/src/mergebot/MergebotQueue.ts | 373 +++++++--- apps/server/src/mergebot/overview.test.ts | 263 +++++++ apps/server/src/mergebot/overview.ts | 218 ++++++ .../src/observability/RpcInstrumentation.ts | 12 + .../PullRequestWatchReactor.test.ts | 6 + .../pullRequest/PullRequestService.test.ts | 6 + apps/server/src/server.ts | 2 + apps/server/src/vcs/JjStackService.test.ts | 103 +++ apps/server/src/vcs/JjStackService.ts | 404 +++++++++++ apps/server/src/ws.ts | 15 + apps/web/src/closedViewStore.ts | 1 + apps/web/src/components/AppSidebarLayout.tsx | 8 - apps/web/src/components/ChatView.tsx | 22 +- .../GitActionsControl.lifecycle.test.ts | 33 +- apps/web/src/components/GitActionsControl.tsx | 55 +- .../RightPanelTabs.keyboard.test.tsx | 16 + apps/web/src/components/RightPanelTabs.tsx | 9 +- apps/web/src/components/Sidebar.tsx | 16 +- .../deployments/ConfiguredDeployments.tsx | 69 ++ .../deployments/DeploymentsPanel.tsx | 310 +++++++++ .../deployments/deployments.logic.test.ts | 232 +++++++ .../deployments/deployments.logic.ts | 308 +++++++++ .../components/diffs/AnnotatableCodeView.tsx | 3 + .../review/CommitMessageEditor.test.tsx | 120 ++++ .../components/review/CommitMessageEditor.tsx | 294 ++++++++ .../web/src/components/review/DiffSurface.tsx | 57 ++ .../components/review/JjReviewPanel.test.tsx | 142 ++++ .../src/components/review/JjReviewPanel.tsx | 644 ++++++++++++++++++ .../src/components/review/JjStackViews.tsx | 318 +++++++++ .../components/review/MergebotStatusPill.tsx | 119 ++++ .../components/review/ThreadMergebotPill.tsx | 27 + .../src/components/review/commitTitle.test.ts | 27 + .../web/src/components/review/commitTitle.tsx | 90 +++ .../components/review/jjReview.logic.test.ts | 96 +++ .../src/components/review/jjReview.logic.ts | 89 +++ .../src/components/sidebar/SidebarChrome.tsx | 44 +- .../sidebar/SidebarDeploymentsButton.tsx | 82 +++ .../src/components/sidebar/mainAppLocation.ts | 3 +- apps/web/src/rightPanelStore.ts | 4 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/deployments.tsx | 44 ++ apps/web/src/state/jj.ts | 36 + apps/web/src/state/mergebot.ts | 59 +- packages/client-runtime/package.json | 4 + .../src/mergebotStatusPill.test.ts | 130 ++++ .../client-runtime/src/mergebotStatusPill.ts | 88 +++ packages/client-runtime/src/rpc/client.ts | 3 + .../src/state/commandPermissions.test.ts | 34 + .../client-runtime/src/state/pullRequests.ts | 18 + .../contracts/src/clientRpcPermissions.ts | 7 + packages/contracts/src/git.ts | 1 + packages/contracts/src/index.ts | 1 + packages/contracts/src/jj.test.ts | 35 + packages/contracts/src/jj.ts | 131 ++++ packages/contracts/src/mergebot.ts | 117 ++++ packages/contracts/src/rpc.ts | 132 +++- packages/shared/src/git.test.ts | 1 + packages/shared/src/git.ts | 1 + 64 files changed, 5734 insertions(+), 186 deletions(-) create mode 100644 apps/server/src/mergebot/overview.test.ts create mode 100644 apps/server/src/mergebot/overview.ts create mode 100644 apps/server/src/vcs/JjStackService.test.ts create mode 100644 apps/server/src/vcs/JjStackService.ts create mode 100644 apps/web/src/components/deployments/ConfiguredDeployments.tsx create mode 100644 apps/web/src/components/deployments/DeploymentsPanel.tsx create mode 100644 apps/web/src/components/deployments/deployments.logic.test.ts create mode 100644 apps/web/src/components/deployments/deployments.logic.ts create mode 100644 apps/web/src/components/review/CommitMessageEditor.test.tsx create mode 100644 apps/web/src/components/review/CommitMessageEditor.tsx create mode 100644 apps/web/src/components/review/DiffSurface.tsx create mode 100644 apps/web/src/components/review/JjReviewPanel.test.tsx create mode 100644 apps/web/src/components/review/JjReviewPanel.tsx create mode 100644 apps/web/src/components/review/JjStackViews.tsx create mode 100644 apps/web/src/components/review/MergebotStatusPill.tsx create mode 100644 apps/web/src/components/review/ThreadMergebotPill.tsx create mode 100644 apps/web/src/components/review/commitTitle.test.ts create mode 100644 apps/web/src/components/review/commitTitle.tsx create mode 100644 apps/web/src/components/review/jjReview.logic.test.ts create mode 100644 apps/web/src/components/review/jjReview.logic.ts create mode 100644 apps/web/src/components/sidebar/SidebarDeploymentsButton.tsx create mode 100644 apps/web/src/routes/deployments.tsx create mode 100644 apps/web/src/state/jj.ts create mode 100644 packages/client-runtime/src/mergebotStatusPill.test.ts create mode 100644 packages/client-runtime/src/mergebotStatusPill.ts create mode 100644 packages/contracts/src/jj.test.ts create mode 100644 packages/contracts/src/jj.ts diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 174b3dd56b140f5946f5ee28b51a0fac39bf2876..7d53fb9821ce17be6f9443cdbb4a8462157a4b9f 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -166,11 +166,33 @@ describe("RPC authorization scopes", () => { }); it("requires orchestration reads for Mergebot snapshots and live updates", () => { - for (const method of [WS_METHODS.mergebotQueue, WS_METHODS.mergebotSubscribe]) { + for (const method of [ + WS_METHODS.mergebotQueue, + WS_METHODS.mergebotSubscribe, + WS_METHODS.mergebotSubscribePullRequestStatus, + WS_METHODS.mergebotSubscribeDeployments, + ]) { expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope); } }); + it("requires source-control writes for Jujutsu and Mergebot mutations", () => { + for (const method of [ + WS_METHODS.mergebotOpenPullRequest, + WS_METHODS.mergebotSetAutoMerge, + WS_METHODS.mergebotUpdatePullRequest, + WS_METHODS.mergebotDeployProduction, + WS_METHODS.jjDescribe, + WS_METHODS.jjSquash, + WS_METHODS.jjPush, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthSourceControlWriteScope); + } + for (const method of [WS_METHODS.jjSubscribeStack, WS_METHODS.jjSnapshot, WS_METHODS.jjDiff]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthFilesystemReadScope); + } + }); + it("separates preview control from observation", () => { for (const method of [ WS_METHODS.previewOpen, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index c60b6bf02e613ae3b1d56a03cbcb230ddd5fb255..62a4974c456f822720b342659140f4db40a56ed9 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -126,6 +126,11 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, [WS_METHODS.mergebotQueue]: AuthOrchestrationReadScope, [WS_METHODS.mergebotSubscribe]: AuthOrchestrationReadScope, + [WS_METHODS.mergebotSubscribePullRequestStatus]: AuthOrchestrationReadScope, + [WS_METHODS.mergebotSubscribeDeployments]: AuthOrchestrationReadScope, + [WS_METHODS.jjSubscribeStack]: AuthFilesystemReadScope, + [WS_METHODS.jjSnapshot]: AuthFilesystemReadScope, + [WS_METHODS.jjDiff]: AuthFilesystemReadScope, [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 87a95fa8eb047c32bbaa969c2b9555f2bf024bf2..68b924fab3ee86bb2b5ecefdae8289a0f153474c 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -927,6 +927,12 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { mergebot: { configuration: { origin: "https://mergebot.example", repositories: ["work/app"] }, read: () => Effect.succeed(null), + watchPullRequestStatus: () => Stream.empty, + watchDeployments: () => Stream.empty, + openPullRequest: () => Effect.die("unused"), + setAutoMerge: () => Effect.die("unused"), + updatePullRequest: () => Effect.die("unused"), + deployProduction: () => Effect.die("unused"), watchQueue: () => Stream.empty, streamChanges: Stream.empty, readPullRequest: () => Effect.sync(() => value), diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 4617965803b9c35d7e73b067df7704d871427f16..6620af20427e7409fa5772cd210afd51b9b22409 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -8,6 +8,7 @@ import { VcsRepositoryDetectionError } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as ServerConfig from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -27,6 +28,7 @@ function layer(input: { ), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), Layer.provide(Layer.mock(GitManager.GitManager)({})), + Layer.provide(NodeServices.layer), ); } @@ -86,6 +88,35 @@ describe("GitWorkflowService", () => { ), ); + it.effect( + "keeps non-colocated Jujutsu changes available without reporting a Git repository", + () => + Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-jj-workflow-" }); + yield* fs.makeDirectory(path.join(root, ".jj")); + const nested = path.join(root, "packages", "app"); + yield* fs.makeDirectory(nested, { recursive: true }); + + for (const cwd of [root, nested]) { + const local = yield* workflow.localStatus({ cwd }); + const status = yield* workflow.status({ cwd }); + expect(local.isRepo).toBe(false); + expect(local.isJjWorkspace).toBe(true); + expect(status.isRepo).toBe(false); + expect(status.isJjWorkspace).toBe(true); + expect(status.refName).toBeNull(); + } + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(layer({ detect: () => Effect.succeed(null) }), NodeServices.layer), + ), + ), + ); + it.effect("returns an empty full status when no VCS repository is detected", () => Effect.gen(function* () { const workflow = yield* GitWorkflowService.GitWorkflowService; @@ -136,6 +167,7 @@ describe("GitWorkflowService", () => { status, }), ), + Layer.provide(NodeServices.layer), ); return Effect.gen(function* () { diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index f7eddd7f3c3fc51f4706b930df69830adf0a5e54..397e101bf648d242b7fbc67340e367507bf0e115 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -1,6 +1,9 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { mergeGitStatusParts } from "@t3tools/shared/git"; import { GitManagerError, @@ -127,32 +130,6 @@ export class GitWorkflowService extends Context.Service< } >()("t3/git/GitWorkflowService") {} -function nonRepositoryLocalStatus(): VcsStatusLocalResult { - return { - isRepo: false, - hasPrimaryRemote: false, - isDefaultRef: false, - refName: null, - hasWorkingTreeChanges: false, - workingTree: { - files: [], - insertions: 0, - deletions: 0, - }, - }; -} - -function nonRepositoryStatus(): VcsStatusResult { - return { - ...nonRepositoryLocalStatus(), - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - aheadOfDefaultCount: 0, - pr: null, - }; -} - function nonRepositoryListRefs(): VcsListRefsResult { return { refs: [], @@ -168,6 +145,37 @@ export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; const git = yield* GitVcsDriver.GitVcsDriver; const gitManager = yield* GitManager.GitManager; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const nonGitLocalStatus = Effect.fn("GitWorkflowService.nonGitLocalStatus")(function* ( + cwd: string, + ) { + let directory = yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd)); + let isJjWorkspace = false; + while (true) { + const metadata = yield* fileSystem + .stat(path.join(directory, ".jj")) + .pipe(Effect.orElseSucceed(() => null)); + if (metadata?.type === "Directory") { + isJjWorkspace = true; + break; + } + const parent = path.dirname(directory); + if (parent === directory) break; + directory = parent; + } + + return { + isRepo: false, + ...(isJjWorkspace ? { isJjWorkspace: true } : {}), + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + } satisfies VcsStatusLocalResult; + }); const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* ( operation: string, @@ -331,15 +339,17 @@ export const make = Effect.gen(function* () { status: (input) => detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe( Effect.flatMap((isGitRepository) => - isGitRepository ? gitManager.status(input) : Effect.succeed(nonRepositoryStatus()), + isGitRepository + ? gitManager.status(input) + : nonGitLocalStatus(input.cwd).pipe( + Effect.map((local) => mergeGitStatusParts(local, null)), + ), ), ), localStatus: (input) => detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe( Effect.flatMap((isGitRepository) => - isGitRepository - ? gitManager.localStatus(input) - : Effect.succeed(nonRepositoryLocalStatus()), + isGitRepository ? gitManager.localStatus(input) : nonGitLocalStatus(input.cwd), ), ), remoteStatus: (input, options) => diff --git a/apps/server/src/mergebot/MergebotQueue.test.ts b/apps/server/src/mergebot/MergebotQueue.test.ts index ac7e688749ab3754779ce3e75981a9086e9d2f99..dc47e752f96aa84f581dcc20b5dd7d3c87b9bc9e 100644 --- a/apps/server/src/mergebot/MergebotQueue.test.ts +++ b/apps/server/src/mergebot/MergebotQueue.test.ts @@ -51,6 +51,16 @@ const check = { startedAt: "2026-10-14T10:00:00Z", completedAt: "2026-10-14T10:01:00Z", }; +const repo = { + processing: true, + defaultBranch: "main", + productionBranch: "production", + stagingUrl: "https://staging.example", + productionUrl: "https://app.example", + triggerLabel: "merge", + stagingDeployment: null, + productionDeployment: null, +}; const repoChannel = { kind: "repo_overview", repoId: repository }; const detailChannel = { kind: "pull_request_detail", repoId: repository, prNumber: 123 }; const repoSnapshot = { @@ -59,8 +69,13 @@ const repoSnapshot = { channel: repoChannel, channelKey: `repo_overview:${repository}`, payload: { - entities: { repos: { [repository]: { processing: true } }, pullRequests: { [pr.id]: pr } }, - view: { repoId: repository, queuePrIds: [pr.id] }, + entities: { + repos: { [repository]: repo }, + users: {}, + pullRequests: { [pr.id]: pr }, + mergedCommits: {}, + }, + view: { repoId: repository, queuePrIds: [pr.id], mergedCommitIds: [] }, }, }; const detailSnapshot = { @@ -122,11 +137,26 @@ function harness(configurationText: string | null = JSON.stringify(configuration const sockets: TestSocket[] = []; const exchanges: Array<{ url: string; authorization: string | undefined }> = []; let exchangeStatus = 200; + let session = "sealed-mergebot-session"; + const mutations: Array<{ + method: string; + url: string; + authorization: string | undefined; + body: unknown; + }> = []; + let respond = (_request: (typeof mutations)[number]) => Response.json({ ok: true }); const requests: Array<{ url: string; options?: Socket.WebSocketConstructorOptions }> = []; return { sockets, requests, exchanges, + mutations, + respondWith: (handler: typeof respond) => { + respond = handler; + }, + setSession: (value: string) => { + session = value; + }, setExchangeStatus: (status: number) => { exchangeStatus = status; }, @@ -143,13 +173,26 @@ function harness(configurationText: string | null = JSON.stringify(configuration Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => { - exchanges.push({ url: request.url, authorization: request.headers.authorization }); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ token: "sealed-mergebot-session" }, { status: exchangeStatus }), - ), - ); + if (request.url.endsWith("/api/auth/github/token")) { + exchanges.push({ url: request.url, authorization: request.headers.authorization }); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ token: session }, { status: exchangeStatus }), + ), + ); + } + const mutation = { + method: request.method, + url: request.url, + authorization: request.headers.authorization, + body: + request.body._tag === "Uint8Array" + ? JSON.parse(new TextDecoder().decode(request.body.body)) + : undefined, + }; + mutations.push(mutation); + return Effect.succeed(HttpClientResponse.fromWeb(request, respond(mutation))); }), ), Layer.succeed(HostProcess.HomeDirectory, "/test"), @@ -553,7 +596,7 @@ describe("MergebotQueue", () => { ...repoSnapshot.payload, entities: { ...repoSnapshot.payload.entities, - repos: { [repository]: { processing: false } }, + repos: { [repository]: { ...repo, processing: false } }, }, }, }); @@ -664,4 +707,221 @@ describe("MergebotQueue", () => { }).pipe(Effect.provide(h.layer)); }, ); + + it.effect("mutations reuse the session and enable auto-merge after opening", () => { + const h = harness(); + h.respondWith((request) => + request.url.endsWith("/pulls") ? Response.json({ number: 456 }) : Response.json({ ok: true }), + ); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + expect( + yield* service.openPullRequest({ + repository: "WORK/APP", + head: "feature", + title: "Ship change", + body: "", + draft: false, + autoMerge: true, + }), + ).toEqual({ number: 456, url: `${url}/pull/456` }); + yield* service.updatePullRequest({ repository, number: 456, draft: true }); + yield* service.setAutoMerge({ repository, number: 456, enabled: false }); + yield* service.deployProduction({ repository, sha: "abc123" }); + const api = "https://mergebot.example/api/repos/work/app"; + const authorization = "Bearer sealed-mergebot-session"; + expect(h.mutations).toEqual([ + { + method: "POST", + url: `${api}/pulls`, + authorization, + body: { head: "feature", title: "Ship change", body: "", draft: false }, + }, + { + method: "POST", + url: `${api}/pulls/456/auto-merge`, + authorization, + body: { enabled: true }, + }, + { method: "PATCH", url: `${api}/pulls/456`, authorization, body: { draft: true } }, + { + method: "POST", + url: `${api}/pulls/456/auto-merge`, + authorization, + body: { enabled: false }, + }, + { method: "POST", url: `${api}/deploy/production`, authorization, body: { sha: "abc123" } }, + ]); + expect(h.exchanges).toHaveLength(1); + expect(h.sockets).toHaveLength(0); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("a rejected session is exchanged once and failures carry Mergebot's message", () => { + const h = harness(); + h.setSession("stale-session"); + h.respondWith((request) => { + if (request.authorization !== "Bearer stale-session") + return Response.json({ message: "Reference update failed" }, { status: 422 }); + h.setSession("fresh-session"); + return new Response(null, { status: 401 }); + }); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + const error = yield* service + .deployProduction({ repository, sha: "abc123" }) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "MergebotOperationError", + operation: "deploy to production", + status: 422, + detail: "Reference update failed", + }); + expect(h.mutations.map((request) => request.authorization)).toEqual([ + "Bearer stale-session", + "Bearer fresh-session", + ]); + expect(h.exchanges).toHaveLength(2); + expect( + yield* service + .deployProduction({ repository: "other/app", sha: "abc123" }) + .pipe(Effect.flip), + ).toMatchObject({ status: null, detail: "other/app is not a Mergebot repository" }); + expect(h.mutations).toHaveLength(2); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("an auto-merge failure does not fail the pull request it follows", () => { + const h = harness(); + h.respondWith((request) => + request.url.endsWith("/pulls") + ? Response.json({ number: 7 }) + : Response.json({ message: "No trigger label" }, { status: 400 }), + ); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + expect( + yield* service.openPullRequest({ + repository, + head: "feature", + base: "release", + title: "Ship change", + body: "Body", + draft: true, + autoMerge: true, + }), + ).toEqual({ number: 7, url: `${url}/pull/7` }); + expect(h.mutations[0]?.body).toEqual({ + head: "feature", + base: "release", + title: "Ship change", + body: "Body", + draft: true, + }); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("status watchers hold the detail channel and release it after they leave", () => { + const h = harness(); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + const loaded = yield* Deferred.make(); + const watcher = yield* service.watchPullRequestStatus({ repository, number: 123 }).pipe( + Stream.filter((status) => status?.checks != null), + Stream.runForEach((status) => Deferred.succeed(loaded, status)), + Effect.forkScoped({ startImmediately: true }), + ); + expect(yield* Deferred.await(loaded)).toEqual({ + number: 123, + url: `${url}/pull/123`, + title: "Ship change", + body: null, + headSha: "current-head", + headBranch: "feature", + lifecycle: "open", + draft: false, + autoMerge: false, + ciStatus: "pending", + checks: { completed: 1, total: 1, failed: 1 }, + hasConflicts: false, + queueStatus: "waiting_ci", + position: 1, + mergeEtaAt: null, + deploy: null, + }); + const unsubscribe = { type: "unsubscribe", channelKey: detailSnapshot.channelKey }; + yield* TestClock.adjust("6 minutes"); + expect(h.sockets[0]?.sent).not.toContainEqual(unsubscribe); + yield* Fiber.interrupt(watcher); + yield* TestClock.adjust("5 minutes"); + expect(h.sockets[0]?.sent.at(-1)).toEqual(unsubscribe); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("deployment watchers map the overview's repository record", () => { + const h = harness(); + const stagingDeployment = { + sha: "merged-sha", + deployedSha: "merged-sha", + mode: "branch_ci", + status: "passed", + currentStep: null, + etaAt: null, + checksUrl: "https://github.com/work/app/commit/merged-sha/checks", + }; + const merged = { ...pr, id: `${repository}#100`, number: 100, lifecycle: "merged" }; + h.subscribeWith((socket, channel) => { + if (channel.kind !== "repo_overview") return; + socket.snapshot({ + ...repoSnapshot, + payload: { + entities: { + repos: { [repository]: { ...repo, stagingDeployment } }, + users: {}, + pullRequests: { [pr.id]: pr, [merged.id]: merged }, + mergedCommits: { + "merged-sha": { + id: "merged-sha", + repoId: repository, + sha: "merged-sha", + pullRequestNumber: 100, + authorId: null, + authorName: "Octo", + message: "Merged change", + mergedAt: "2026-10-14T09:00:00Z", + url: "https://github.com/work/app/pull/100", + labels: [], + status: "default", + isProductionHead: false, + }, + }, + }, + view: { repoId: repository, queuePrIds: [pr.id], mergedCommitIds: ["merged-sha"] }, + }, + }); + }); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + const deployments = yield* Stream.runHead(service.watchDeployments({ repository })); + expect(deployments).toMatchObject({ + _tag: "Some", + value: { + repository, + staging: { sha: "merged-sha", status: "passed" }, + production: null, + mergedCommits: [{ sha: "merged-sha", pullRequestNumber: 100, authorName: "Octo" }], + }, + }); + const status = yield* Stream.runHead( + service.watchPullRequestStatus({ repository, number: 100 }), + ); + expect(status).toMatchObject({ + _tag: "Some", + value: { lifecycle: "merged", deploy: { state: "staging" } }, + }); + expect(yield* Stream.runHead(service.watchDeployments({ repository: "other/app" }))).toEqual( + expect.objectContaining({ _tag: "Some", value: null }), + ); + }).pipe(Effect.provide(h.layer)); + }); }); diff --git a/apps/server/src/mergebot/MergebotQueue.ts b/apps/server/src/mergebot/MergebotQueue.ts index 386dc8d1ad84410a7967f744bc56eb27df54c876..986efb75cae0f68ba49c88f2dd743c8f0fcf1f5d 100644 --- a/apps/server/src/mergebot/MergebotQueue.ts +++ b/apps/server/src/mergebot/MergebotQueue.ts @@ -1,11 +1,19 @@ import { MergebotConfiguration, + MergebotDeployments, + MergebotOpenPullRequestResult, + MergebotOperationError, MergebotPullRequestCheck, + type MergebotDeployProductionInput, + type MergebotOpenPullRequestInput, MergebotPullRequestDetails, + MergebotPullRequestLiveStatus, MergebotPullRequestStatus, type MergebotPullRequestInput, type MergebotQueueInput, MergebotRepoQueue, + type MergebotSetAutoMergeInput, + type MergebotUpdatePullRequestInput, PullRequestActor, } from "@t3tools/contracts"; import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; @@ -25,19 +33,8 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as Socket from "effect/socket/Socket"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; +import { deployments, pullRequestLiveStatus, PullRequestRecord, RepoPayload } from "./overview.ts"; -const PullRequestRecord = Schema.Struct({ - ...Struct.omit(MergebotPullRequestDetails.fields, [ - "position", - "checks", - "author", - "comments", - "observedAt", - ]), - authorId: Schema.NullOr(Schema.String), - id: Schema.String, - repoId: Schema.String, -}); const Comment = Schema.Struct({ id: Schema.String, kind: Schema.Literals(["comment", "review", "review_comment"]), @@ -55,13 +52,6 @@ const DetailChannel = Schema.Struct({ repoId: Schema.String, prNumber: Schema.Number, }); -const RepoPayload = Schema.Struct({ - entities: Schema.Struct({ - repos: Schema.Record(Schema.String, Schema.Struct({ processing: Schema.Boolean })), - pullRequests: Schema.Record(Schema.String, PullRequestRecord), - }), - view: Schema.Struct({ repoId: Schema.String, queuePrIds: Schema.Array(Schema.String) }), -}); const Message = Schema.Union([ Schema.Struct({ type: Schema.Literal("snapshot"), @@ -98,6 +88,12 @@ const Message = Schema.Union([ const decodeMessage = Schema.decodeEffect(Schema.fromJsonString(Message)); const encodeStatus = Schema.encodeSync(MergebotPullRequestStatus); const queueEquivalence = Schema.toEquivalence(Schema.NullOr(MergebotRepoQueue)); +const liveStatusEquivalence = Schema.toEquivalence(Schema.NullOr(MergebotPullRequestLiveStatus)); +const deploymentsEquivalence = Schema.toEquivalence(Schema.NullOr(MergebotDeployments)); +const decodeApiError = Schema.decodeUnknownEffect(Schema.Struct({ message: Schema.String })); +const decodeOpenedPullRequest = Schema.decodeUnknownEffect( + Schema.Struct({ number: MergebotOpenPullRequestResult.fields.number }), +); const decodeSession = Schema.decodeUnknownEffect( Schema.Struct({ token: Schema.String.check(Schema.isMinLength(1)), @@ -125,6 +121,24 @@ export class MergebotQueue extends Context.Service< readonly repository: string; readonly headBranch: string; }) => Effect.Effect; + readonly watchPullRequestStatus: ( + input: MergebotPullRequestInput, + ) => Stream.Stream; + readonly watchDeployments: ( + input: MergebotQueueInput, + ) => Stream.Stream; + readonly openPullRequest: ( + input: MergebotOpenPullRequestInput, + ) => Effect.Effect; + readonly setAutoMerge: ( + input: MergebotSetAutoMergeInput, + ) => Effect.Effect; + readonly updatePullRequest: ( + input: MergebotUpdatePullRequestInput, + ) => Effect.Effect; + readonly deployProduction: ( + input: MergebotDeployProductionInput, + ) => Effect.Effect; readonly streamChanges: Stream.Stream; } >()("t3/mergebot/MergebotQueue") {} @@ -165,7 +179,7 @@ const make = Effect.gen(function* () { const constructSocket = yield* Socket.WebSocketConstructor; const scope = yield* Effect.scope; const changes = yield* PubSub.unbounded(); - const repos = new Map(); + const repos = new Map(); const details = new Map(); const channels = new Map(); const observations = new Map(); @@ -175,6 +189,9 @@ const make = Effect.gen(function* () { let started = false; let account: string | undefined; let activeSocket: Socket.WebSocketLike | undefined; + let session: { readonly fingerprint: string; readonly token: Redacted.Redacted } | undefined; + /** Live status watchers per detail channel; a watched channel is never idle. */ + const watchers = new Map(); const repositoryFor = (repository: string) => configuration?.repositories.find((repo) => repo.toLowerCase() === repository.toLowerCase()); @@ -268,10 +285,7 @@ const make = Effect.gen(function* () { yield* invalidate(message.channelKey); return; } - repos.set(repository, { - entities: { repos: entities.repos, pullRequests: entities.pullRequests }, - view, - }); + repos.set(repository, { entities, view }); for (const record of pullRequestRecords) { const key = channelKey({ kind: "pull_request_detail", @@ -351,16 +365,13 @@ const make = Effect.gen(function* () { yield* PubSub.publish(changes, { repository, number: message.channel.prNumber }); }); - const connect = Effect.gen(function* () { - if (!configuration) return; - const credential = yield* credentials.get("github.com"); - if (account !== undefined && account !== credential.fingerprint) { - yield* invalidate(); - } - account = credential.fingerprint; + const exchange = Effect.fnUntraced(function* ( + origin: string, + credential: GitHubCredentials.GitHubCredential, + ) { const response = yield* http .execute( - HttpClientRequest.post(`${configuration.origin}/api/auth/github/token`).pipe( + HttpClientRequest.post(`${origin}/api/auth/github/token`).pipe( HttpClientRequest.setHeader( "Authorization", `Bearer ${Redacted.value(credential.token)}`, @@ -376,8 +387,19 @@ const make = Effect.gen(function* () { if (response.status === 401) yield* credentials.invalidate("github.com"); return yield* new MergebotAuthenticationError({ status: response.status }); } - const session = yield* response.json.pipe(Effect.flatMap(decodeSession)); - const token = Redacted.make(session.token); + const { token } = yield* response.json.pipe(Effect.flatMap(decodeSession)); + session = { fingerprint: credential.fingerprint, token: Redacted.make(token) }; + return session.token; + }); + + const connect = Effect.gen(function* () { + if (!configuration) return; + const credential = yield* credentials.get("github.com"); + if (account !== undefined && account !== credential.fingerprint) { + yield* invalidate(); + } + account = credential.fingerprint; + const token = yield* exchange(configuration.origin, credential); const socket = yield* Socket.fromWebSocket( Effect.acquireRelease( Effect.sync(() => { @@ -432,7 +454,7 @@ const make = Effect.gen(function* () { const releaseIdleChannels = Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; for (const [key, at] of lastReadAt) { - if (now - at < 5 * 60_000) continue; + if (watchers.has(key) || now - at < 5 * 60_000) continue; lastReadAt.delete(key); channels.delete(key); details.delete(key); @@ -498,21 +520,9 @@ const make = Effect.gen(function* () { return (yield* authorize()) === fingerprint ? value : null; }); - const readPullRequest = Effect.fn("MergebotQueue.readPullRequest")(function* ( - input: MergebotPullRequestInput, - ) { - const repository = repositoryFor(input.repository); - if (!repository) return null; - const fingerprint = yield* authorize(); - if (fingerprint === null) return null; - yield* start(); - const channel = { - kind: "pull_request_detail", - repoId: repository, - prNumber: input.number, - } as const; + const subscribeDetail = Effect.fnUntraced(function* (repository: string, number: number) { + const channel = { kind: "pull_request_detail", repoId: repository, prNumber: number } as const; const key = channelKey(channel); - lastReadAt.set(key, yield* Clock.currentTimeMillis); if (!channels.has(key)) { channels.set(key, channel); if (writer) @@ -521,46 +531,189 @@ const make = Effect.gen(function* () { .pipe(Effect.orElseSucceed(() => undefined)); } yield* retryChannel(key); + return key; + }); + + /** Resolves a configured repository for the current account and keeps its overview live. */ + const openOverview = Effect.fnUntraced(function* (input: string) { + const repository = repositoryFor(input); + if (!repository) return null; + const fingerprint = yield* authorize(); + if (fingerprint === null) return null; + yield* start(); + yield* retryChannel(channelKey({ kind: "repo_overview", repoId: repository })); + return { repository, fingerprint }; + }); + + const readPullRequest = Effect.fn("MergebotQueue.readPullRequest")(function* ( + input: MergebotPullRequestInput, + ) { + const repository = repositoryFor(input.repository); + if (!repository) return null; + const fingerprint = yield* authorize(); + if (fingerprint === null) return null; + yield* start(); + const key = yield* subscribeDetail(repository, input.number); + lastReadAt.set(key, yield* Clock.currentTimeMillis); return yield* waitFor(fingerprint, () => details.get(key)); }); const read = Effect.fn("MergebotQueue.read")(function* (input: MergebotQueueInput) { + const opened = yield* openOverview(input.repository); + if (!opened) return null; + return yield* waitFor(opened.fingerprint, () => queueFor(opened.repository)); + }); + + const readPullRequestStatus = Effect.fn("MergebotQueue.readPullRequestStatus")(function* ( + input: MergebotPullRequestInput, + ) { + const opened = yield* openOverview(input.repository); + if (!opened || !configuration) return null; + const { origin } = configuration; + const { repository } = opened; + const key = yield* subscribeDetail(repository, input.number); + return yield* waitFor(opened.fingerprint, () => { + const payload = repos.get(repository); + const detail = details.get(key); + if (!payload) return payload; + // Closed and long-merged PRs exist only in their detail snapshot. + const inOverview = payload.entities.pullRequests[`${repository}#${input.number}`]; + if (!inOverview && detail === undefined) return undefined; + return pullRequestLiveStatus( + `${origin}/${repository}`, + repository, + payload, + detail, + input.number, + ); + }); + }); + + const readDeployments = Effect.fn("MergebotQueue.readDeployments")(function* ( + input: MergebotQueueInput, + ) { + const opened = yield* openOverview(input.repository); + if (!opened || !configuration) return null; + const { origin } = configuration; + const { repository } = opened; + return yield* waitFor(opened.fingerprint, () => { + const payload = repos.get(repository); + return payload && deployments(`${origin}/${repository}`, repository, payload); + }); + }); + + const watch = ( + repository: string, + read: Effect.Effect, + equivalence: (self: A, that: A) => boolean, + ) => + Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + return Stream.concat( + Stream.fromEffect(read), + Stream.fromSubscription(subscription).pipe( + Stream.filter((change) => change.repository.toLowerCase() === repository.toLowerCase()), + Stream.mapEffect(() => read), + ), + ); + }), + ).pipe(Stream.changesWith(equivalence)); + + /** Sends one mutation with the Mergebot session, exchanging a fresh session once on 401. */ + const request = Effect.fnUntraced(function* ( + operation: string, + input: { readonly repository: string }, + method: "POST" | "PATCH", + path: string, + body: object, + ) { const repository = repositoryFor(input.repository); - if (!repository) return null; - const fingerprint = yield* authorize(); - if (fingerprint === null) return null; - yield* start(); - yield* retryChannel(channelKey({ kind: "repo_overview", repoId: repository })); - return yield* waitFor(fingerprint, () => queueFor(repository)); + if (!configuration || !repository) + return yield* new MergebotOperationError({ + operation, + status: null, + detail: `${input.repository} is not a Mergebot repository`, + }); + const { origin } = configuration; + + const sessionToken = Effect.gen(function* () { + const credential = yield* credentials.get("github.com").pipe( + Effect.mapError( + () => + new MergebotOperationError({ + operation, + status: null, + detail: "GitHub is not signed in", + }), + ), + ); + return session?.fingerprint === credential.fingerprint + ? session.token + : yield* exchange(origin, credential); + }); + const send = (token: Redacted.Redacted) => + http + .execute( + HttpClientRequest.make(method)(`${origin}/api/repos/${repository}${path}`).pipe( + HttpClientRequest.setHeader("Authorization", `Bearer ${Redacted.value(token)}`), + HttpClientRequest.setHeader("accept", "application/json"), + HttpClientRequest.bodyJsonUnsafe(body), + ), + ) + .pipe(Effect.timeout("30 seconds")); + + const response = yield* Effect.gen(function* () { + const first = yield* send(yield* sessionToken); + if (first.status !== 401) return first; + session = undefined; + return yield* send(yield* sessionToken); + }).pipe( + Effect.mapError((cause) => + cause._tag === "MergebotOperationError" + ? cause + : cause._tag === "MergebotAuthenticationError" + ? new MergebotOperationError({ + operation, + status: cause.status, + detail: "Mergebot did not accept the GitHub sign-in", + }) + : new MergebotOperationError({ + operation, + status: null, + detail: "Mergebot is unreachable", + }), + ), + ); + const json = yield* response.json.pipe(Effect.orElseSucceed(() => undefined)); + if (response.status >= 200 && response.status < 300) + return { json, url: `${origin}/${repository}` }; + + const detail = yield* decodeApiError(json).pipe( + Effect.map((error) => error.message), + Effect.orElseSucceed(() => `HTTP ${response.status}`), + ); + return yield* new MergebotOperationError({ operation, status: response.status, detail }); + }, Effect.scoped); + + const setAutoMerge = Effect.fn("MergebotQueue.setAutoMerge")(function* ( + input: MergebotSetAutoMergeInput, + ) { + yield* request("update auto-merge", input, "POST", `/pulls/${input.number}/auto-merge`, { + enabled: input.enabled, + }); }); return MergebotQueue.of({ configuration, read, - watchQueue: (input) => - Stream.unwrap( - Effect.gen(function* () { - const subscription = yield* PubSub.subscribe(changes); - return Stream.concat( - Stream.fromEffect(read(input)), - Stream.fromSubscription(subscription).pipe( - Stream.filter( - (change) => change.repository.toLowerCase() === input.repository.toLowerCase(), - ), - Stream.mapEffect(() => read(input)), - ), - ); - }), - ).pipe(Stream.changesWith(queueEquivalence)), + watchQueue: (input) => watch(input.repository, read(input), queueEquivalence), readPullRequest, findPullRequest: Effect.fn("MergebotQueue.findPullRequest")(function* (input) { - const repository = repositoryFor(input.repository); - if (!repository) return null; - const fingerprint = yield* authorize(); - if (fingerprint === null) return null; - yield* start(); - yield* retryChannel(channelKey({ kind: "repo_overview", repoId: repository })); - const queue = yield* waitFor(fingerprint, () => queueFor(repository)); + const opened = yield* openOverview(input.repository); + if (!opened) return null; + const { repository } = opened; + const queue = yield* waitFor(opened.fingerprint, () => queueFor(repository)); if (!queue || queue.status !== "ready") return null; const matches = Object.values(repos.get(repository)?.entities.pullRequests ?? {}) .filter((pr) => pr.headBranch === input.headBranch) @@ -572,6 +725,72 @@ const make = Effect.gen(function* () { const match = matches?.[0]; return match ? yield* readPullRequest({ repository, number: match.number }) : null; }), + watchPullRequestStatus: (input) => + Stream.unwrap( + Effect.gen(function* () { + const repository = repositoryFor(input.repository); + if (repository) { + const key = channelKey({ + kind: "pull_request_detail", + repoId: repository, + prNumber: input.number, + }); + yield* Effect.acquireRelease( + Effect.sync(() => watchers.set(key, (watchers.get(key) ?? 0) + 1)), + () => + Effect.map(Clock.currentTimeMillis, (now) => { + const remaining = (watchers.get(key) ?? 1) - 1; + if (remaining > 0) { + watchers.set(key, remaining); + } else { + // The last watcher hands the channel to the idle lease. + watchers.delete(key); + lastReadAt.set(key, now); + } + }), + ); + } + return watch(input.repository, readPullRequestStatus(input), liveStatusEquivalence); + }), + ), + watchDeployments: (input) => + watch(input.repository, readDeployments(input), deploymentsEquivalence), + openPullRequest: Effect.fn("MergebotQueue.openPullRequest")(function* (input) { + const operation = "open the pull request"; + const pullRequest = Struct.omit(input, ["repository", "autoMerge"]); + const created = yield* request(operation, input, "POST", "/pulls", pullRequest); + const { number } = yield* decodeOpenedPullRequest(created.json).pipe( + Effect.mapError( + () => + new MergebotOperationError({ + operation, + status: null, + detail: "Mergebot returned an unexpected response", + }), + ), + ); + // The PR exists now, so failing here would invite a retry that collides with it. + if (input.autoMerge) + yield* setAutoMerge({ repository: input.repository, number, enabled: true }).pipe( + Effect.catchTags({ + MergebotOperationError: (error) => + Effect.logWarning("Mergebot opened the pull request without auto-merge", { + status: error.status, + }), + }), + ); + return { number, url: `${created.url}/pull/${number}` }; + }), + setAutoMerge, + updatePullRequest: Effect.fn("MergebotQueue.updatePullRequest")(function* (input) { + const changes = Struct.omit(input, ["repository", "number"]); + yield* request("update the pull request", input, "PATCH", `/pulls/${input.number}`, changes); + }), + deployProduction: Effect.fn("MergebotQueue.deployProduction")(function* (input) { + yield* request("deploy to production", input, "POST", "/deploy/production", { + sha: input.sha, + }); + }), streamChanges: Stream.fromPubSub(changes), }); }); diff --git a/apps/server/src/mergebot/overview.test.ts b/apps/server/src/mergebot/overview.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d91c9267b42c47e620249e827c97d20873dea13e --- /dev/null +++ b/apps/server/src/mergebot/overview.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { MergebotDeploymentEnvironment, MergebotPullRequestDetails } from "@t3tools/contracts"; +import { deployments, deployState, pullRequestLiveStatus, type RepoPayload } from "./overview.ts"; + +const repository = "work/app"; +const url = `https://mergebot.example/${repository}`; + +const commit = (number: number, isProductionHead = false) => ({ + sha: `sha-${number}`, + pullRequestNumber: number, + authorId: `user-${number}`, + authorName: `Commit Author ${number}`, + message: `Change ${number}`, + mergedAt: `2026-10-14T10:0${number}:00Z`, + url: `https://github.com/${repository}/pull/${number}`, + labels: [], + status: "default" as const, + isProductionHead, +}); +// Newest first, as Mergebot orders `mergedCommitIds`. +const commits = [5, 4, 3, 2, 1, 0].map((number) => commit(number, number === 0)); + +const deployment = ( + sha: string, + deployedSha: string | null, + status: MergebotDeploymentEnvironment["status"], + etaAt: string | null = null, +): MergebotDeploymentEnvironment => ({ + sha, + deployedSha, + status, + currentStep: null, + etaAt, + checksUrl: "https://github.com/checks", +}); + +const repo = { + processing: false, + defaultBranch: "main", + productionBranch: "production", + stagingUrl: "https://staging.example", + productionUrl: "https://app.example", + triggerLabel: "merge", + stagingDeployment: deployment("sha-4", "sha-2", "pending", "2026-10-14T11:00:00Z"), + productionDeployment: deployment("sha-1", "sha-0", "failed"), +}; + +const pr = { + id: `${repository}#5`, + repoId: repository, + number: 5, + authorId: null, + createdAt: "2026-10-14T10:00:00Z", + updatedAt: "2026-10-14T10:02:00Z", + additions: 3, + deletions: 1, + changedFilesCount: 2, + labels: [{ name: "merge", color: null }], + title: "Ship change", + body: "Body", + draft: false, + headBranch: "feature", + baseBranch: "main", + headSha: "head", + lifecycle: "open" as const, + queueStatus: "queued" as const, + mergeEtaAt: "2026-10-14T10:30:00Z", + ciStatus: "passed" as const, + hasConflicts: false, + url: `https://github.com/${repository}/pull/5`, +}; + +const payload = (overrides: Partial = {}): RepoPayload => ({ + entities: { + repos: { [repository]: repo }, + users: { + "user-4": { login: "octo", name: "Octo Cat", avatarUrl: "https://avatars.example/octo" }, + }, + pullRequests: { [pr.id]: pr }, + mergedCommits: Object.fromEntries(commits.map((entry) => [entry.sha, entry])), + ...overrides, + }, + view: { + repoId: repository, + queuePrIds: [`${repository}#9`, pr.id], + mergedCommitIds: commits.map((entry) => entry.sha), + }, +}); + +const detail = ( + overrides: Partial = {}, +): MergebotPullRequestDetails => ({ + ...pr, + author: null, + comments: [], + observedAt: 0, + position: null, + checks: [ + { status: "completed", conclusion: "success" }, + { status: "completed", conclusion: "failure" }, + { status: "in_progress", conclusion: null }, + ].map((check, index) => ({ + ...check, + id: `check:${index}`, + name: `Check ${index}`, + workflowName: null, + url: null, + startedAt: null, + completedAt: null, + })), + ...overrides, +}); + +describe("deployState", () => { + it.each([ + [5, { state: "waiting", environment: "staging", etaAt: null }], + [4, { state: "deploying", environment: "staging", etaAt: "2026-10-14T11:00:00Z" }], + [3, { state: "deploying", environment: "staging", etaAt: "2026-10-14T11:00:00Z" }], + [2, { state: "staging", environment: "staging", etaAt: null }], + [1, { state: "failed", environment: "production", etaAt: null }], + [0, { state: "production", environment: "production", etaAt: null }], + ])("places #%s on the deploy timeline", (number, expected) => { + expect(deployState(repo, commits, { number })).toEqual(expected); + }); + + it("treats a passed deploy as covering everything at or below its head", () => { + const passed = { ...repo, stagingDeployment: deployment("sha-4", "sha-4", "passed") }; + expect(deployState(passed, commits, { number: 3 })?.state).toBe("staging"); + expect(deployState(passed, commits, { number: 5 })?.state).toBe("waiting"); + }); + + it("reports a production deploy in flight with its own ETA", () => { + const shipping = { + ...repo, + productionDeployment: deployment("sha-2", "sha-0", "pending", "2026-10-14T12:00:00Z"), + }; + expect(deployState(shipping, commits, { number: 1 })).toEqual({ + state: "deploying", + environment: "production", + etaAt: "2026-10-14T12:00:00Z", + }); + }); + + it("falls back to the production branch head without deployment checks", () => { + const branchOnly = { ...repo, stagingDeployment: null, productionDeployment: null }; + expect(deployState(branchOnly, commits, { number: 0 })?.state).toBe("production"); + expect(deployState(branchOnly, commits, { number: 1 })).toEqual({ + state: "waiting", + environment: "production", + etaAt: null, + }); + }); + + it("has no deploy state for a repository without deploys", () => { + const none = { + ...repo, + productionBranch: null, + stagingDeployment: null, + productionDeployment: null, + }; + expect(deployState(none, commits, { number: 3 })).toBeNull(); + }); + + it("places merges outside Mergebot's window by merge time", () => { + expect( + deployState(repo, commits, { number: 99, mergedAt: "2026-10-13T00:00:00Z" })?.state, + ).toBe("production"); + expect(deployState(repo, commits, { number: 99, mergedAt: "2026-10-15T00:00:00Z" })).toBeNull(); + expect(deployState(repo, commits, { number: 99 })).toBeNull(); + }); +}); + +describe("pullRequestLiveStatus", () => { + it("composes overview fields, queue position, and checks for the current head", () => { + expect(pullRequestLiveStatus(url, repository, payload(), detail(), 5)).toEqual({ + number: 5, + url: `${url}/pull/5`, + title: "Ship change", + body: "Body", + headSha: "head", + headBranch: "feature", + lifecycle: "open", + draft: false, + autoMerge: true, + ciStatus: "passed", + checks: { completed: 2, total: 3, failed: 1 }, + hasConflicts: false, + queueStatus: "queued", + position: 2, + mergeEtaAt: "2026-10-14T10:30:00Z", + deploy: null, + }); + }); + + it("drops checks read at an older head and reports auto-merge from the trigger label", () => { + const status = pullRequestLiveStatus( + url, + repository, + payload({ pullRequests: { [pr.id]: { ...pr, labels: [] } } }), + detail({ headSha: "old-head" }), + 5, + ); + expect(status).toMatchObject({ checks: null, autoMerge: false }); + }); + + it("falls back to the detail snapshot for PRs outside the overview", () => { + const closed = detail({ number: 77, lifecycle: "closed", queueStatus: "not_queued" }); + expect(pullRequestLiveStatus(url, repository, payload(), closed, 77)).toMatchObject({ + number: 77, + lifecycle: "closed", + position: null, + deploy: null, + }); + expect(pullRequestLiveStatus(url, repository, payload(), null, 77)).toBeNull(); + }); + + it("maps a merged PR through the merged commits", () => { + const merged = { ...pr, id: `${repository}#2`, number: 2, lifecycle: "merged" as const }; + const status = pullRequestLiveStatus( + url, + repository, + payload({ pullRequests: { [merged.id]: merged } }), + undefined, + 2, + ); + expect(status?.deploy).toEqual({ state: "staging", environment: "staging", etaAt: null }); + }); +}); + +describe("deployments", () => { + it("keeps Mergebot's newest-first order and prefers overview author details", () => { + const result = deployments(url, repository, payload()); + expect(result).toMatchObject({ + repository, + url, + defaultBranch: "main", + productionBranch: "production", + staging: repo.stagingDeployment, + production: repo.productionDeployment, + }); + expect(result?.mergedCommits.map((entry) => entry.sha)).toEqual( + commits.map((entry) => entry.sha), + ); + expect(result?.mergedCommits[1]).toEqual({ + sha: "sha-4", + pullRequestNumber: 4, + authorLogin: "octo", + authorName: "Octo Cat", + authorAvatarUrl: "https://avatars.example/octo", + message: "Change 4", + mergedAt: "2026-10-14T10:04:00Z", + url: `https://github.com/${repository}/pull/4`, + labels: [], + status: "default", + isProductionHead: false, + }); + expect(result?.mergedCommits[0]).toMatchObject({ + authorLogin: null, + authorName: "Commit Author 5", + authorAvatarUrl: null, + }); + }); +}); diff --git a/apps/server/src/mergebot/overview.ts b/apps/server/src/mergebot/overview.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c12002ff455cd45b850e20248a7ff18f1f1df46 --- /dev/null +++ b/apps/server/src/mergebot/overview.ts @@ -0,0 +1,218 @@ +import { + MergebotDeploymentEnvironment, + MergebotDeployments, + MergebotMergedCommit, + MergebotPullRequestDetails, + PullRequestActor, + type MergebotPullRequestLiveStatus, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import { mergebotChecks } from "./pullRequestRead.ts"; + +export const PullRequestRecord = Schema.Struct({ + ...Struct.omit(MergebotPullRequestDetails.fields, [ + "position", + "checks", + "author", + "comments", + "observedAt", + ]), + authorId: Schema.NullOr(Schema.String), + id: Schema.String, + repoId: Schema.String, +}); + +const RepoRecord = Schema.Struct({ + ...Struct.pick(MergebotDeployments.fields, [ + "defaultBranch", + "productionBranch", + "stagingUrl", + "productionUrl", + ]), + processing: Schema.Boolean, + triggerLabel: Schema.String, + stagingDeployment: Schema.NullOr(MergebotDeploymentEnvironment), + productionDeployment: Schema.NullOr(MergebotDeploymentEnvironment), +}); + +const MergedCommitRecord = Schema.Struct({ + ...Struct.omit(MergebotMergedCommit.fields, ["authorLogin", "authorAvatarUrl"]), + authorId: Schema.NullOr(Schema.String), +}); + +export const RepoPayload = Schema.Struct({ + entities: Schema.Struct({ + repos: Schema.Record(Schema.String, RepoRecord), + users: Schema.Record(Schema.String, PullRequestActor), + pullRequests: Schema.Record(Schema.String, PullRequestRecord), + mergedCommits: Schema.Record(Schema.String, MergedCommitRecord), + }), + view: Schema.Struct({ + repoId: Schema.String, + queuePrIds: Schema.Array(Schema.String), + mergedCommitIds: Schema.Array(Schema.String), + }), +}); +export type RepoPayload = typeof RepoPayload.Type; +type Repo = typeof RepoRecord.Type; +type MergedCommit = typeof MergedCommitRecord.Type; +type Deploy = NonNullable; + +const mergedCommitsOf = (payload: RepoPayload) => + payload.view.mergedCommitIds.flatMap((id) => payload.entities.mergedCommits[id] ?? []); + +/** Where a commit at `index` (0 is the newest merge) stands against one environment's deployment. */ +function environmentState( + deployment: MergebotDeploymentEnvironment, + commits: ReadonlyArray, + index: number, +): "waiting" | "deploying" | "deployed" | "failed" { + const headIndex = Math.max( + 0, + commits.findIndex((commit) => commit.sha === deployment.sha), + ); + const deployedIndex = + deployment.deployedSha === null + ? -1 + : commits.findIndex((commit) => commit.sha === deployment.deployedSha); + + if (deployedIndex !== -1 && index >= deployedIndex) return "deployed"; + if (index < headIndex) return "waiting"; + if (deployment.status === "passed") return "deployed"; + if (deployment.status === "pending") return "deploying"; + return deployment.status === "failed" ? "failed" : "waiting"; +} + +/** + * Maps a merged PR onto the deploy timeline. A merge missing from Mergebot's window but older than + * all of it sits below every listed commit; a newer one has not reached the overview yet. + */ +export function deployState( + repo: Repo, + commits: ReadonlyArray, + pullRequest: { readonly number: number; readonly mergedAt?: string | null | undefined }, +): Deploy | null { + const listed = commits.findIndex((commit) => commit.pullRequestNumber === pullRequest.number); + const oldest = commits.at(-1); + const mergedAt = Date.parse(pullRequest.mergedAt ?? ""); + const index = + listed !== -1 + ? listed + : oldest && mergedAt < Date.parse(oldest.mergedAt) + ? commits.length + : null; + if (index === null) return null; + + const productionHeadIndex = commits.findIndex((commit) => commit.isProductionHead); + const production = repo.productionDeployment + ? environmentState(repo.productionDeployment, commits, index) + : repo.productionBranch === null + ? null + : productionHeadIndex !== -1 && index >= productionHeadIndex + ? "deployed" + : "waiting"; + const staging = repo.stagingDeployment + ? environmentState(repo.stagingDeployment, commits, index) + : null; + + if (production === "deployed") + return { state: "production", environment: "production", etaAt: null }; + if (production === "deploying") + return { + state: "deploying", + environment: "production", + etaAt: repo.productionDeployment?.etaAt ?? null, + }; + if (production === "failed") return { state: "failed", environment: "production", etaAt: null }; + if (staging === "deployed") return { state: "staging", environment: "staging", etaAt: null }; + if (staging === "deploying") + return { + state: "deploying", + environment: "staging", + etaAt: repo.stagingDeployment?.etaAt ?? null, + }; + if (staging === "failed") return { state: "failed", environment: "staging", etaAt: null }; + if (staging === null && production === null) return null; + return { + state: "waiting", + environment: staging === null ? "production" : "staging", + etaAt: null, + }; +} + +/** + * Composes one PR's live status. The overview carries open and recently merged PRs; the detail + * snapshot covers the rest and supplies checks for the current head. + */ +export function pullRequestLiveStatus( + url: string, + repository: string, + payload: RepoPayload, + detail: MergebotPullRequestDetails | null | undefined, + number: number, +): MergebotPullRequestLiveStatus | null { + const id = `${repository}#${number}`; + const pullRequest = payload.entities.pullRequests[id] ?? detail; + const repo = payload.entities.repos[repository]; + if (!pullRequest || !repo) return null; + + const position = payload.view.queuePrIds.indexOf(id) + 1; + const checks = detail && detail.headSha === pullRequest.headSha ? mergebotChecks(detail) : null; + + return { + number, + url: `${url}/pull/${number}`, + title: pullRequest.title, + body: pullRequest.body, + headSha: pullRequest.headSha, + headBranch: pullRequest.headBranch, + lifecycle: pullRequest.lifecycle, + draft: pullRequest.draft, + autoMerge: pullRequest.labels.some((label) => label.name === repo.triggerLabel), + ciStatus: pullRequest.ciStatus, + checks: checks && { + total: checks.length, + completed: checks.filter((check) => check.status !== "pending").length, + failed: checks.filter((check) => check.status === "failure").length, + }, + hasConflicts: pullRequest.hasConflicts, + queueStatus: pullRequest.queueStatus, + position: position === 0 ? null : position, + mergeEtaAt: pullRequest.mergeEtaAt, + deploy: + pullRequest.lifecycle === "merged" + ? deployState(repo, mergedCommitsOf(payload), pullRequest) + : null, + }; +} + +export function deployments( + url: string, + repository: string, + payload: RepoPayload, +): MergebotDeployments | null { + const repo = payload.entities.repos[repository]; + if (!repo) return null; + const users = payload.entities.users; + + return { + repository, + url, + defaultBranch: repo.defaultBranch, + productionBranch: repo.productionBranch, + stagingUrl: repo.stagingUrl, + productionUrl: repo.productionUrl, + staging: repo.stagingDeployment, + production: repo.productionDeployment, + mergedCommits: mergedCommitsOf(payload).map(({ authorId, ...commit }) => { + const user = authorId === null ? undefined : users[authorId]; + return { + ...commit, + authorLogin: user?.login ?? null, + authorName: user?.name ?? commit.authorName, + authorAvatarUrl: user?.avatarUrl ?? null, + }; + }), + }; +} diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index ede4ade4f26aff4bee4b968fb2ce8119cd081489..168c94514903d3be23315e0a12728b1de911323b 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -101,6 +101,18 @@ const RPC_AGGREGATES = { [WS_METHODS.cloudInstallRelayClient]: "cloud", [WS_METHODS.mergebotQueue]: "mergebot", [WS_METHODS.mergebotSubscribe]: "mergebot", + [WS_METHODS.mergebotOpenPullRequest]: "mergebot", + [WS_METHODS.mergebotSetAutoMerge]: "mergebot", + [WS_METHODS.mergebotUpdatePullRequest]: "mergebot", + [WS_METHODS.mergebotSubscribePullRequestStatus]: "mergebot", + [WS_METHODS.mergebotSubscribeDeployments]: "mergebot", + [WS_METHODS.mergebotDeployProduction]: "mergebot", + [WS_METHODS.jjSubscribeStack]: "vcs", + [WS_METHODS.jjSnapshot]: "vcs", + [WS_METHODS.jjDiff]: "vcs", + [WS_METHODS.jjDescribe]: "vcs", + [WS_METHODS.jjSquash]: "vcs", + [WS_METHODS.jjPush]: "vcs", [WS_METHODS.pullRequestsList]: "pull-requests", [WS_METHODS.pullRequestsListStats]: "pull-requests", [WS_METHODS.pullRequestsSummary]: "pull-requests", diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.test.ts index 26091236091f8fb4b2f87a14d6a7af5a074f7b52..5793c662c1bc3eaa93150c2d78add18b42e85e8d 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.test.ts @@ -299,6 +299,12 @@ it.effect( mergebot: { configuration: { origin: "https://mergebot.example", repositories: [key.repository] }, read: () => Effect.succeed(null), + watchPullRequestStatus: () => Stream.empty, + watchDeployments: () => Stream.empty, + openPullRequest: () => Effect.die("unused"), + setAutoMerge: () => Effect.die("unused"), + updatePullRequest: () => Effect.die("unused"), + deployProduction: () => Effect.die("unused"), watchQueue: () => Stream.empty, findPullRequest: () => Effect.succeed(null), readPullRequest: () => diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index dc7a0378fa0db91b679304055571ccf3276ad5bd..2efb036bfd027c565f5ceefd2c680c3d7e4117ad 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -7959,6 +7959,12 @@ it.effect( mergebot: { configuration: { origin: "https://mergebot.example", repositories: ["work/app"] }, read: () => Effect.succeed(null), + watchPullRequestStatus: () => Stream.empty, + watchDeployments: () => Stream.empty, + openPullRequest: () => Effect.die("unused"), + setAutoMerge: () => Effect.die("unused"), + updatePullRequest: () => Effect.die("unused"), + deployProduction: () => Effect.die("unused"), watchQueue: () => Stream.empty, readPullRequest: () => Effect.sync(() => value), findPullRequest: () => Effect.succeed(null), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ed65039d9d74645522e7cfb1c2b39fcf2a15d8f6..0373ca51e94ec9b691fd54fa5680466b373a7140 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -126,6 +126,7 @@ import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; +import * as JjStackService from "./vcs/JjStackService.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; @@ -658,6 +659,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), Layer.provideMerge(layerServerSettings), Layer.provideMerge(MergebotQueue.layer), + Layer.provideMerge(JjStackService.layer), // The asset route uses the registry's GitHub credential for private PR media, which the // built-in drivers' layer provides alongside the registry. Layer.provideMerge(layerSourceControlProviderRegistry), diff --git a/apps/server/src/vcs/JjStackService.test.ts b/apps/server/src/vcs/JjStackService.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..eee1d78cd3d20def6e0f062f99b904eb92a63f05 --- /dev/null +++ b/apps/server/src/vcs/JjStackService.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/process"; + +import { gitDiffFileStats, githubRepository } from "./JjStackService.ts"; +import * as JjStackService from "./JjStackService.ts"; +import { VcsProcess } from "./VcsProcess.ts"; + +describe("gitDiffFileStats", () => { + it("counts lines per file and keeps rename sources", () => { + const diff = [ + "diff --git a/src/old.ts b/src/new.ts", + "rename from src/old.ts", + "rename to src/new.ts", + "--- a/src/old.ts", + "+++ b/src/new.ts", + "@@ -1,2 +1,2 @@", + "-const a = 1;", + "+const a = 2;", + " export { a };", + "diff --git a/README.md b/README.md", + "new file mode 100644", + "--- /dev/null", + "+++ b/README.md", + "@@ -0,0 +1,2 @@", + "+# Title", + "+--- a line that looks like a header", + ].join("\n"); + expect(gitDiffFileStats(diff)).toEqual([ + { path: "src/new.ts", previousPath: "src/old.ts", additions: 1, deletions: 1 }, + { path: "README.md", previousPath: null, additions: 2, deletions: 0 }, + ]); + }); +}); + +describe("githubRepository", () => { + const remotes = [ + "origin git@github.com:acme/app.git", + "upstream https://github.com/acme/upstream", + "mirror https://git.example.com/acme/app.git", + "lookalike https://notgithub.com/acme/app.git", + ].join("\n"); + + it("reads owner/name from the push remote's GitHub URL", () => { + expect(githubRepository(remotes, "origin")).toBe("acme/app"); + expect(githubRepository(remotes, "upstream")).toBe("acme/upstream"); + }); + + it("returns null for other hosts and unknown remotes", () => { + expect(githubRepository(remotes, "mirror")).toBeNull(); + expect(githubRepository(remotes, "lookalike")).toBeNull(); + expect(githubRepository(remotes, "fork")).toBeNull(); + }); +}); + +const pushLayer = (bookmarks: string) => + JjStackService.layer.pipe( + Layer.provide( + Layer.succeed( + VcsProcess, + VcsProcess.of({ + run: (input) => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: + input.operation === "JjStackService.push-template" + ? '"review-" ++ change_id.short()' + : input.operation === "JjStackService.bookmark-name" + ? "review-selected" + : input.operation === "JjStackService.pushed-bookmark" + ? bookmarks + : "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + ), + Layer.provide(NodeServices.layer), + ); + +describe("JjStackService.push", () => { + it.effect("returns the bookmark it pushed when other bookmarks point at the same commit", () => + Effect.gen(function* () { + const service = yield* JjStackService.JjStackService; + const result = yield* service.push({ cwd: "/workspace", changeId: "selected" }); + expect(result).toEqual({ bookmark: "review-selected", remote: "origin" }); + }).pipe(Effect.provide(pushLayer("another-pr\torigin\nreview-selected\torigin"))), + ); + + it.effect("refuses to report success when only another bookmark is pushed", () => + Effect.gen(function* () { + const service = yield* JjStackService.JjStackService; + const result = yield* service + .push({ cwd: "/workspace", changeId: "selected" }) + .pipe(Effect.flip); + expect(result).toMatchObject({ _tag: "JjError", operation: "push" }); + }).pipe(Effect.provide(pushLayer("another-pr\torigin"))), + ); +}); diff --git a/apps/server/src/vcs/JjStackService.ts b/apps/server/src/vcs/JjStackService.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a33ca263dec41bb7f5dd4f7795c719cc4725684 --- /dev/null +++ b/apps/server/src/vcs/JjStackService.ts @@ -0,0 +1,404 @@ +import { + JjDescriptionConflictError, + JjError, + type JjChange, + type JjChangeInput, + type JjDescribeInput, + type JjDiffInput, + type JjDiffResult, + type JjPushInput, + type JjPushResult, + type JjStack, + type JjStackInput, + type ReviewDiffFileStat, +} from "@t3tools/contracts"; +import { parseGitHubRepositoryNameWithOwnerFromRemoteUrl } from "@t3tools/shared/git"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { VcsProcess } from "./VcsProcess.ts"; + +/** Ancestors down to trunk plus anything above `@`; sibling lines that share a base stay out. */ +const STACK_REVSET = "(::@ | @::) ~ ::trunk() ~ (empty() ~ @)"; +const STACK_LIMIT = 60; +const MAX_DIFF_BYTES = 4_000_000; + +const STACK_TEMPLATE = [ + '"{\\"changeId\\":" ++ json(change_id)', + '",\\"shortId\\":" ++ json(stringify(change_id.shortest(4)))', + '",\\"commitId\\":" ++ json(commit_id)', + '",\\"parentCommitIds\\":[" ++ parents.map(|p| json(p.commit_id())).join(",") ++ "]"', + '",\\"description\\":" ++ json(description)', + '",\\"trailers\\":[" ++ trailers.map(|t| "{\\"key\\":" ++ json(t.key()) ++ ",\\"value\\":" ++ json(t.value()) ++ "}").join(",") ++ "]"', + '",\\"localBookmarks\\":[" ++ local_bookmarks.map(|b| json(b.name())).join(",") ++ "]"', + '",\\"remoteBookmarks\\":[" ++ remote_bookmarks.filter(|b| b.remote() != "git").map(|b| json(b.name())).join(",") ++ "]"', + '",\\"immutable\\":" ++ json(immutable)', + '",\\"conflict\\":" ++ json(conflict)', + '",\\"empty\\":" ++ json(empty)', + '",\\"workingCopy\\":" ++ json(current_working_copy)', + '",\\"additions\\":" ++ json(diff.stat().total_added())', + '",\\"deletions\\":" ++ json(diff.stat().total_removed())', + '",\\"authoredAt\\":" ++ json(stringify(author.timestamp().format("%Y-%m-%dT%H:%M:%S%:z"))) ++ "}\\n"', +].join(" ++ "); + +const RawChange = Schema.Struct({ + changeId: Schema.String, + shortId: Schema.String, + commitId: Schema.String, + parentCommitIds: Schema.Array(Schema.String), + description: Schema.String, + trailers: Schema.Array(Schema.Struct({ key: Schema.String, value: Schema.String })), + localBookmarks: Schema.Array(Schema.String), + remoteBookmarks: Schema.Array(Schema.String), + immutable: Schema.Boolean, + conflict: Schema.Boolean, + empty: Schema.Boolean, + workingCopy: Schema.Boolean, + additions: Schema.Number, + deletions: Schema.Number, + authoredAt: Schema.String, +}); +const decodeRawChange = Schema.decodeUnknownSync(Schema.fromJsonString(RawChange)); + +function toChange(line: string): JjChange { + const raw = decodeRawChange(line); + return { + changeId: raw.changeId, + shortId: raw.shortId, + commitId: raw.commitId, + parentCommitIds: raw.parentCommitIds, + description: raw.description.replace(/\n+$/, ""), + trailers: raw.trailers, + bookmarks: raw.localBookmarks.map((name) => ({ + name, + pushed: raw.remoteBookmarks.includes(name), + })), + immutable: raw.immutable, + conflict: raw.conflict, + empty: raw.empty, + workingCopy: raw.workingCopy, + additions: raw.additions, + deletions: raw.deletions, + authoredAt: raw.authoredAt, + }; +} + +/** Per-file statistics from `jj diff --git`, so the client can list files before rendering patches. */ +export function gitDiffFileStats(diff: string): ReviewDiffFileStat[] { + const files: ReviewDiffFileStat[] = []; + let current: { + path: string; + previousPath: string | null; + additions: number; + deletions: number; + } | null = null; + for (const line of diff.split("\n")) { + const header = /^diff --git a\/(.+) b\/(.+)$/.exec(line); + if (header) { + if (current) files.push(current); + current = { path: header[2]!, previousPath: null, additions: 0, deletions: 0 }; + continue; + } + if (!current) continue; + if (line.startsWith("rename from ")) current.previousPath = line.slice("rename from ".length); + else if (line.startsWith("+") && !line.startsWith("+++ ")) current.additions++; + else if (line.startsWith("-") && !line.startsWith("--- ")) current.deletions++; + } + if (current) files.push(current); + return files; +} + +/** `owner/name` of the named remote in `jj git remote list` output, when it is on GitHub. */ +export function githubRepository(remoteList: string, remote: string): string | null { + for (const line of remoteList.split("\n")) { + const [name, url] = line.trim().split(/\s+/); + if (name !== remote || !url) continue; + return parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url); + } + return null; +} + +export class JjStackService extends Context.Service< + JjStackService, + { + readonly watchStack: (input: JjStackInput) => Stream.Stream; + readonly snapshot: (input: JjStackInput) => Effect.Effect; + readonly diff: (input: JjDiffInput) => Effect.Effect; + readonly describe: ( + input: JjDescribeInput, + ) => Effect.Effect<{ readonly commitId: string }, JjError | JjDescriptionConflictError>; + readonly squash: (input: JjChangeInput) => Effect.Effect; + readonly push: (input: JjPushInput) => Effect.Effect; + } +>()("t3/vcs/JjStackService") {} + +const make = Effect.gen(function* () { + const vcs = yield* VcsProcess; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const jj = ( + operation: string, + cwd: string, + args: ReadonlyArray, + options: { + readonly stdin?: string; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + } = {}, + ) => + vcs + .run({ + operation: `JjStackService.${operation}`, + command: "jj", + args: ["--no-pager", "--color=never", ...args], + cwd, + ...options, + }) + .pipe(Effect.mapError((error) => new JjError({ operation, detail: error.message }))); + + const workspaceRoot = (cwd: string) => + jj("root", cwd, ["--ignore-working-copy", "root"]).pipe( + Effect.map((output) => output.stdout.trim() || null), + Effect.orElseSucceed(() => null), + ); + + const readStack = Effect.fn("JjStackService.readStack")(function* (root: string) { + const [changes, trunk, behind, remotes, pushRemote] = yield* Effect.all( + [ + jj("log", root, [ + "--ignore-working-copy", + "log", + "--no-graph", + "--limit", + String(STACK_LIMIT), + "-r", + STACK_REVSET, + "-T", + STACK_TEMPLATE, + ]), + jj("trunk", root, [ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + "trunk()", + "-T", + 'commit_id ++ "\\t" ++ remote_bookmarks.filter(|b| b.remote() != "git").map(|b| stringify(b.name() ++ "@" ++ b.remote())).join(",") ++ "\\n"', + ]), + jj("behind", root, [ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + "::trunk() ~ ::@", + "-T", + '"."', + ]), + jj("remotes", root, ["--ignore-working-copy", "git", "remote", "list"]), + jj("push-remote", root, ["--ignore-working-copy", "config", "get", "git.push"]).pipe( + Effect.map((output) => output.stdout.trim()), + Effect.orElseSucceed(() => ""), + ), + ], + { concurrency: "unbounded" }, + ); + const [trunkCommitId, trunkNames] = trunk.stdout.trim().split("\t"); + return { + workspaceRoot: root, + repository: githubRepository(remotes.stdout, pushRemote || "origin"), + trunk: trunkCommitId + ? { + name: trunkNames?.split(",")[0] || "trunk()", + commitId: trunkCommitId, + behind: behind.stdout.trim().length, + } + : null, + changes: changes.stdout + .split("\n") + .filter((line) => line.trim()) + .map(toChange), + } satisfies JjStack; + }); + + // Every jj operation, from any workspace of the repo, adds a file under op_heads/heads. + const opHeadsDirectory = Effect.fn("JjStackService.opHeadsDirectory")(function* (root: string) { + const repoPath = path.join(root, ".jj", "repo"); + const info = yield* fs.stat(repoPath); + const repo = + info.type === "File" + ? path.resolve(path.join(root, ".jj"), (yield* fs.readFileString(repoPath)).trim()) + : repoPath; + return path.join(repo, "op_heads", "heads"); + }); + + const watchStack = (input: JjStackInput) => + Stream.unwrap( + Effect.gen(function* () { + const root = yield* workspaceRoot(input.cwd); + if (!root) return Stream.make(null); + const heads = yield* opHeadsDirectory(root).pipe( + Effect.mapError((error) => new JjError({ operation: "watch", detail: String(error) })), + ); + const changes = fs.watch(heads).pipe( + Stream.debounce(Duration.millis(150)), + Stream.catch(() => Stream.empty), + ); + let previous = ""; + return Stream.concat(Stream.make(undefined), changes).pipe( + Stream.mapEffect(() => readStack(root)), + Stream.filter((stack) => { + const key = JSON.stringify(stack); + if (key === previous) return false; + previous = key; + return true; + }), + ); + }), + ); + + const snapshot = (input: JjStackInput) => + jj("snapshot", input.cwd, ["util", "snapshot"]).pipe(Effect.asVoid); + + const diff = Effect.fn("JjStackService.diff")(function* (input: JjDiffInput) { + const output = yield* jj( + "diff", + input.cwd, + [ + "--ignore-working-copy", + "diff", + "--git", + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + "--from", + input.fromCommitId, + "--to", + input.toCommitId, + ], + { maxOutputBytes: MAX_DIFF_BYTES }, + ); + const range = `${input.fromCommitId}..${input.toCommitId}`; + return { + id: range, + kind: "branch-range", + title: "Changes", + baseRef: input.fromCommitId, + headRef: input.toCommitId, + diff: output.stdout, + // Commit ids are content addresses, so the range alone identifies the diff. + diffHash: `${range}${input.ignoreWhitespace ? ":w" : ""}`, + truncated: output.stdoutTruncated, + files: gitDiffFileStats(output.stdout), + } satisfies JjDiffResult; + }); + + const readCommit = (cwd: string, revision: string) => + jj("show-commit", cwd, [ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + revision, + "-T", + 'commit_id ++ "\\n" ++ description', + ]).pipe( + Effect.map((output) => { + const newline = output.stdout.indexOf("\n"); + return { + commitId: output.stdout.slice(0, newline), + description: output.stdout.slice(newline + 1).replace(/\n+$/, ""), + }; + }), + ); + + const describe = Effect.fn("JjStackService.describe")(function* (input: JjDescribeInput) { + const [current, loaded] = yield* Effect.all([ + readCommit(input.cwd, input.changeId), + readCommit(input.cwd, input.expectedCommitId), + ]); + if (current.commitId !== input.expectedCommitId && current.description !== loaded.description) { + return yield* new JjDescriptionConflictError({ + changeId: input.changeId, + description: current.description, + commitId: current.commitId, + }); + } + if (current.description !== input.message) { + yield* jj( + "describe", + input.cwd, + ["--ignore-working-copy", "describe", "-r", input.changeId, "--stdin"], + { stdin: input.message }, + ); + } + return { commitId: (yield* readCommit(input.cwd, input.changeId)).commitId }; + }); + + const squash = (input: JjChangeInput) => + jj("squash", input.cwd, ["squash", "-r", input.changeId, "--use-destination-message"]).pipe( + Effect.asVoid, + ); + + const push = Effect.fn("JjStackService.push")(function* (input: JjPushInput) { + const network = { timeoutMs: 120_000 }; + let bookmark = input.bookmark; + if (bookmark) { + yield* jj("bookmark-move", input.cwd, ["bookmark", "move", bookmark, "--to", input.changeId]); + yield* jj("push", input.cwd, ["git", "push", "--bookmark", bookmark], network); + } else { + const template = yield* jj("push-template", input.cwd, [ + "--ignore-working-copy", + "config", + "get", + "templates.git_push_bookmark", + ]).pipe( + Effect.map((output) => output.stdout.trim()), + Effect.orElseSucceed(() => '"push-" ++ change_id.short()'), + ); + bookmark = (yield* jj("bookmark-name", input.cwd, [ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + input.nameFrom ?? input.changeId, + "-T", + template, + ])).stdout.trim(); + yield* jj( + "push", + input.cwd, + ["git", "push", "--named", `${bookmark}=${input.changeId}`], + network, + ); + } + const pushed = yield* jj("pushed-bookmark", input.cwd, [ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + input.changeId, + "-T", + 'remote_bookmarks.filter(|b| b.remote() != "git").map(|b| stringify(b.name() ++ "\\t" ++ b.remote())).join("\\n")', + ]); + const remote = pushed.stdout + .split("\n") + .find((line) => line.startsWith(`${bookmark}\t`)) + ?.split("\t")[1]; + if (!bookmark || !remote) { + return yield* new JjError({ + operation: "push", + detail: "No pushed bookmark points at this change.", + }); + } + return { bookmark, remote }; + }); + + return { watchStack, snapshot, diff, describe, squash, push }; +}); + +export const layer = Layer.effect(JjStackService, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2dd0c3faecea0c7888dbf56c921ed5be33329e22..d17331ceec4672324df9fb75f1b28a3b2376db00 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -188,6 +188,7 @@ import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/Atta import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; +import * as JjStackService from "./vcs/JjStackService.ts"; import * as DevServer from "./workspace/DevServer.ts"; import * as ManagedWorkspaces from "./workspace/ManagedWorkspaces.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; @@ -1233,6 +1234,7 @@ const layerWsRpc = ( const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const devServer = yield* DevServer.DevServer; + const jjStack = yield* JjStackService.JjStackService; const managedWorkspaces = yield* ManagedWorkspaces.ManagedWorkspaces; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; @@ -2419,6 +2421,13 @@ const layerWsRpc = ( ), [WS_METHODS.mergebotQueue]: (input) => mergebotQueue.read(input), [WS_METHODS.mergebotSubscribe]: (input) => mergebotQueue.watchQueue(input), + [WS_METHODS.mergebotSubscribePullRequestStatus]: (input) => + mergebotQueue.watchPullRequestStatus(input), + [WS_METHODS.mergebotSubscribeDeployments]: (input) => mergebotQueue.watchDeployments(input), + [WS_METHODS.mergebotOpenPullRequest]: (input) => mergebotQueue.openPullRequest(input), + [WS_METHODS.mergebotSetAutoMerge]: (input) => mergebotQueue.setAutoMerge(input), + [WS_METHODS.mergebotUpdatePullRequest]: (input) => mergebotQueue.updatePullRequest(input), + [WS_METHODS.mergebotDeployProduction]: (input) => mergebotQueue.deployProduction(input), [WS_METHODS.pullRequestsList]: (input) => pullRequests.list(input), [WS_METHODS.pullRequestsListStats]: (input) => pullRequests.listStats(input), [WS_METHODS.pullRequestsRoutingIdentity]: (input) => pullRequests.routingIdentity(input), @@ -2611,6 +2620,12 @@ const layerWsRpc = ( [WS_METHODS.managedWorkspacesArchiveCompleted]: (input) => managedWorkspaces.archiveCompleted(input), [WS_METHODS.devServerStatus]: (input) => devServer.status(input), + [WS_METHODS.jjSubscribeStack]: (input) => jjStack.watchStack(input), + [WS_METHODS.jjSnapshot]: (input) => jjStack.snapshot(input), + [WS_METHODS.jjDiff]: (input) => jjStack.diff(input), + [WS_METHODS.jjDescribe]: (input) => jjStack.describe(input), + [WS_METHODS.jjSquash]: (input) => jjStack.squash(input), + [WS_METHODS.jjPush]: (input) => jjStack.push(input), [WS_METHODS.devServerStart]: (input) => devServer.start(input), [WS_METHODS.projectsReadFile]: (input) => workspaceFileSystem.readFile(input).pipe( diff --git a/apps/web/src/closedViewStore.ts b/apps/web/src/closedViewStore.ts index 76fee83a83d088590e94ba17cf3b270dd0d9e497..327a31a1264970c6965f06f307f8b16d271508eb 100644 --- a/apps/web/src/closedViewStore.ts +++ b/apps/web/src/closedViewStore.ts @@ -63,6 +63,7 @@ const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => { case "diff": case "files": case "pull-requests": + case "deployments": return surface.id === surface.kind; case "preview": return surface.resourceId === null || typeof surface.resourceId === "string"; diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 4a5946c85f67e232ea7ea58a0f0d1c8cf0d35327..d2c83d232a86f4366192a015d34a677509bfba15 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -35,7 +35,6 @@ import LegacyThreadSidebar from "./LegacySidebar"; import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; import ThreadSidebar from "./Sidebar"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; -import { NewWorkWorkspaceButton } from "./sidebar/NewWorkWorkspaceButton"; import { AppMenu } from "./sidebar/AppMenu"; import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; @@ -54,7 +53,6 @@ import { SidebarRail, SidebarTrigger, useSidebar, - useSidebarVisibility, } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -85,7 +83,6 @@ function SidebarControl() { const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar, isMobile, mobileSheetMounted } = useSidebar(); - const isSidebarVisible = useSidebarVisibility(); const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", { context: { usagePageOpen }, }); @@ -156,11 +153,6 @@ function SidebarControl() { Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} - {isSidebarVisible ? ( -
- -
- ) : null} ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f42c19bf42ba1bab46c9c401ad726129c34076ca..2dde40a9696ccdd452d40e7a444480602618b839 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import { supportsWorkspaceChanges } from "./review/jjReview.logic"; import { ThreadFind, ThreadFindCanvas, type ThreadFindControls } from "./chat/ThreadFindProvider"; import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar"; import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner"; @@ -718,7 +719,10 @@ function useDraftHeroLayoutTransition( const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), ); -const DiffPanel = lazy(() => import("./DiffPanel")); +const DiffPanel = lazy(() => import("./review/DiffSurface")); +const DeploymentsPanel = lazy(() => + import("./deployments/DeploymentsPanel").then((module) => ({ default: module.DeploymentsPanel })), +); const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) => settings.browserAutoShowFloatingPreview; const DevicePanel = lazy(() => @@ -4287,6 +4291,8 @@ export default function ChatView(props: ChatViewProps) { } }, [environmentId, gitStatusCwd, liveIsGitRepo]); const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true; + const workspaceChangesAvailable = + isServerThread && supportsWorkspaceChanges({ ...gitStatusQuery.data, isRepo: isGitRepo }); // When context is enabled, keep a hidden, off-flow strip mounted so the composer // can measure whether its relocated controls fit. The visible chrome remains // content-driven: Git/environment context or controls that actually fit. @@ -5554,11 +5560,11 @@ export default function ChatView(props: ChatViewProps) { [activeThreadRef, canOperatePreview, openPreview], ); const addDiffSurface = useCallback(() => { - if (!activeThreadRef || !isServerThread || !isGitRepo) return; + if (!activeThreadRef || !workspaceChangesAvailable) return; useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); - }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); + }, [activeThreadRef, workspaceChangesAvailable, onDiffPanelOpen]); const openChangesFromThreadPanel = useCallback(() => { addDiffSurface(); }, [addDiffSurface]); @@ -11278,6 +11284,10 @@ export default function ChatView(props: ChatViewProps) { /> ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( + ) : renderedRightPanelSurface?.kind === "deployments" && activeThreadRef ? ( + + + ) : renderedRightPanelSurface?.kind === "device" ? ( ({ toasts: [] as { type?: string; description?: string; actionProps?: { onClick: () => void } }[], scopes: new Set(), primaryScopes: new Set(), + isRepo: true, })); vi.mock("@effect/atom-react", () => ({ @@ -40,7 +41,7 @@ vi.mock("~/state/query", () => ({ useEnvironmentQuery: () => ({ data: { sourceControlProviders: [], - isRepo: true, + isRepo: state.isRepo, refName: "main", isDefaultRef: false, hasPrimaryRemote: true, @@ -191,6 +192,7 @@ beforeEach(() => { state.primaryScopes = new Set([AuthSourceControlWriteScope]); state.actions = []; state.toasts = []; + state.isRepo = true; }); afterEach(async () => { @@ -200,6 +202,35 @@ afterEach(async () => { }); describe("Git action targets across thread navigation", () => { + it("keeps Changes reachable in a JJ-only panel without rendering Git actions", async () => { + state.isRepo = false; + let renderer!: ReturnType; + await act(async () => { + renderer = create( + createElement(MemoGitActionsControl, { + ...props("jj-thread", "/jj-workspace"), + displayMode: "panel", + onOpenChanges: () => {}, + }), + ); + }); + renderers.push(renderer); + expect(renderer.root.findAllByType("button")).toHaveLength(1); + expect(JSON.stringify(renderer.toJSON())).toContain("Changes"); + expect(JSON.stringify(renderer.toJSON())).not.toContain("Commit"); + expect(state.actions).toEqual([]); + + await act(async () => { + renderer.update( + createElement(MemoGitActionsControl, { + ...props("plain-thread", "/plain-folder"), + displayMode: "panel", + }), + ); + }); + expect(renderer.toJSON()).toBeNull(); + }); + it("runs a retained PR toast in the thread that produced it", async () => { const renderer = await renderActions("first", "/first"); await clickQuickAction(renderer); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index cd3e701a76b58f4bd6203fb00ac78607013ec526..11be98ec2bc9e510deedb9c0906f01a657b033ea 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -14,15 +14,7 @@ import type { GitStackedAction, VcsStatusResult, } from "@t3tools/contracts"; -import { - type MouseEvent, - useCallback, - useEffect, - useEffectEvent, - useMemo, - useRef, - useState, -} from "react"; +import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CheckIcon, ChevronDownIcon, @@ -1073,7 +1065,30 @@ export default function GitActionsControl({ ); - if (!gitCwd || !isRepo) return null; + const changesControl = isPanel ? ( + + + Changes + {isRepo ? ( + + +{formatDiffCount(changesTotals?.insertions ?? 0)} + + -{formatDiffCount(changesTotals?.deletions ?? 0)} + + + ) : null} + + ) : null; + + if (!gitCwd) return null; + if (!isRepo) return onOpenChanges ? changesControl : null; return ( <> @@ -1248,25 +1263,7 @@ export default function GitActionsControl({ )} - {isPanel && isRepo ? ( - - - Changes - - +{formatDiffCount(changesTotals?.insertions ?? 0)} - - -{formatDiffCount(changesTotals?.deletions ?? 0)} - - - - ) : null} + {changesControl} ({ useTheme: () => ({ resolvedTheme: "light" }) })); vi.mock("~/browser/browserDefaults", () => ({ useBrowserDefaults: () => ({ profiles: [] }) })); @@ -110,6 +111,21 @@ async function press(key: string, options: KeyboardEventInit = {}) { } describe("right panel new-tab shortcut", () => { + it("offers Diff in a non-colocated Jujutsu workspace and disables it in a plain folder", async () => { + for (const isJjWorkspace of [true, false]) { + await renderPanel({ + diffAvailable: supportsWorkspaceChanges({ isRepo: false, isJjWorkspace }), + }); + await press("t", { metaKey: true }); + const item = [...document.querySelectorAll('[role="menuitem"]')].find((entry) => + entry.textContent?.includes("Diff"), + ); + expect(item).toBeDefined(); + expect(item?.getAttribute("aria-disabled") === "true").toBe(!isJjWorkspace); + await press("Escape"); + } + }); + it.each(["MacIntel", "Win32", "Linux x86_64"])( "opens the menu on %s and chooses a tab", async (platform) => { diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index e44dfcad0cbb005a0384779cc6d566dd888375cb..170d6920a4b64b483e4f6a6fc8ce658a52fc9468 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -35,6 +35,7 @@ import { Globe2, Plus, TerminalSquare, + RocketIcon, } from "lucide-react"; import { Volume2, VolumeOff } from "lucide"; import { @@ -174,7 +175,7 @@ const SURFACE_DISABLED_REASONS = { browser: "Browser previews are only available in The Knife desktop app.", terminal: "Terminal surfaces are only available from a project thread.", files: "Files are only available when a project is open.", - diff: "Diff is only available for server threads in Git repositories.", + diff: "Diff is only available for server threads in Git or Jujutsu workspaces.", pullRequest: "This thread's branch has no pull request yet.", pullRequests: "No linked pull requests are available for this thread.", device: "Devices are only available from a thread.", @@ -197,7 +198,7 @@ const SURFACE_UNAVAILABLE_HINTS = { browser: "Only available in the desktop app.", terminal: "Available when a project is open.", files: "Available when a project is open.", - diff: "Available for Git repositories.", + diff: "Available for Git or Jujutsu workspaces.", pullRequest: "No pull request on this branch yet.", pullRequests: "No linked pull requests available.", device: "Available from a thread.", @@ -618,6 +619,8 @@ function surfaceTitle( return `#${surface.number}`; case "pull-requests": return "Pull requests"; + case "deployments": + return "Deployments"; case "device": return surface.title ?? surface.target?.name ?? "Device"; case "preview": { @@ -701,6 +704,8 @@ function SurfaceIcon({ ); case "pull-requests": return ; + case "deployments": + return ; case "device": return surface.target?.platform === "ios" ? ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d9c836d7d98ae2dc853403342b5028af9c2d6bce..29d087629d9dab39dbcea687871338e4fd443b9d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -105,6 +105,7 @@ import { } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; +import { ThreadMergebotPill, useIsMergebotRepository } from "./review/ThreadMergebotPill"; import { useRightPanelStore } from "../rightPanelStore"; import { isAtomCommandInterrupted, @@ -1299,6 +1300,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const currentLinkedPr = supportsMultiplePullRequests ? resolveThreadCurrentPullRequestLink(thread.pullRequests) : null; + const mergebotPullRequest = useIsMergebotRepository( + thread.environmentId, + currentLinkedPr?.repository, + ) + ? currentLinkedPr + : null; // Same semantics as the legacy sidebar (never-visited counts as read): // switching sidebars must not light up every historical thread as unread. @@ -2201,6 +2208,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null}
+ {mergebotPullRequest ? ( + + ) : null} {/* Always the branch. The plan step used to take this slot while working, but it truncated to a half-sentence and dropped the branch, so the row lost its most stable identifier. */} @@ -2226,7 +2240,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )} {terminalStatusIcon} - {prBadge} + {mergebotPullRequest ? null : prBadge} {diff ? ( diff --git a/apps/web/src/components/deployments/ConfiguredDeployments.tsx b/apps/web/src/components/deployments/ConfiguredDeployments.tsx new file mode 100644 index 0000000000000000000000000000000000000000..621af8c227c12376caee81f9a097f4919c08623f --- /dev/null +++ b/apps/web/src/components/deployments/ConfiguredDeployments.tsx @@ -0,0 +1,69 @@ +import type { EnvironmentId, MergebotDeployments } from "@t3tools/contracts"; +import type { ReactNode } from "react"; + +import { useEnvironment } from "../../state/environments"; +import { useMergebotDeployments } from "../../state/mergebot"; + +type DeploymentsRender = (deployments: readonly MergebotDeployments[]) => ReactNode; + +/** + * Renders `children` with deployment data for every Mergebot repository configured on the + * environment that has any. One component per repository keeps each hook call order stable. + */ +export function ConfiguredDeployments(props: { + environmentId: EnvironmentId; + children: DeploymentsRender; +}) { + const environment = useEnvironment(props.environmentId); + + return ( + + {props.children} + + ); +} + +function Collect(props: { + environmentId: EnvironmentId; + repositories: readonly string[]; + found: readonly MergebotDeployments[]; + children: DeploymentsRender; +}) { + const [repository, ...rest] = props.repositories; + if (repository === undefined) return props.children(props.found); + + return ( + + {props.children} + + ); +} + +function CollectRepository(props: { + environmentId: EnvironmentId; + repository: string; + rest: readonly string[]; + found: readonly MergebotDeployments[]; + children: DeploymentsRender; +}) { + const deployments = useMergebotDeployments(props.environmentId, props.repository); + + return ( + + {props.children} + + ); +} diff --git a/apps/web/src/components/deployments/DeploymentsPanel.tsx b/apps/web/src/components/deployments/DeploymentsPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..625105e0641801a45129cd7d85e49e0d90c7904c --- /dev/null +++ b/apps/web/src/components/deployments/DeploymentsPanel.tsx @@ -0,0 +1,310 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId, MergebotDeployments, MergebotMergedCommit } from "@t3tools/contracts"; +import { CircleDashedIcon, CircleXIcon, ExternalLinkIcon } from "lucide-react"; +import { useState } from "react"; + +import { cn } from "../../lib/utils"; +import { useEnvironment } from "../../state/environments"; +import { mergebotEnvironment } from "../../state/mergebot"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useNowMinute } from "../../hooks/useNowMinute"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { PullRequestActorAvatar } from "../pullRequest/pullRequestPresentation"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { ScrollArea } from "../ui/scroll-area"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { ConfiguredDeployments } from "./ConfiguredDeployments"; +import { CommitTitle } from "../review/commitTitle"; +import { + buildDeploymentTimeline, + railCommitCount, + type DeploymentTimelineItem, +} from "./deployments.logic"; + +/** Mergebot's deploys pane: environment markers over the merged-commit rail. */ +export function DeploymentsPanel(props: { environmentId: EnvironmentId }) { + return ( + + {(found) => } + + ); +} + +function DeploymentsView(props: { + environmentId: EnvironmentId; + found: readonly MergebotDeployments[]; +}) { + const configuration = useEnvironment(props.environmentId)?.serverConfig?.mergebot; + const [selectedRepository, setSelectedRepository] = useState(null); + const deployments = + props.found.find((entry) => entry.repository === selectedRepository) ?? props.found[0]; + + if (!configuration) { + return Mergebot is not configured on this environment.; + } + if (!deployments) { + return Waiting for deployment data from Mergebot…; + } + + return ( +
+
+ {props.found.length > 1 ? ( + + ) : ( + + {deployments.repository} + + )} + +
+ + + +
+ ); +} + +function PanelMessage(props: { children: string }) { + return ( +
+

{props.children}

+
+ ); +} + +function DeploymentTimeline(props: { + environmentId: EnvironmentId; + deployments: MergebotDeployments; +}) { + const { deployments } = props; + const nowMinute = useNowMinute(); + const [expanded, setExpanded] = useState(false); + const [confirming, setConfirming] = useState(null); + const [deploying, setDeploying] = useState(false); + const canDeploy = useAtomValue( + mergebotEnvironment.deployProduction.permissionAtom(props.environmentId), + ); + const deployProduction = useAtomCommand(mergebotEnvironment.deployProduction); + + const railCount = railCommitCount(deployments); + const hasMore = deployments.mergedCommits.length > railCount; + const items = buildDeploymentTimeline( + deployments, + Date.parse(`${nowMinute}Z`), + expanded ? undefined : railCount, + ); + + const confirm = async () => { + if (!confirming) return; + setDeploying(true); + const result = await deployProduction({ + environmentId: props.environmentId, + input: { repository: deployments.repository, sha: confirming.sha }, + }); + setDeploying(false); + if (result._tag === "Success") setConfirming(null); + }; + + const confirmTitle = confirming ? firstLine(confirming.message) : ""; + + return ( +
+ {items.map((item) => + item.kind === "marker" ? ( + + ) : ( + setConfirming(item.commit) : null} + /> + ), + )} + {hasMore ? ( + + ) : null} + + { + if (!open && !deploying) setConfirming(null); + }} + > + + + Deploy to production? + + Fast-forwards {deployments.productionBranch} to{" "} + {confirming?.sha.slice(0, 7)} + {` (${confirmTitle})`} and ships every commit up to it. + + + + } disabled={deploying}> + Cancel + + + + + +
+ ); +} + +const MARKER_TONE = { + default: { text: "text-muted-foreground", line: "bg-border" }, + pending: { text: "text-warning-foreground", line: "bg-warning" }, + failed: { text: "text-destructive-foreground", line: "bg-border" }, +} as const; + +function TimelineMarker(props: { item: Extract }) { + const { label, tone, href } = props.item; + const content = ( + <> + + + {tone === "pending" ? : null} + {tone === "failed" ? : null} + {label} + + + + ); + const className = "flex items-center gap-2 py-1"; + + return href ? ( +
+ {content} + + ) : ( +
{content}
+ ); +} + +const COMMIT_TONE = { + deploying: "border-warning/60 bg-warning-surface", + hotfix: "border-warning/40 bg-warning-surface", + unshipped: "border-success/50", + default: "border-border", +} as const; + +function firstLine(message: string) { + return message.split("\n", 1)[0]?.trim() ?? ""; +} + +function MergedCommitRow(props: { + item: Extract; + onDeploy: (() => void) | null; +}) { + const { commit, tone, isDeployCandidate } = props.item; + const authorName = (commit.authorName ?? commit.authorLogin ?? "ghost").trim().split(/\s+/)[0]; + const actor = { + login: commit.authorLogin ?? authorName ?? "ghost", + name: commit.authorName, + avatarUrl: commit.authorAvatarUrl, + }; + + return ( + + ); +} diff --git a/apps/web/src/components/deployments/deployments.logic.test.ts b/apps/web/src/components/deployments/deployments.logic.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d173b35caadcd85eb8f21d62effe7fc259b6c835 --- /dev/null +++ b/apps/web/src/components/deployments/deployments.logic.test.ts @@ -0,0 +1,232 @@ +import type { + MergebotDeploymentEnvironment, + MergebotDeployments, + MergebotMergedCommit, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildDeploymentTimeline, + deploymentActivity, + formatEta, + railCommitCount, + stagingOnlyCount, +} from "./deployments.logic"; + +const NOW = Date.parse("2026-10-15T12:00:00Z"); + +function commit(sha: string, overrides: Partial = {}): MergebotMergedCommit { + return { + sha, + pullRequestNumber: null, + authorLogin: null, + authorName: null, + authorAvatarUrl: null, + message: `feat: ${sha}`, + mergedAt: "2026-10-15T11:00:00Z", + url: `https://github.com/acme/app/commit/${sha}`, + labels: [], + status: "default", + isProductionHead: false, + ...overrides, + }; +} + +function environment( + sha: string, + overrides: Partial = {}, +): MergebotDeploymentEnvironment { + return { + sha, + deployedSha: null, + status: "passed", + currentStep: null, + etaAt: null, + checksUrl: `https://github.com/acme/app/commit/${sha}/checks`, + ...overrides, + }; +} + +function deployments(overrides: Partial = {}): MergebotDeployments { + return { + repository: "acme/app", + url: "https://github.com/acme/app", + defaultBranch: "main", + productionBranch: "production", + stagingUrl: "https://staging.acme.dev", + productionUrl: "https://acme.dev", + staging: environment("c1"), + production: environment("c3"), + mergedCommits: [ + commit("c1"), + commit("c2"), + commit("c3", { status: "recent", isProductionHead: true }), + commit("c4", { status: "recent" }), + ], + ...overrides, + }; +} + +function shape(items: ReturnType) { + return items.map((item) => + item.kind === "marker" + ? `marker:${item.label}` + : `${item.commit.sha}${item.isDeployCandidate ? " (candidate)" : ""}`, + ); +} + +describe("stagingOnlyCount", () => { + it("counts commits live on staging but not production", () => { + expect(stagingOnlyCount(deployments())).toBe(2); + }); + + it("measures from the live staging sha while staging deploys", () => { + const value = deployments({ + staging: environment("c1", { status: "pending", deployedSha: "c2" }), + }); + expect(stagingOnlyCount(value)).toBe(1); + }); + + it("measures from the live production sha while production deploys", () => { + const value = deployments({ + production: environment("c2", { status: "pending", deployedSha: "c3" }), + }); + expect(stagingOnlyCount(value)).toBe(2); + }); + + it("is zero when environments agree or staging is absent", () => { + expect(stagingOnlyCount(deployments({ production: environment("c1") }))).toBe(0); + expect(stagingOnlyCount(deployments({ staging: null, stagingUrl: null }))).toBe(0); + }); + + it("falls back to the production branch head", () => { + expect(stagingOnlyCount(deployments({ production: null, productionUrl: null }))).toBe(2); + }); +}); + +describe("buildDeploymentTimeline", () => { + it("places staging on top and production above its commit", () => { + expect(shape(buildDeploymentTimeline(deployments(), NOW))).toEqual([ + "marker:on staging", + "c1 (candidate)", + "c2 (candidate)", + "marker:on production", + "c3", + "c4", + ]); + }); + + it("stacks both environments when they share a commit", () => { + const value = deployments({ production: environment("c1") }); + expect(shape(buildDeploymentTimeline(value, NOW))).toEqual([ + "marker:on staging + production", + "c1", + "c2", + "c3", + "c4", + ]); + }); + + it("shows an in-flight staging deploy with its step and ETA above the target commit", () => { + const value = deployments({ + staging: environment("c1", { + status: "pending", + deployedSha: "c2", + currentStep: "deploy_client", + etaAt: "2026-10-15T12:02:00Z", + }), + }); + const items = buildDeploymentTimeline(value, NOW); + expect(shape(items)).toEqual([ + "marker:deploying to staging · deploy_client · ~2m left", + "c1", + "marker:on staging", + "c2 (candidate)", + "marker:on production", + "c3", + "c4", + ]); + const c1 = items.find((item) => item.kind === "commit" && item.commit.sha === "c1"); + expect(c1?.kind === "commit" && c1.tone).toBe("deploying"); + }); + + it("keeps production's live commit marked while it deploys a newer one", () => { + const value = deployments({ + production: environment("c2", { status: "failed", deployedSha: "c3" }), + }); + expect(shape(buildDeploymentTimeline(value, NOW))).toEqual([ + "marker:on staging", + "c1 (candidate)", + "marker:failed deployment to production", + "c2", + "marker:on production", + "c3", + "c4", + ]); + }); + + it("marks unshipped work without staging and offers no deploys without a production branch", () => { + const withoutStaging = deployments({ staging: null, stagingUrl: null }); + expect(shape(buildDeploymentTimeline(withoutStaging, NOW))).toEqual([ + "marker:main (unshipped)", + "c1 (candidate)", + "c2 (candidate)", + "marker:on production", + "c3", + "c4", + ]); + + const trunkOnly = deployments({ + staging: null, + stagingUrl: null, + production: null, + productionUrl: null, + productionBranch: null, + }); + expect(shape(buildDeploymentTimeline(trunkOnly, NOW))).toEqual([ + "marker:main", + "c1", + "c2", + "c3", + "c4", + ]); + }); + + it("truncates commits without moving markers", () => { + expect(shape(buildDeploymentTimeline(deployments(), NOW, 2))).toEqual([ + "marker:on staging", + "c1 (candidate)", + "c2 (candidate)", + ]); + }); +}); + +describe("railCommitCount", () => { + it("reaches back to the oldest environment anchor", () => { + const mergedCommits = Array.from({ length: 12 }, (_, index) => + commit(`c${index}`, { isProductionHead: index === 10 }), + ); + expect(railCommitCount(deployments({ mergedCommits, production: environment("c10") }))).toBe( + 11, + ); + expect(railCommitCount(deployments({ mergedCommits: mergedCommits.slice(0, 5) }))).toBe(8); + }); +}); + +describe("deploymentActivity", () => { + it("reports the worst configured environment", () => { + const deploying = deployments({ staging: environment("c1", { status: "pending" }) }); + const failed = deployments({ production: environment("c3", { status: "failed" }) }); + expect(deploymentActivity([deployments()])).toBe("healthy"); + expect(deploymentActivity([deployments(), deploying])).toBe("deploying"); + expect(deploymentActivity([deploying, failed])).toBe("failed"); + }); +}); + +describe("formatEta", () => { + it("rounds to minutes", () => { + expect(formatEta("2026-10-15T12:00:20Z", NOW)).toBe("<1m"); + expect(formatEta("2026-10-15T12:02:10Z", NOW)).toBe("~2m"); + expect(formatEta("2026-10-15T13:05:00Z", NOW)).toBe("~1h 5m"); + }); +}); diff --git a/apps/web/src/components/deployments/deployments.logic.ts b/apps/web/src/components/deployments/deployments.logic.ts new file mode 100644 index 0000000000000000000000000000000000000000..a4b8549ff8293ed3297e053c567dcbd7b674385a --- /dev/null +++ b/apps/web/src/components/deployments/deployments.logic.ts @@ -0,0 +1,308 @@ +import type { + MergebotDeploymentEnvironment, + MergebotDeployments, + MergebotMergedCommit, +} from "@t3tools/contracts"; + +// A port of Mergebot's deploy pane (www/src/components/deploys.tsx) as pure derivations. + +type EnvironmentName = "staging" | "production"; +type EnvironmentState = "on" | "deploying" | "failed"; + +export type DeploymentMarkerTone = "default" | "pending" | "failed"; + +export type DeploymentTimelineItem = + | { + kind: "marker"; + key: string; + label: string; + tone: DeploymentMarkerTone; + href: string | null; + } + | { + kind: "commit"; + key: string; + commit: MergebotMergedCommit; + tone: "deploying" | "hotfix" | "unshipped" | "default"; + isDeployCandidate: boolean; + }; + +const RAIL_COMMIT_COUNT = 8; + +function hasStaging(deployments: MergebotDeployments) { + return deployments.stagingUrl !== null || deployments.staging !== null; +} + +function hasProduction(deployments: MergebotDeployments) { + return deployments.productionUrl !== null || deployments.production !== null; +} + +function liveSha(environment: MergebotDeploymentEnvironment | null) { + if (!environment) return null; + return environment.status === "passed" ? environment.sha : environment.deployedSha; +} + +function indexOfSha(commits: readonly MergebotMergedCommit[], sha: string | null | undefined) { + return sha ? commits.findIndex((commit) => commit.sha === sha) : -1; +} + +/** + * Where production stands: the deployment sha, else the production branch head. Off the listed + * window counts as older than every commit. + */ +function productionIndex(deployments: MergebotDeployments, sha: string | null | undefined) { + const commits = deployments.mergedCommits; + const deploymentIndex = indexOfSha(commits, sha); + const index = + deploymentIndex === -1 + ? commits.findIndex((commit) => commit.isProductionHead) + : deploymentIndex; + return index === -1 ? commits.length : index; +} + +/** Commits live on staging that production does not have yet. */ +export function stagingOnlyCount(deployments: MergebotDeployments): number { + if (!hasStaging(deployments) || deployments.productionBranch === null) return 0; + + const stagingIndex = indexOfSha(deployments.mergedCommits, liveSha(deployments.staging)); + if (stagingIndex === -1) return 0; + + const production = productionIndex(deployments, liveSha(deployments.production)); + return Math.max(0, production - stagingIndex); +} + +/** Worst state across every configured environment, for the sidebar status dot. */ +export function deploymentActivity( + found: readonly MergebotDeployments[], +): "healthy" | "deploying" | "failed" { + const states = new Set( + found.flatMap((deployments) => [ + ...(hasStaging(deployments) ? [environmentState(deployments.staging)] : []), + ...(hasProduction(deployments) ? [environmentState(deployments.production)] : []), + ]), + ); + if (states.has("failed")) return "failed"; + return states.has("deploying") ? "deploying" : "healthy"; +} + +function environmentState(environment: MergebotDeploymentEnvironment | null): EnvironmentState { + if (environment?.status === "passed") return "on"; + return environment?.status === "failed" ? "failed" : "deploying"; +} + +export function formatEta(etaAt: string, now: number): string { + const minutes = Math.round((new Date(etaAt).getTime() - now) / 60_000); + if (minutes < 1) return "<1m"; + if (minutes < 60) return `~${minutes}m`; + const remainder = minutes % 60; + return remainder === 0 + ? `~${Math.floor(minutes / 60)}h` + : `~${Math.floor(minutes / 60)}h ${remainder}m`; +} + +// "deploying to staging · deploy_client_fargate · ~2m left" +function withCurrentStep( + label: string, + environment: MergebotDeploymentEnvironment | null, + now: number, +) { + const withStep = environment?.currentStep ? `${label} · ${environment.currentStep}` : label; + return environment?.etaAt ? `${withStep} · ${formatEta(environment.etaAt, now)} left` : withStep; +} + +function environmentPhrase(state: EnvironmentState, names: EnvironmentName[]) { + const joined = names.join(" + "); + if (state === "on") return `on ${joined}`; + return state === "deploying" ? `deploying to ${joined}` : `failed deployment to ${joined}`; +} + +/** + * One line for every environment anchored at the same commit. Matching states collapse with "+" + * ("on staging + production"); mixed states join with ", ". Links only for a single environment. + */ +function environmentHeader( + deployments: MergebotDeployments, + parts: Array<{ name: EnvironmentName; state: EnvironmentState }>, + now: number, + key: string, +): DeploymentTimelineItem { + const label = (["on", "deploying", "failed"] as const) + .flatMap((state) => { + const names = parts.filter((part) => part.state === state).map((part) => part.name); + const [only] = names; + if (!only) return []; + + const phrase = environmentPhrase(state, names); + if (state === "deploying" && names.length === 1) { + return [withCurrentStep(phrase, deployments[only], now)]; + } + return [phrase]; + }) + .join(", "); + + const tone: DeploymentMarkerTone = parts.some((part) => part.state === "failed") + ? "failed" + : parts.some((part) => part.state === "deploying") + ? "pending" + : "default"; + + const [first, ...rest] = parts; + const href = + first && rest.length === 0 + ? first.name === "staging" + ? deployments.stagingUrl + : deployments.productionUrl + : null; + + return { kind: "marker", key, label, tone, href }; +} + +function environmentMarker( + deployments: MergebotDeployments, + name: EnvironmentName, + now: number, + key: string, +): DeploymentTimelineItem { + const environment = deployments[name]; + const status = environment?.status ?? "pending"; + const checksUrl = environment?.checksUrl ?? null; + + if (status === "failed") { + return { + kind: "marker", + key, + label: `failed deployment to ${name}`, + tone: "failed", + href: checksUrl, + }; + } + + if (status === "passed") { + const href = name === "staging" ? deployments.stagingUrl : deployments.productionUrl; + return { kind: "marker", key, label: `on ${name}`, tone: "default", href }; + } + + const label = withCurrentStep(`deploying to ${name}`, environment, now); + return { kind: "marker", key, label, tone: "pending", href: checksUrl }; +} + +/** The rail: the latest merges, reaching back far enough to show where every environment stands. */ +export function railCommitCount(deployments: MergebotDeployments): number { + const anchors = new Set( + [deployments.staging, deployments.production].flatMap((environment) => + environment ? [environment.sha, environment.deployedSha] : [], + ), + ); + const oldestAnchor = deployments.mergedCommits.findLastIndex( + (commit) => commit.isProductionHead || anchors.has(commit.sha), + ); + return Math.max(RAIL_COMMIT_COUNT, oldestAnchor + 1); +} + +/** + * Environment markers interleaved with merged commits, newest first. `limit` truncates the + * commit list after placement so markers keep their positions. + */ +export function buildDeploymentTimeline( + deployments: MergebotDeployments, + now: number, + limit = Number.POSITIVE_INFINITY, +): DeploymentTimelineItem[] { + const { staging, production, productionBranch, mergedCommits } = deployments; + const withStaging = hasStaging(deployments); + const withProduction = hasProduction(deployments); + const defaultBranch = deployments.defaultBranch || "default"; + + // Both environments passed on the same commit: one stacked header above everything. + const stacked = + withProduction && + staging?.status === "passed" && + production?.status === "passed" && + staging.sha === production.sha; + + const items: DeploymentTimelineItem[] = []; + if (!withStaging) { + if (productionBranch === null) { + items.push({ kind: "marker", key: "top", label: defaultBranch, tone: "default", href: null }); + } else if (mergedCommits.some((commit) => commit.status === "default")) { + const label = `${defaultBranch} (unshipped)`; + items.push({ kind: "marker", key: "top", label, tone: "default", href: null }); + } + } else if (stacked) { + const parts = [ + { name: "staging" as const, state: "on" as const }, + { name: "production" as const, state: "on" as const }, + ]; + items.push(environmentHeader(deployments, parts, now, "top")); + } else if (staging?.status === "passed") { + items.push(environmentMarker(deployments, "staging", now, "top")); + } + + // Deploy candidates sit newer than production's target and, with staging, no newer than what + // staging has live: fast-forwarding to any of them is valid. + const productionTarget = productionIndex(deployments, production?.sha); + const stagingLive = withStaging ? indexOfSha(mergedCommits, liveSha(staging)) : -1; + const candidateFrom = stagingLive === -1 ? 0 : stagingLive; + + const deployingFrom = staging?.status === "pending" ? indexOfSha(mergedCommits, staging.sha) : -1; + const deployingTo = indexOfSha(mergedCommits, staging?.deployedSha); + const isDeployingToStaging = (index: number) => + staging?.status === "pending" && + index >= (deployingFrom === -1 ? 0 : deployingFrom) && + index < (deployingTo === -1 ? mergedCommits.length : deployingTo); + + mergedCommits.slice(0, limit).forEach((commit, index) => { + const notPassed = (environment: MergebotDeploymentEnvironment | null) => + environment !== null && environment.status !== "passed"; + + if (withStaging && notPassed(staging) && staging?.sha === commit.sha) { + items.push(environmentMarker(deployments, "staging", now, `staging:${commit.sha}`)); + } + + const productionMarked = + !stacked && + productionBranch !== null && + (withProduction ? production?.sha === commit.sha : commit.isProductionHead); + + if (productionMarked && withProduction && production?.status !== "passed") { + items.push(environmentMarker(deployments, "production", now, `production:${commit.sha}`)); + } + if (productionMarked && !withProduction) { + const key = `production:${commit.sha}`; + items.push({ kind: "marker", key, label: "production", tone: "default", href: null }); + } + + const stagingLiveHere = + withStaging && notPassed(staging) && staging?.deployedSha === commit.sha; + const productionLiveHere = + (productionMarked && withProduction && production?.status === "passed") || + (withProduction && notPassed(production) && production?.deployedSha === commit.sha); + const live: EnvironmentName[] = [ + ...(stagingLiveHere ? (["staging"] as const) : []), + ...(productionLiveHere ? (["production"] as const) : []), + ]; + if (live.length > 0) { + const parts = live.map((name) => ({ name, state: "on" as const })); + items.push(environmentHeader(deployments, parts, now, `live:${commit.sha}`)); + } + + const tone = isDeployingToStaging(index) + ? "deploying" + : commit.status === "hotfix" + ? "hotfix" + : productionBranch !== null && commit.status === "default" + ? "unshipped" + : "default"; + + items.push({ + kind: "commit", + key: commit.sha, + commit, + tone, + isDeployCandidate: + productionBranch !== null && index >= candidateFrom && index < productionTarget, + }); + }); + + return items; +} diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx index b36fd8b3cb37d0e72bce603d4b01106a34cea93f..fc67c09e02d8efc1fda97daf507304685ad93e89 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -86,6 +86,7 @@ interface AnnotatableCodeViewProps { options: StyledDiffCodeViewOptions; viewerRef?: Ref; className?: string; + renderCodeViewHeader?: () => ReactNode; renderCodeViewFooter?: () => ReactNode; unsafeCSSExtra?: string; renderHeaderMetadata?: (fileDiff: FileDiffMetadata) => ReactNode; @@ -112,6 +113,7 @@ export function AnnotatableCodeView({ options, viewerRef, className, + renderCodeViewHeader, renderCodeViewFooter, unsafeCSSExtra, renderHeaderMetadata, @@ -261,6 +263,7 @@ export function AnnotatableCodeView({ item.type === "diff" ? renderHeaderMetadata(item.fileDiff) : null, } : {})} + {...(renderCodeViewHeader ? { renderCodeViewHeader } : {})} {...(renderCodeViewFooter ? { renderCodeViewFooter } : {})} items={items} selectedLines={selectedLines} diff --git a/apps/web/src/components/review/CommitMessageEditor.test.tsx b/apps/web/src/components/review/CommitMessageEditor.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4596954b4eb0f52c501387632423b17651a1de22 --- /dev/null +++ b/apps/web/src/components/review/CommitMessageEditor.test.tsx @@ -0,0 +1,120 @@ +// @vitest-environment jsdom +import { cursorCharLeft, cursorCharRight } from "@codemirror/commands"; +import { syntaxTree } from "@codemirror/language"; +import { EditorView } from "@codemirror/view"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { CommitMessageEditor } from "./CommitMessageEditor"; + +let root: Root; +let container: HTMLDivElement; +const change = vi.fn(); + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + change.mockReset(); + vi.unstubAllGlobals(); +}); + +function render(value: string, readOnly = false) { + act(() => + root.render(), + ); + const view = EditorView.findFromDOM(container.querySelector(".cm-editor")!)!; + return view; +} + +function typeAt(view: EditorView, position: number, text: string) { + act(() => + view.dispatch({ + changes: { from: position, insert: text }, + selection: { anchor: position + text.length }, + userEvent: "input", + }), + ); +} + +describe("CommitMessageEditor", () => { + it("keeps the title decoration on line 1 as lines move", () => { + const view = render("fix: title\nbody"); + const title = () => container.querySelector(".cm-commit-title")?.textContent; + expect(title()).toBe("fix: title"); + + typeAt(view, 0, "\n"); + expect(title()).toBe(""); + expect(view.state.doc.line(2).text).toBe("fix: title"); + expect(change).toHaveBeenLastCalledWith("\nfix: title\nbody"); + }); + + it("never parses the title as a heading, list, or setext heading", () => { + for (const value of ["# not a heading\nbody", "- not a list\nbody", "title\n---"]) { + const view = render(value); + const tree = syntaxTree(view.state); + expect(tree.resolveInner(1, 1).name).toBe("CommitTitle"); + expect(container.querySelector(".cm-commit-title")?.textContent).toBe(value.split("\n")[0]); + } + }); + + it("colors conventional-commit titles", () => { + render("[ABC-1, ABC-22] feat(review)!: add editor\nbody"); + expect(container.querySelector(".cm-commit-issues")?.textContent).toBe("[ABC-1, ABC-22]"); + expect(container.querySelector(".cm-commit-type-add")?.textContent).toBe("feat(review)!:"); + expect(container.querySelector(".cm-commit-scope")?.textContent).toBe("(review)"); + + render("hotfix: x"); + expect(container.querySelector(".cm-commit-type-fix")?.textContent).toBe("hotfix:"); + render("chore: x"); + expect(container.querySelector(".cm-commit-type-other")?.textContent).toBe("chore:"); + render("WIP: x"); + expect(container.querySelector(".cm-commit-type-wip")?.textContent).toBe("WIP:"); + render("feat: x\nfeat: body is not a title"); + expect(container.querySelectorAll(".cm-commit-type-add")).toHaveLength(1); + }); + + it("shows each Assisted-by trailer once, as an atomic provider chip", () => { + const value = "feat: x\nbody\n\nAssisted-by: claude-opus-5.5\nAssisted-by: mystery-1"; + const view = render(value); + + const chips = [...container.querySelectorAll(".cm-commit-trailer")]; + expect(chips.map((chip) => chip.textContent)).toEqual(["claude-opus-5.5", "mystery-1"]); + expect(chips.every((chip) => chip.querySelector("svg"))).toBe(true); + expect(container.querySelector(".cm-content")?.textContent).not.toContain("Assisted-by"); + expect(view.state.doc.toString()).toBe(value); + + const trailer = view.state.doc.line(4); + act(() => view.dispatch({ selection: { anchor: trailer.from } })); + act(() => void cursorCharRight(view)); + expect(view.state.selection.main.head).toBe(trailer.to); + act(() => void cursorCharLeft(view)); + expect(view.state.selection.main.head).toBe(trailer.from); + }); + + it("adopts external values without echoing onChange and keeps the caret", () => { + const view = render("title\nbody"); + act(() => view.dispatch({ selection: { anchor: "title\nbo".length } })); + + render("new title\nbody"); + expect(view.state.doc.toString()).toBe("new title\nbody"); + expect(view.state.selection.main.head).toBe("new title\nbo".length); + expect(change).not.toHaveBeenCalled(); + }); + + it("is read-only when asked", () => { + const view = render("title", true); + expect(view.state.readOnly).toBe(true); + expect(view.contentDOM.getAttribute("contenteditable")).toBe("false"); + + render("title", false); + expect(view.state.readOnly).toBe(false); + }); +}); diff --git a/apps/web/src/components/review/CommitMessageEditor.tsx b/apps/web/src/components/review/CommitMessageEditor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..817b62c0b6209bd010b9a84d9740db1057a9c102 --- /dev/null +++ b/apps/web/src/components/review/CommitMessageEditor.tsx @@ -0,0 +1,294 @@ +import { + chipField, + createMarkdownEditor, + livePreview, + type MarkdownEditor, +} from "@clo/markdown-editor"; +import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; +import { Compartment, EditorState, Prec, type Range, Transaction } from "@codemirror/state"; +import { + Decoration, + EditorView, + placeholder as placeholderText, + WidgetType, +} from "@codemirror/view"; +import { BotIcon } from "lucide-react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { ClaudeAI, OpenAI } from "../Icons"; +import { ISSUE_PREFIX, parseCommitTitle } from "./commitTitle"; + +/** Line 1 is always the title: no heading, list, or setext reading of its content. */ +const commitMarkdown = Prec.highest( + markdown({ + base: markdownLanguage, + addKeymap: false, + pasteURLAsLink: false, + completeHTMLTags: false, + extensions: { + defineNodes: [{ name: "CommitTitle", block: true }], + parseBlock: [ + { + name: "CommitTitle", + before: "LinkReference", + parse(cx, line) { + if (cx.lineStart !== 0) return false; + const from = cx.lineStart + line.pos; + const to = cx.lineStart + line.text.length; + cx.nextLine(); + cx.addElement(cx.elt("CommitTitle", from, to)); + return true; + }, + }, + ], + }, + }), +); + +function titleDecorations(state: EditorState): Range[] { + const title = state.doc.line(1); + const ranges = [Decoration.line({ class: "cm-commit-title" }).range(title.from)]; + const parsed = parseCommitTitle(title.text); + if (parsed.issues.length > 0) { + const issuesEnd = title.from + (ISSUE_PREFIX.exec(title.text)?.[0].trimEnd().length ?? 0); + ranges.push(Decoration.mark({ class: "cm-commit-issues" }).range(title.from, issuesEnd)); + } + if (!parsed.prefix) return ranges; + ranges.push( + Decoration.mark({ class: `cm-commit-type-${parsed.prefix.tone}` }).range( + title.from + parsed.prefix.from, + title.from + parsed.prefix.to, + ), + ); + if (parsed.scope) { + ranges.push( + Decoration.mark({ class: "cm-commit-scope" }).range( + title.from + parsed.scope.from, + title.from + parsed.scope.to, + ), + ); + } + return ranges; +} + +const ASSISTED_BY = /^Assisted-by:[ \t]*(\S(?:.*\S)?)[ \t]*$/i; + +function assistedByTrailers(state: EditorState) { + const trailers: { from: number; to: number; model: string }[] = []; + for (let number = 2; number <= state.doc.lines; number++) { + const line = state.doc.line(number); + const model = ASSISTED_BY.exec(line.text)?.[1]; + if (model) trailers.push({ from: line.from, to: line.to, model }); + } + return trailers; +} + +interface TrailerSlot { + readonly key: number; + readonly dom: HTMLElement; + readonly model: string; +} + +/** Hands its icon slot to React, which portals the provider glyph in. */ +class AssistedByWidget extends WidgetType { + readonly model: string; + readonly slots: { + readonly add: (dom: HTMLElement, model: string) => void; + readonly remove: (dom: HTMLElement) => void; + }; + + constructor(model: string, slots: AssistedByWidget["slots"]) { + super(); + this.model = model; + this.slots = slots; + } + + override eq(other: AssistedByWidget): boolean { + return other.model === this.model; + } + + override toDOM(): HTMLElement { + const chip = document.createElement("span"); + chip.className = "cm-commit-trailer"; + chip.title = `Assisted-by: ${this.model}`; + const icon = document.createElement("span"); + icon.className = "cm-commit-trailer-icon"; + chip.append(icon, this.model); + this.slots.add(icon, this.model); + return chip; + } + + override destroy(dom: HTMLElement): void { + if (dom.firstElementChild instanceof HTMLElement) this.slots.remove(dom.firstElementChild); + } +} + +function trailerIcon(model: string) { + if (/^claude-/i.test(model)) return ClaudeAI; + if (/^(?:gpt-|chatgpt|o\d)/i.test(model)) return OpenAI; + return BotIcon; +} + +const commitTheme = Prec.highest( + EditorView.theme({ + "&": { + fontSize: "13.5px", + color: "var(--foreground)", + "--markdown-text": "var(--foreground)", + "--markdown-muted": "var(--muted-foreground)", + "--markdown-panel": "var(--muted)", + "--markdown-accent": "var(--primary)", + }, + "&.cm-focused": { outline: "none" }, + ".cm-scroller": { fontFamily: "var(--font-sans)", lineHeight: "1.55" }, + ".cm-content": { padding: "10px 12px" }, + ".cm-commit-title": { + fontSize: "15px", + fontWeight: "600", + lineHeight: "1.45", + paddingBottom: "4px", + }, + ".cm-commit-issues": { color: "var(--muted-foreground)" }, + ".cm-commit-type-add": { color: "var(--success-foreground)" }, + ".cm-commit-type-fix": { color: "var(--error-foreground)" }, + ".cm-commit-type-other": { color: "var(--info-foreground)" }, + ".cm-commit-scope": { opacity: "0.65" }, + ".cm-commit-type-wip": { + color: "var(--background)", + backgroundColor: "var(--warning)", + borderRadius: "3px", + boxDecorationBreak: "clone", + }, + ".cm-commit-trailer": { + display: "inline-flex", + alignItems: "center", + gap: "4px", + fontSize: "12px", + color: "var(--muted-foreground)", + verticalAlign: "baseline", + }, + ".cm-commit-trailer-icon": { display: "inline-flex", width: "12px", height: "12px" }, + ".cm-lp-code, .cm-lp-codeblock": { + fontFamily: 'var(--chat-code-font, "AT Name Mono"), "T3 Markdown Symbols", var(--font-mono)', + fontVariationSettings: '"CASL" 0.15, "MONO" 1', + fontSize: "var(--font-size-code, 13px)", + }, + }), +); + +/** Edits a commit message in editor form: title on line 1, body from line 2. */ +export function CommitMessageEditor(props: { + /** Editor form: title on line 1, body from line 2 (no blank separator). */ + value: string; + readOnly: boolean; + onChange: (value: string) => void; + placeholder?: string; +}) { + const host = useRef(null); + const editor = useRef(null); + const latest = useRef(props); + const [editing] = useState(() => new Compartment()); + const [trailerSlots, setTrailerSlots] = useState([]); + + useLayoutEffect(() => { + latest.current = props; + }); + // oxlint-disable react/exhaustive-effect-dependencies -- One view per mount; the effects below keep it controlled. + useLayoutEffect(() => { + let nextKey = 0; + const slots = { + add: (dom: HTMLElement, model: string) => { + const key = nextKey++; + setTrailerSlots((current) => [...current, { key, dom, model }]); + }, + remove: (dom: HTMLElement) => + setTrailerSlots((current) => current.filter((slot) => slot.dom !== dom)), + }; + const instance = createMarkdownEditor({ + preset: "document", + mentions: false, + preview: false, + doc: latest.current.value, + onChange: (text) => { + if (text !== latest.current.value && !latest.current.readOnly) + latest.current.onChange(text); + }, + extensions: [ + commitMarkdown, + livePreview({ + blockWidgets: true, + exclude: assistedByTrailers, + }), + chipField({ chips: titleDecorations }), + chipField({ + atomic: true, + chips: (state) => + assistedByTrailers(state).map(({ from, to, model }) => + Decoration.replace({ widget: new AssistedByWidget(model, slots) }).range(from, to), + ), + }), + editing.of([]), + commitTheme, + ], + }); + editor.current = instance; + host.current?.append(instance.dom); + return () => { + editor.current = null; + instance.destroy(); + }; + }, []); + // oxlint-enable react/exhaustive-effect-dependencies + + useEffect(() => { + editor.current?.view.dispatch({ + effects: editing.reconfigure([ + EditorState.readOnly.of(props.readOnly), + EditorView.editable.of(!props.readOnly), + EditorView.contentAttributes.of({ + "aria-label": "Commit message", + "aria-readonly": String(props.readOnly), + }), + props.placeholder === undefined ? [] : placeholderText(props.placeholder), + ]), + }); + }, [editing, props.placeholder, props.readOnly]); + + useEffect(() => { + const view = editor.current?.view; + const current = view?.state.doc.toString(); + if (!view || current === undefined || current === props.value) return; + + // Replace only the differing middle so the selection maps through the change. + const next = props.value; + let from = 0; + while (from < current.length && from < next.length && current[from] === next[from]) from++; + let suffix = 0; + while ( + suffix < current.length - from && + suffix < next.length - from && + current[current.length - 1 - suffix] === next[next.length - 1 - suffix] + ) + suffix++; + + view.dispatch({ + changes: { + from, + to: current.length - suffix, + insert: next.slice(from, next.length - suffix), + }, + annotations: Transaction.addToHistory.of(false), + }); + }, [props.value]); + + return ( + <> +
+ {trailerSlots.map(({ key, dom, model }) => { + const Glyph = trailerIcon(model); + return createPortal(, dom, String(key)); + })} + + ); +} diff --git a/apps/web/src/components/review/DiffSurface.tsx b/apps/web/src/components/review/DiffSurface.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2bf764105514c4b9221f3dbef2ab4a4b61bae648 --- /dev/null +++ b/apps/web/src/components/review/DiffSurface.tsx @@ -0,0 +1,57 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { DraftId } from "~/composerDraftStore"; +import { useParams } from "@tanstack/react-router"; +import * as Atom from "effect/reactivity/Atom"; +import * as AsyncResult from "effect/reactivity/AsyncResult"; + +import { useProject, useThreadShell } from "~/state/entities"; +import { jjEnvironment } from "~/state/jj"; +import { resolveThreadRouteRef } from "~/threadRoutes"; + +import DiffPanel from "../DiffPanel"; +import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "../DiffPanelShell"; +import { JjReviewPanel } from "./JjReviewPanel"; + +const NO_STACK = Atom.make(AsyncResult.success(null)); + +/** The right panel's diff surface: the jj review panel inside a jj workspace, T3's diff panel otherwise. */ +export default function DiffSurface(props: { + mode?: DiffPanelMode; + composerDraftTarget: ScopedThreadRef | DraftId; + workspaceMutationId: string | null; +}) { + const mode = props.mode ?? "inline"; + const threadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params) }); + const thread = useThreadShell(threadRef); + const project = useProject( + thread?.projectId ? { environmentId: thread.environmentId, projectId: thread.projectId } : null, + ); + const cwd = thread?.worktreePath ?? project?.workspaceRoot; + const stack = useAtomValue( + thread && cwd + ? jjEnvironment.stack({ environmentId: thread.environmentId, input: { cwd } }) + : NO_STACK, + ); + + if (stack._tag === "Initial") { + return ( + + + + ); + } + if (stack._tag === "Success" && stack.value && thread && cwd) { + return ( + + ); + } + return ; +} diff --git a/apps/web/src/components/review/JjReviewPanel.test.tsx b/apps/web/src/components/review/JjReviewPanel.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6953c9e7226a3ea6e644a5fc6b45874a6a123ebf --- /dev/null +++ b/apps/web/src/components/review/JjReviewPanel.test.tsx @@ -0,0 +1,142 @@ +// @vitest-environment jsdom +import { EnvironmentId, ThreadId, type JjStack } from "@t3tools/contracts"; +import * as AsyncResult from "effect/reactivity/AsyncResult"; +import * as Cause from "effect/Cause"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { JjReviewPanel } from "./JjReviewPanel"; + +const state = vi.hoisted(() => ({ + result: { _tag: "Initial" } as unknown, + diff: vi.fn(() => ({})), + refresh: vi.fn(), + queue: null as { pullRequests: Array<{ headSha: string; number: number }> } | null, +})); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => state.result, + useAtomRefresh: () => state.refresh, +})); +vi.mock("~/state/jj", () => ({ jjEnvironment: { diff: state.diff } })); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("~/state/entities", () => ({ useThreadShell: () => null })); +vi.mock("~/state/mergebot", () => ({ + mergebotEnvironment: {}, + useMergebotQueue: () => state.queue, + useMergebotPullRequestStatus: () => null, +})); +vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +vi.mock("~/hooks/useSettings", () => ({ + useClientSettings: () => ({ diffLayout: "split", wordWrap: false }), + useUpdateClientSettings: () => vi.fn(), +})); +vi.mock("~/hooks/useLocalStorage", () => ({ useLocalStorage: () => [false, vi.fn()] })); +vi.mock("~/hooks/usePullRequestLinking", () => ({ usePullRequestLinking: () => ({}) })); +vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn() })); +vi.mock("../DiffPanelShell", () => ({ + DiffPanelShell: ({ header, children }: { header: ReactNode; children: ReactNode }) => ( + <> + {header} + {children} + + ), + DiffPanelLoadingState: () =>
Loading changes
, +})); +vi.mock("../diffs/AnnotatableCodeView", () => ({ + AnnotatableCodeView: () =>
, +})); +vi.mock("./CommitMessageEditor", () => ({ CommitMessageEditor: () => null })); +vi.mock("./JjStackViews", () => ({ StackRail: () => null, StackStrip: () => null })); + +let container: HTMLDivElement; +let root: Root; +const environmentId = EnvironmentId.make("review-test"); +const stack: JjStack = { + workspaceRoot: "/workspace", + repository: null, + trunk: null, + changes: [], +}; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.queue = null; + state.diff.mockClear(); + state.refresh.mockClear(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +function render(value: JjStack) { + act(() => + root.render( + , + ), + ); +} + +it("shows an empty stack without requesting a diff for invalid commit IDs", () => { + state.result = AsyncResult.success(null); + render(stack); + expect(state.diff).not.toHaveBeenCalled(); + expect(container.textContent).toContain("No changes off trunk yet."); +}); + +const change = { + changeId: "change", + shortId: "chan", + commitId: "commit", + parentCommitIds: ["parent"], + description: "feat: review", + trailers: [], + bookmarks: [], + immutable: false, + conflict: false, + empty: false, + workingCopy: false, + additions: 1, + deletions: 0, + authoredAt: "2026-10-15T12:00:00Z", +}; + +it("shows a failed diff and lets the user retry the selected range", () => { + state.result = AsyncResult.failure(Cause.fail(new Error("Connection lost"))); + render({ + ...stack, + changes: [change], + }); + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + "Couldn’t load changes.", + ); + expect(container.querySelector("[data-code-view]")).toBeNull(); + const retry = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Try again", + ); + expect(retry).toBeDefined(); + act(() => retry!.click()); + expect(state.refresh).toHaveBeenCalledOnce(); +}); + +it("does not offer to create a duplicate PR while an existing PR's live status loads", () => { + state.result = AsyncResult.success(null); + state.queue = { pullRequests: [{ headSha: change.commitId, number: 42 }] }; + render({ ...stack, repository: "acme/project", changes: [change] }); + expect(container.querySelector('[aria-label="More ways to open a PR"]')).toBeNull(); + expect(container.textContent).not.toContain("Auto-merge"); +}); diff --git a/apps/web/src/components/review/JjReviewPanel.tsx b/apps/web/src/components/review/JjReviewPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..655c4aa0c8ab2be86eeaf694ad3b32b2178e0bbc --- /dev/null +++ b/apps/web/src/components/review/JjReviewPanel.tsx @@ -0,0 +1,644 @@ +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import { + commitMessageToEditor, + editorToCommitMessage, + type EnvironmentId, + type JjChange, + type JjStack, + type MergebotPullRequestLiveStatus, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { mergebotStatusPill } from "@t3tools/client-runtime/mergebot-status-pill"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; +import { + ArrowUpIcon, + ChevronDownIcon, + Columns2Icon, + ExternalLinkIcon, + Rows2Icon, +} from "lucide-react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +import type { DraftId } from "~/composerDraftStore"; +import { useTheme } from "~/hooks/useTheme"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; +import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; +import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; +import { + buildFileDiffIdentityKey, + getRenderablePatch, + resolveDiffThemeName, + resolveFileDiffPath, +} from "~/lib/diffRendering"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; +import { useThreadShell } from "~/state/entities"; +import { jjEnvironment } from "~/state/jj"; +import { + mergebotEnvironment, + useMergebotPullRequestStatus, + useMergebotQueue, +} from "~/state/mergebot"; +import { useAtomCommand } from "~/state/use-atom-command"; +import * as Schema from "effect/Schema"; +import * as Atom from "effect/reactivity/Atom"; +import * as AsyncResult from "effect/reactivity/AsyncResult"; + +import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "../DiffPanelShell"; +import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; +import { AnnotatableCodeView } from "../diffs/AnnotatableCodeView"; +import { Button } from "../ui/button"; +import { Group, GroupSeparator } from "../ui/group"; +import { Menu, MenuItem, MenuItemLabel, MenuPopup, MenuTrigger } from "../ui/menu"; +import { CommitMessageEditor } from "./CommitMessageEditor"; +import { CommitTitle } from "./commitTitle"; +import { MergebotStatusPill } from "./MergebotStatusPill"; +import { StackRail, StackStrip } from "./JjStackViews"; +import { + changeTitle, + clampSelection, + newestPullRequestIndex, + pullRequestFor, + pullRequestText, + selectChange, + selectionRange, + visibleChanges, + type Selection, +} from "./jjReview.logic"; + +const SAVE_DELAY_MS = 800; +const NO_DIFF = Atom.make(AsyncResult.initial()); + +export function JjReviewPanel(props: { + mode: DiffPanelMode; + environmentId: EnvironmentId; + threadRef: ScopedThreadRef | null; + cwd: string; + stack: JjStack; + composerDraftTarget: ScopedThreadRef | DraftId; +}) { + const { environmentId, cwd, stack } = props; + const { resolvedTheme } = useTheme(); + const settings = useClientSettings(); + const updateSettings = useUpdateClientSettings(); + const [railOpen, setRailOpen] = useLocalStorage("t3.jjReview.railOpen", false, Schema.Boolean); + const changes = useMemo(() => visibleChanges(stack), [stack]); + const [rawSelection, setSelection] = useState([0, 0]); + const [anchor, setAnchor] = useState(0); + const selection = clampSelection(rawSelection, changes.length); + + const snapshot = useAtomCommand(jjEnvironment.snapshot, { reportFailure: false }); + useEffect(() => { + // Reads never snapshot; opening or refocusing the panel picks up the agent's latest edits. + const refresh = () => void snapshot({ environmentId, input: { cwd } }); + refresh(); + window.addEventListener("focus", refresh); + return () => window.removeEventListener("focus", refresh); + }, [cwd, environmentId, snapshot]); + + const repository = stack.repository; + const queue = useMergebotQueue(environmentId, repository ?? ""); + const thread = useThreadShell(props.threadRef); + // Open PRs come from Mergebot's queue by head commit; a merged or closed PR this thread opened + // drops out of the queue, so its link still claims the change its bookmark points at. + const prByHeadSha = useMemo(() => { + const map = new Map( + (repository && queue?.pullRequests ? queue.pullRequests : []).map( + (pr) => [pr.headSha, pr.number] as const, + ), + ); + const linked = thread ? resolveThreadCurrentPullRequestLink(thread.pullRequests ?? []) : null; + const head = linked?.snapshot?.headBranch; + const claimed = head && changes.find((change) => change.bookmarks.some((b) => b.name === head)); + if (linked && claimed && ![...map.values()].includes(linked.number)) { + map.set(claimed.commitId, linked.number); + } + return map; + }, [changes, queue, repository, thread]); + const headIndex = newestPullRequestIndex(changes, prByHeadSha); + const pullRequestNumber = + headIndex >= 0 ? (prByHeadSha.get(changes[headIndex]!.commitId) ?? null) : null; + const pendingCount = headIndex > 0 ? headIndex : 0; + + const range = selectionRange(changes, selection); + const diffAtom = range + ? jjEnvironment.diff({ environmentId, input: { cwd, ...range } }) + : NO_DIFF; + const diffResult = useAtomValue(diffAtom); + const refreshDiff = useAtomRefresh(diffAtom); + const diff = diffResult._tag === "Success" ? diffResult.value : null; + const renderable = useMemo( + () => getRenderablePatch(diff?.diff, `jj:${diff?.diffHash ?? ""}`), + [diff?.diff, diff?.diffHash], + ); + const files = useMemo( + () => + renderable?.kind === "files" + ? renderable.files.map((fileDiff) => ({ + fileDiff, + filePath: resolveFileDiffPath(fileDiff), + fileKey: buildFileDiffIdentityKey(fileDiff), + fileVersion: 0, + collapsed: false, + })) + : [], + [renderable], + ); + + const select = (index: number, extend: boolean) => { + setSelection(selectChange(anchor, index, extend)); + if (!extend) setAnchor(index); + }; + const selectAll = () => { + setSelection([0, changes.length - 1]); + setAnchor(0); + }; + + const selectedPullRequest = (() => { + const numbers = new Set(); + for (let index = selection[0]; index <= Math.min(selection[1], changes.length - 1); index++) { + numbers.add(pullRequestFor(changes, prByHeadSha, index)); + } + const [only] = numbers; + return numbers.size === 1 && only != null ? only : null; + })(); + const selectedChange = selection[0] === selection[1] ? changes[selection[0]] : undefined; + + const render = (status: MergebotPullRequestLiveStatus | null) => { + const pill = status ? mergebotStatusPill(status) : null; + const header = ( + + updateSettings({ diffLayout: settings.diffLayout === "split" ? "stacked" : "split" }) + } + /> + ); + + const editor = + selectedPullRequest !== null && status && selectedPullRequest === status.number ? ( + + ) : selectedChange ? ( + + ) : ( + + ); + + if (changes.length === 0) { + return ( + +
+ No changes off {stack.trunk?.name ?? "trunk"} yet. +
+
+ ); + } + + return ( + +
+ {railOpen ? ( + setRailOpen(false)} + onOpenFile={(path) => + document + .querySelector(`[data-jj-review] [data-file-path="${CSS.escape(path)}"]`) + ?.scrollIntoView({ block: "start" }) + } + /> + ) : null} +
+ {railOpen ? null : ( + setRailOpen(true)} + /> + )} + {diffResult._tag === "Initial" ? ( +
+ {editor} + +
+ ) : diffResult._tag === "Failure" ? ( +
+ {editor} +
+

Couldn’t load changes.

+ +
+
+ ) : ( + editor} + renderHeaderFilenameSuffix={() => null} + renderHeaderPrefix={() => null} + onRevealSearchMatch={() => {}} + options={{ + diffStyle: settings.diffLayout === "split" ? "split" : "unified", + lineDiffType: "none", + overflow: settings.wordWrap ? "wrap" : "scroll", + theme: resolveDiffThemeName(resolvedTheme), + preferredHighlighter: PREFERRED_HIGHLIGHTER, + themeType: resolvedTheme, + stickyHeaders: true, + }} + /> + )} +
+
+
+ ); + }; + + return pullRequestNumber !== null && repository ? ( + + {render} + + ) : ( + render(null) + ); +} + +function PullRequestStatusScope(props: { + environmentId: EnvironmentId; + repository: string; + number: number; + children: (status: MergebotPullRequestLiveStatus | null) => ReactNode; +}) { + return props.children( + useMergebotPullRequestStatus(props.environmentId, props.repository, props.number), + ); +} + +/** A range has no single message to edit; it shows what it spans and what a PR would open as. */ +function RangeSummary(props: { changes: readonly JjChange[]; opensPullRequest: boolean }) { + const additions = props.changes.reduce((sum, change) => sum + change.additions, 0); + const deletions = props.changes.reduce((sum, change) => sum + change.deletions, 0); + const title = pullRequestText(props.changes).title; + return ( +
+
+ {props.changes.length} changes ·{" "} + +{additions}{" "} + −{deletions} + {props.opensPullRequest && title ? " · opens as" : null} +
+ {props.opensPullRequest && title ? ( +
+ +
+ ) : null} +
+ ); +} + +/** Saves the latest edit after a pause; flushes on unmount so switching changes keeps the edit. */ +function useDebouncedSave(save: (value: string) => void) { + const pending = useRef(null); + const timer = useRef | null>(null); + const saveRef = useRef(save); + useLayoutEffect(() => { + saveRef.current = save; + }); + const flush = useCallback(() => { + if (timer.current) clearTimeout(timer.current); + timer.current = null; + if (pending.current !== null) saveRef.current(pending.current); + pending.current = null; + }, []); + useEffect(() => flush, [flush]); + return useCallback( + (value: string) => { + pending.current = value; + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(flush, SAVE_DELAY_MS); + }, + [flush], + ); +} + +function ChangeMessage(props: { environmentId: EnvironmentId; cwd: string; change: JjChange }) { + const { change } = props; + const describe = useAtomCommand(jjEnvironment.describe, { reportFailure: true }); + const [value, setValue] = useState(() => commitMessageToEditor(change.description)); + const loadedCommitId = useRef(change.commitId); + const saved = useRef(change.description); + useEffect(() => { + // Another writer (the agent, a terminal) changed the description: show theirs. + if (change.description !== saved.current) { + saved.current = change.description; + setValue(commitMessageToEditor(change.description)); + } + loadedCommitId.current = change.commitId; + }, [change.commitId, change.description]); + const save = useDebouncedSave((text) => { + const message = editorToCommitMessage(text); + if (message === saved.current) return; + saved.current = message; + void describe({ + environmentId: props.environmentId, + input: { + cwd: props.cwd, + changeId: change.changeId, + expectedCommitId: loadedCommitId.current, + message, + }, + }); + }); + return ( +
+ { + setValue(next); + save(next); + }} + /> +
+ ); +} + +function PullRequestMessage(props: { + environmentId: EnvironmentId; + repository: string; + number: number; + title: string; + body: string; +}) { + const update = useAtomCommand(mergebotEnvironment.updatePullRequest, { reportFailure: true }); + const message = props.body ? `${props.title}\n\n${props.body}` : props.title; + const [value, setValue] = useState(() => commitMessageToEditor(message)); + const save = useDebouncedSave((text) => { + const [title = "", ...rest] = editorToCommitMessage(text).split("\n"); + if (!title.trim()) return; + void update({ + environmentId: props.environmentId, + input: { + repository: props.repository, + number: props.number, + title, + body: rest.join("\n").trim(), + }, + }); + }); + return ( +
+ { + setValue(next); + save(next); + }} + /> +
+ ); +} + +function ReviewHeader(props: { + environmentId: EnvironmentId; + threadRef: ScopedThreadRef | null; + cwd: string; + repository: string | null; + changes: readonly JjChange[]; + pendingCount: number; + pullRequestNumber: number | null; + status: MergebotPullRequestLiveStatus | null; + pill: ReturnType; + diffLayout: string; + onToggleLayout: () => void; +}) { + const { environmentId, repository, changes, status } = props; + const push = useAtomCommand(jjEnvironment.push, { reportFailure: true }); + const openPullRequest = useAtomCommand(mergebotEnvironment.openPullRequest, { + reportFailure: true, + }); + const setAutoMerge = useAtomCommand(mergebotEnvironment.setAutoMerge, { reportFailure: true }); + const updatePullRequest = useAtomCommand(mergebotEnvironment.updatePullRequest, { + reportFailure: true, + }); + const linking = usePullRequestLinking(environmentId); + const openLink = useOpenChangeRequestLink(props.threadRef ?? undefined); + const [busy, setBusy] = useState(null); + + const newest = changes[0]; + const undescribed = changes + .slice(0, props.pendingCount || changes.length) + .some((change) => !changeTitle(change)); + const blocked = undescribed ? "Describe or squash the working copy first" : null; + const shipping = status && (status.lifecycle !== "open" || status.queueStatus === "merging"); + + const run = async (label: string, work: () => Promise) => { + setBusy(label); + try { + await work(); + } finally { + setBusy(null); + } + }; + + const open = (options: { autoMerge: boolean; draft: boolean }) => + run(options.autoMerge ? "auto-merge" : "open", async () => { + if (!newest || !repository) return; + const text = pullRequestText(changes); + const pushed = await push({ + environmentId, + input: { + cwd: props.cwd, + changeId: newest.changeId, + ...(text.baseChangeId ? { nameFrom: text.baseChangeId } : {}), + }, + }); + if (pushed._tag !== "Success") return; + const opened = await openPullRequest({ + environmentId, + input: { + repository, + head: pushed.value.bookmark, + title: text.title, + body: text.body, + ...options, + }, + }); + if (opened._tag !== "Success" || !props.threadRef) return; + await linking + .changeLink( + props.threadRef, + `https://github.com/${repository}/pull/${opened.value.number}`, + true, + ) + .catch(() => {}); + }); + + const pushFollowUps = () => + run("push", async () => { + if (!newest || !status) return; + await push({ + environmentId, + input: { cwd: props.cwd, changeId: newest.changeId, bookmark: status.headBranch }, + }); + }); + + const toggleAutoMerge = () => + run("auto-merge", async () => { + if (!status || !repository) return; + // The queue skips drafts, so asking a draft to auto-merge marks it ready first. + if (status.draft) { + const ready = await updatePullRequest({ + environmentId, + input: { repository, number: status.number, draft: false }, + }); + if (ready._tag !== "Success") return; + } + await setAutoMerge({ + environmentId, + input: { repository, number: status.number, enabled: !status.autoMerge }, + }); + }); + + return ( +
+ {status && props.pill ? ( + + ) : null} + + {repository && props.pullRequestNumber === null && newest ? ( + + + + + + } + > + + + + void open({ autoMerge: false, draft: false })}> + Open PR + + void open({ autoMerge: false, draft: true })}> + Open as draft + + + + + ) : null} + {status && !shipping && props.pendingCount > 0 ? ( + + ) : null} + {status && !shipping ? ( + + ) : null} + +
+ ); +} diff --git a/apps/web/src/components/review/JjStackViews.tsx b/apps/web/src/components/review/JjStackViews.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aec28cbc04f7dba5e8ef51d50cd56124cc378617 --- /dev/null +++ b/apps/web/src/components/review/JjStackViews.tsx @@ -0,0 +1,318 @@ +import type { JjChange, JjStack } from "@t3tools/contracts"; +import { LockIcon, PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react"; +import { useLayoutEffect, useRef, type MouseEvent } from "react"; + +import { cn } from "~/lib/utils"; +import { ClaudeAI, OpenAI } from "../Icons"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { CommitTitle, parseCommitTitle, type CommitTitleTone } from "./commitTitle"; +import { changeTitle, type Selection } from "./jjReview.logic"; + +const NODE_TONE: Record = { + add: "text-success-foreground", + fix: "text-error-foreground", + other: "text-info-foreground", + wip: "text-warning-foreground", + none: "text-muted-foreground", +}; + +const changeTone = (change: JjChange) => + parseCommitTitle(changeTitle(change)).prefix?.tone ?? "none"; + +/** jj's own glyphs: `@` for the working copy, a diamond when immutable, a circle otherwise. */ +export function ChangeNode({ change, className }: { change: JjChange; className?: string }) { + if (change.workingCopy) { + return ( + + @ + + ); + } + const tone = change.conflict ? "text-error-foreground" : NODE_TONE[changeTone(change)]; + return ( + + {change.immutable ? ( + + ) : ( + + )} + + ); +} + +function TrunkNode() { + return ( + + + + ); +} + +function LineCounts({ additions, deletions }: { additions: number; deletions: number }) { + return ( + + +{additions}{" "} + −{deletions} + + ); +} + +export function modelGlyph(model: string) { + if (model.startsWith("claude")) return ; + if (/^(gpt|chatgpt|o\d)/.test(model)) return ; + return null; +} + +const inSelection = (selection: Selection, index: number) => + index >= selection[0] && index <= selection[1]; + +const extendsSelection = (event: MouseEvent) => event.shiftKey || event.metaKey || event.ctrlKey; + +/** One icon per change, oldest to newest, trunk first; line counts under each, the newest change named. */ +export function StackStrip(props: { + stack: JjStack; + changes: readonly JjChange[]; + selection: Selection; + onSelect: (index: number, extend: boolean) => void; + onSelectAll: () => void; + onOpenRail: () => void; +}) { + const { stack, changes, selection } = props; + const scroller = useRef(null); + const newestSelected = selection[0]; + useLayoutEffect(() => { + if (changes.length === 0) return; + scroller.current + ?.querySelector(`[data-index="${newestSelected}"]`) + ?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [changes, newestSelected]); + const newest = changes[0]; + const allSelected = selection[0] === 0 && selection[1] === changes.length - 1; + + return ( +
+ +
+ {stack.trunk ? ( +
+ + + {stack.trunk.behind > 0 ? `${stack.trunk.behind} behind` : "trunk"} + +
+ ) : null} + {changes + .map((change, index) => ({ change, index })) + .toReversed() + .map(({ change, index }) => { + const selected = inSelection(selection, index); + return ( + + props.onSelect(index, extendsSelection(event))} + className={cn( + "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", + "before:absolute before:top-[calc(0.25rem+5.5px)] before:right-1/2 before:-left-1/2 before:h-px before:bg-border", + selected ? "bg-primary/14" : "rounded-md hover:bg-accent", + selected && !inSelection(selection, index + 1) && "rounded-s-md", + selected && !inSelection(selection, index - 1) && "rounded-e-md", + )} + /> + } + > + + + + + {changeTitle(change) ? ( + + ) : ( + "(no description)" + )} + + + ); + })} +
+ {newest ? ( + + {changeTitle(newest) ? ( + + ) : ( + (no description) + )} + + ) : null} + {changes.length > 1 ? ( + + ) : null} +
+ ); +} + +/** The vertical stack and the selection's files, shown when the panel has room. */ +export function StackRail(props: { + stack: JjStack; + changes: readonly JjChange[]; + selection: Selection; + pendingCount: number; + files: ReadonlyArray<{ path: string; additions: number; deletions: number }>; + onSelect: (index: number, extend: boolean) => void; + onSelectAll: () => void; + onClose: () => void; + onOpenFile: (path: string) => void; +}) { + const { stack, changes, selection } = props; + return ( +
+
+ + Stack + + {changes.length} + + + +
+
+ {changes.map((change, index) => { + const selected = inSelection(selection, index); + const model = change.trailers.find((t) => t.key === "Assisted-by")?.value; + const bookmark = change.bookmarks[0]; + return ( + + ); + })} + {stack.trunk ? ( +
+ + + + + {stack.trunk.name} + {stack.trunk.behind > 0 ? ` · ${stack.trunk.behind} behind` : ""} + +
+ ) : null} +
+
+
+ Files + {props.files.length} +
+
+ {props.files.map((file) => { + const slash = file.path.lastIndexOf("/"); + return ( + + ); + })} +
+
+
+ ); +} diff --git a/apps/web/src/components/review/MergebotStatusPill.tsx b/apps/web/src/components/review/MergebotStatusPill.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d74b27f9f5e931525f446c1c5250b53c8db493be --- /dev/null +++ b/apps/web/src/components/review/MergebotStatusPill.tsx @@ -0,0 +1,119 @@ +import type { MergebotPullRequestLiveStatus } from "@t3tools/contracts"; +import type { + MergebotStatusPill as Pill, + MergebotStatusPillTone, +} from "@t3tools/client-runtime/mergebot-status-pill"; +import { + CircleCheckIcon, + CircleDashedIcon, + CircleXIcon, + FlaskConicalIcon, + GlobeIcon, + HourglassIcon, + RocketIcon, + TriangleAlertIcon, + type LucideIcon, +} from "lucide-react"; + +import { useNowMinute } from "~/hooks/useNowMinute"; +import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; +import { cn } from "~/lib/utils"; + +const TONE: Record = { + yellow: "bg-yellow-500/15 text-yellow-700 dark:text-yellow-400", + red: "bg-error/12 text-error-foreground", + orange: "bg-orange-500/12 text-orange-700 dark:text-orange-400", + blue: "bg-info/12 text-info-foreground", + green: "bg-success/12 text-success-foreground", + muted: "bg-muted text-muted-foreground", +}; + +const ICON: Record, LucideIcon> = { + rebasing: PullRequestGlyph.pullRequest, + "ci-failed": CircleXIcon, + "merge-conflicts": TriangleAlertIcon, + "draft-passes-ci": CircleDashedIcon, + ready: CircleCheckIcon, + queued: HourglassIcon, + merging: PullRequestGlyph.merged, + merged: PullRequestGlyph.merged, + deploying: RocketIcon, + "deploy-failed": RocketIcon, + "on-staging": FlaskConicalIcon, + shipped: GlobeIcon, +}; + +/** A still arc of finished checks; nothing on the pill repaints on its own. */ +function ChecksProgress({ completed, total }: { completed: number; total: number }) { + const radius = 8; + const circumference = 2 * Math.PI * radius; + const filled = total > 0 ? (completed / total) * circumference : 0; + return ( + + + {filled > 0 ? ( + + ) : null} + + ); +} + +function minutesUntil(etaAt: string, now: string) { + const minutes = Math.round((Date.parse(etaAt) - Date.parse(now)) / 60_000); + return minutes < 1 ? "<1m" : `~${minutes}m`; +} + +export function MergebotStatusPill({ + pill, + status, + className, +}: { + pill: Pill; + status: MergebotPullRequestLiveStatus; + className?: string; +}) { + const now = useNowMinute(); + const Icon = pill.kind === "running-ci" ? null : ICON[pill.kind]; + return ( + + {Icon ? ( + + ) : ( + + )} + {pill.label} + {pill.etaAt ? ( + {minutesUntil(pill.etaAt, now)} + ) : null} + + ); +} diff --git a/apps/web/src/components/review/ThreadMergebotPill.tsx b/apps/web/src/components/review/ThreadMergebotPill.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b274696c36136946f297551c80ec00e955df9d8d --- /dev/null +++ b/apps/web/src/components/review/ThreadMergebotPill.tsx @@ -0,0 +1,27 @@ +import { mergebotStatusPill } from "@t3tools/client-runtime/mergebot-status-pill"; +import { isMergebotRepository, type EnvironmentId } from "@t3tools/contracts"; + +import { useServerConfigs } from "~/state/entities"; +import { useMergebotPullRequestStatus } from "~/state/mergebot"; + +import { MergebotStatusPill } from "./MergebotStatusPill"; + +/** True when the environment routes this repository's PRs through Mergebot. */ +export function useIsMergebotRepository( + environmentId: EnvironmentId, + repository: string | undefined, +) { + const configuration = useServerConfigs().get(environmentId)?.mergebot; + return repository !== undefined && isMergebotRepository(configuration, repository); +} + +/** The row-3 status pill for a thread's current PR. */ +export function ThreadMergebotPill(props: { + environmentId: EnvironmentId; + repository: string; + number: number; +}) { + const status = useMergebotPullRequestStatus(props.environmentId, props.repository, props.number); + const pill = status ? mergebotStatusPill(status) : null; + return status && pill ? : null; +} diff --git a/apps/web/src/components/review/commitTitle.test.ts b/apps/web/src/components/review/commitTitle.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f049430eadac9e89a064de120189d508a4888ef --- /dev/null +++ b/apps/web/src/components/review/commitTitle.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { parseCommitTitle } from "./commitTitle"; + +describe("parseCommitTitle", () => { + it("finds issue keys, the conventional prefix, and its scope", () => { + const title = "[ENG-311, ENG-312] fix(api)!: retry flaky uploads"; + const parsed = parseCommitTitle(title); + expect(parsed.issues).toEqual(["ENG-311", "ENG-312"]); + expect(parsed.prefix?.tone).toBe("fix"); + expect(title.slice(parsed.prefix!.from, parsed.prefix!.to)).toBe("fix(api)!:"); + expect(title.slice(parsed.scope!.from, parsed.scope!.to)).toBe("(api)"); + expect(title.slice(parsed.restFrom)).toBe(" retry flaky uploads"); + }); + + it("uses Mergebot's tones: features green, fixes red, anything else blue", () => { + expect(parseCommitTitle("infra: cache browsers").prefix?.tone).toBe("add"); + expect(parseCommitTitle("hotpatch: revert").prefix?.tone).toBe("fix"); + expect(parseCommitTitle("chore: follow-up changes").prefix?.tone).toBe("other"); + }); + + it("marks WIP without a scope highlight and leaves plain titles alone", () => { + expect(parseCommitTitle("WIP(ui): try layouts").prefix?.tone).toBe("wip"); + expect(parseCommitTitle("WIP(ui): try layouts").scope).toBeNull(); + expect(parseCommitTitle("Fix the build").prefix).toBeNull(); + }); +}); diff --git a/apps/web/src/components/review/commitTitle.tsx b/apps/web/src/components/review/commitTitle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c3b728171101c7db094550812ffe102e4e28e5e0 --- /dev/null +++ b/apps/web/src/components/review/commitTitle.tsx @@ -0,0 +1,90 @@ +import { cn } from "~/lib/utils"; + +export const ISSUE_PREFIX = /^\[[A-Z][A-Z0-9]*-\d+(?:,\s*[A-Z][A-Z0-9]*-\d+)*\]\s*/; +export const CONVENTIONAL_TYPE = /^([A-Za-z]+)(\([^()]*\))?!?:/; + +export type CommitTitleTone = "add" | "fix" | "other" | "wip"; + +/** The same tone table as Mergebot's commit-title.ts, so both UIs color a title alike. */ +export function commitTypeTone(type: string): CommitTitleTone { + switch (type.toLowerCase()) { + case "wip": + return "wip"; + case "feat": + case "feature": + case "aura": + case "infra": + return "add"; + case "fix": + case "hotfix": + case "hotpatch": + return "fix"; + default: + return "other"; + } +} + +export interface ParsedCommitTitle { + readonly issues: readonly string[]; + /** Offsets into the title; `prefix` covers `type(scope)!:`. */ + readonly prefix: { + readonly from: number; + readonly to: number; + readonly tone: CommitTitleTone; + } | null; + readonly scope: { readonly from: number; readonly to: number } | null; + readonly restFrom: number; +} + +export function parseCommitTitle(title: string): ParsedCommitTitle { + const issueMatch = ISSUE_PREFIX.exec(title); + const typeStart = issueMatch ? issueMatch[0].length : 0; + const issues = issueMatch ? (issueMatch[0].match(/[A-Z][A-Z0-9]*-\d+/g) ?? []) : []; + const conventional = CONVENTIONAL_TYPE.exec(title.slice(typeStart)); + if (!conventional) return { issues, prefix: null, scope: null, restFrom: typeStart }; + const [prefix, type = "", scope] = conventional; + const tone = commitTypeTone(type); + return { + issues, + prefix: { from: typeStart, to: typeStart + prefix.length, tone }, + scope: + scope && tone !== "wip" + ? { from: typeStart + type.length, to: typeStart + type.length + scope.length } + : null, + restFrom: typeStart + prefix.length, + }; +} + +const TONE_CLASS: Record = { + add: "text-success-foreground", + fix: "text-error-foreground", + other: "text-info-foreground", + wip: "rounded-[3px] bg-warning px-0.5 text-background", +}; + +/** A commit title with its issue keys dimmed and its conventional prefix colored. */ +export function CommitTitle({ title, className }: { title: string; className?: string }) { + const parsed = parseCommitTitle(title); + const { prefix, scope } = parsed; + return ( + + {parsed.issues.length > 0 ? ( + {parsed.issues.join(", ")} + ) : null} + {prefix ? ( + + {scope ? ( + <> + {title.slice(prefix.from, scope.from)} + {title.slice(scope.from, scope.to)} + {title.slice(scope.to, prefix.to)} + + ) : ( + title.slice(prefix.from, prefix.to) + )} + + ) : null} + {title.slice(parsed.restFrom)} + + ); +} diff --git a/apps/web/src/components/review/jjReview.logic.test.ts b/apps/web/src/components/review/jjReview.logic.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..335ff3a590d49b1c6383cd5d64cfb92300d4eeb2 --- /dev/null +++ b/apps/web/src/components/review/jjReview.logic.test.ts @@ -0,0 +1,96 @@ +import type { JjChange } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + clampSelection, + newestPullRequestIndex, + pullRequestFor, + pullRequestText, + selectChange, + selectionRange, + visibleChanges, +} from "./jjReview.logic"; + +const change = (id: string, description: string, overrides: Partial = {}): JjChange => ({ + changeId: id, + shortId: id.slice(0, 4), + commitId: `${id}-commit`, + parentCommitIds: [`${id}-parent`], + description, + trailers: [...description.matchAll(/^Assisted-by: (.+)$/gm)].map((m) => ({ + key: "Assisted-by", + value: m[1]!, + })), + bookmarks: [], + immutable: false, + conflict: false, + empty: false, + workingCopy: false, + additions: 1, + deletions: 0, + authoredAt: "2026-10-15T12:00:00-07:00", + ...overrides, +}); + +// Newest first, like the stack. +const followUp = change("follow", "chore: follow-up changes\n\nAssisted-by: gpt-6.1-sol"); +const feature = change( + "feature", + "feat(reader): keep scroll\n\nRestore offsets per tab.\n\nAssisted-by: claude-opus-5.5", +); +const fix = change("fixit", "fix(api): retry uploads"); + +describe("selection", () => { + it("selects one change, or extends a contiguous range from the anchor", () => { + expect(selectChange(1, 2, false)).toEqual([2, 2]); + expect(selectChange(2, 0, true)).toEqual([0, 2]); + }); + + it("clamps a selection when the stack shrinks", () => { + expect(clampSelection([2, 3], 2)).toEqual([1, 1]); + expect(clampSelection([0, 0], 0)).toEqual([0, 0]); + }); + + it("diffs a range from the oldest change's parent to the newest change", () => { + expect(selectionRange([followUp, feature], [0, 1])).toEqual({ + fromCommitId: "feature-parent", + toCommitId: "follow-commit", + }); + expect( + selectionRange([change("root", "x", { parentCommitIds: [] })], [0, 0])?.fromCommitId, + ).toBe("0".repeat(40)); + }); + + it("hides an empty working copy but keeps one with changes", () => { + const stack = { workspaceRoot: "/w", repository: null, trunk: null }; + expect( + visibleChanges({ + ...stack, + changes: [change("wc", "", { workingCopy: true, empty: true }), feature], + }), + ).toEqual([feature]); + expect( + visibleChanges({ ...stack, changes: [change("wc", "", { workingCopy: true }), feature] }), + ).toHaveLength(2); + }); +}); + +describe("pull requests", () => { + const prs = new Map([["feature-commit", 80]]); + + it("puts a change in the PR of the nearest head at or above it", () => { + const changes = [followUp, feature, fix]; + expect(pullRequestFor(changes, prs, 2)).toBe(80); + expect(pullRequestFor(changes, prs, 1)).toBe(80); + expect(pullRequestFor(changes, prs, 0)).toBeNull(); + expect(newestPullRequestIndex(changes, prs)).toBe(1); + }); + + it("titles a PR after its oldest non-follow-up change and credits every model", () => { + expect(pullRequestText([followUp, feature])).toEqual({ + title: "feat(reader): keep scroll", + body: "Restore offsets per tab.\n\nAssisted-by: gpt-6.1-sol\nAssisted-by: claude-opus-5.5", + baseChangeId: "feature", + }); + }); +}); diff --git a/apps/web/src/components/review/jjReview.logic.ts b/apps/web/src/components/review/jjReview.logic.ts new file mode 100644 index 0000000000000000000000000000000000000000..2cb6b36f348d58e294fb535c67b66beb7c1ddb32 --- /dev/null +++ b/apps/web/src/components/review/jjReview.logic.ts @@ -0,0 +1,89 @@ +import type { JjChange, JjStack, VcsStatusResult } from "@t3tools/contracts"; + +export function supportsWorkspaceChanges( + status: Pick, +) { + return status.isRepo || status.isJjWorkspace === true; +} + +export const FOLLOW_UP_TITLE = "chore: follow-up changes"; +/** jj's root commit, the parent of a change with no parents. */ +const ROOT_COMMIT_ID = "0".repeat(40); + +export const changeTitle = (change: JjChange) => change.description.split("\n", 1)[0] ?? ""; + +/** The changes the panel lists, newest first: the stack without an empty working copy. */ +export function visibleChanges(stack: JjStack): JjChange[] { + return stack.changes.filter((change) => !(change.workingCopy && change.empty)); +} + +/** Inclusive index range into `visibleChanges`, newest first. */ +export type Selection = readonly [newest: number, oldest: number]; + +/** Click selects one change; shift- or cmd-click extends a contiguous range from the anchor. */ +export function selectChange(anchor: number, index: number, extend: boolean): Selection { + return extend ? [Math.min(anchor, index), Math.max(anchor, index)] : [index, index]; +} + +export function clampSelection(selection: Selection, count: number): Selection { + if (count === 0) return [0, 0]; + const newest = Math.min(selection[0], count - 1); + return [newest, Math.min(Math.max(selection[1], newest), count - 1)]; +} + +/** From the oldest selected change's first parent to the newest selected change. */ +export function selectionRange(changes: readonly JjChange[], selection: Selection) { + const newest = changes[selection[0]]; + const oldest = changes[selection[1]]; + if (!newest || !oldest) return null; + return { + fromCommitId: oldest.parentCommitIds[0] ?? ROOT_COMMIT_ID, + toCommitId: newest.commitId, + }; +} + +/** The PR a change ships in: the nearest change at or above it whose commit heads a PR. */ +export function pullRequestFor( + changes: readonly JjChange[], + prByHeadSha: ReadonlyMap, + index: number, +): number | null { + for (let i = Math.min(index, changes.length - 1); i >= 0; i--) { + const number = prByHeadSha.get(changes[i]!.commitId); + if (number !== undefined) return number; + } + return null; +} + +/** Index of the newest change heading a PR, or -1. Changes above it are not on the PR yet. */ +export function newestPullRequestIndex( + changes: readonly JjChange[], + prByHeadSha: ReadonlyMap, +): number { + return changes.findIndex((change) => prByHeadSha.has(change.commitId)); +} + +/** PR text: the oldest change that is not a follow-up, with every change's Assisted-by trailers. */ +export function pullRequestText(changes: readonly JjChange[]) { + const base = + changes.findLast((change) => changeTitle(change) && changeTitle(change) !== FOLLOW_UP_TITLE) ?? + changes[0]; + const models = [ + ...new Set( + changes.flatMap((change) => + change.trailers.filter((t) => t.key === "Assisted-by").map((t) => t.value), + ), + ), + ]; + const [title = "", ...rest] = (base?.description ?? "").split("\n"); + const prose = rest + .join("\n") + .replace(/(^|\n)Assisted-by: .+/g, "") + .trim(); + const credits = models.map((model) => `Assisted-by: ${model}`).join("\n"); + return { + title, + body: [prose, credits].filter(Boolean).join("\n\n"), + baseChangeId: base?.changeId, + }; +} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 5d681bc5c31a91cba6b5e4936a9d6011ddfa9375..17f1212fb661969e8f4198c9af55765ee8ab388d 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -23,6 +23,7 @@ import { NewWorkWorkspaceButton } from "./NewWorkWorkspaceButton"; import { AppMenu } from "./AppMenu"; import { SidebarMergebotButton } from "./SidebarMergebotButton"; import { SidebarUsageButton } from "./SidebarUsageButton"; +import { SidebarDeploymentsButton } from "./SidebarDeploymentsButton"; export const SidebarChromeHeader = memo(function SidebarChromeHeader({ isElectron, @@ -38,29 +39,33 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ return ( // The titlebar row, not a padded SidebarHeader: it aligns to the window controls. + // Its height already spaces the first group below, so it absorbs that group's top inset.
- {/* Match the fixed control's inset and border compensation inside the sheet's safe area. */} -
-
- + {/* Match the fixed control's top inset inside the sheet's safe area. */} +
+ {/* Match the fixed control's inset and border compensation. */} +
+
+ +
+
- -
+ {pillLabel ? ( +
+ + {pillLabel} + +
+ ) : null} +
- {pillLabel ? ( -
- - {pillLabel} - -
- ) : null}
); }); @@ -101,7 +106,16 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( - +
+
+ +
+
+ + + +
+
); diff --git a/apps/web/src/components/sidebar/SidebarDeploymentsButton.tsx b/apps/web/src/components/sidebar/SidebarDeploymentsButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a249d9b4ccbefbcb16dd3cfcf72aff8ceee4263d --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarDeploymentsButton.tsx @@ -0,0 +1,82 @@ +import type { EnvironmentId, MergebotDeployments } from "@t3tools/contracts"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { RocketIcon } from "lucide-react"; + +import { cn } from "../../lib/utils"; +import { useRightPanelStore } from "../../rightPanelStore"; +import { usePrimaryEnvironmentId } from "../../state/environments"; +import { resolveThreadRouteRef } from "../../threadRoutes"; +import { ConfiguredDeployments } from "../deployments/ConfiguredDeployments"; +import { deploymentActivity, stagingOnlyCount } from "../deployments/deployments.logic"; +import { Badge } from "../ui/badge"; +import { SidebarMenuButton, SidebarMenuItem, useSidebar } from "../ui/sidebar"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +const ACTIVITY_DOT = { + healthy: "bg-success", + deploying: "bg-warning", + failed: "bg-destructive", +} as const; + +/** Opens Deployments in the current thread's right panel, or as a page without a thread. */ +export function SidebarDeploymentsButton() { + const threadRef = useParams({ strict: false, select: resolveThreadRouteRef }); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const environmentId = threadRef?.environmentId ?? primaryEnvironmentId; + + if (!environmentId) return null; + + return ( + + {(found) => + found.length > 0 ? : null + } + + ); +} + +function DeploymentsButton(props: { + environmentId: EnvironmentId; + found: readonly MergebotDeployments[]; +}) { + const navigate = useNavigate(); + const threadRef = useParams({ strict: false, select: resolveThreadRouteRef }); + const { isMobile, setOpenMobile } = useSidebar(); + + const pending = props.found.reduce((total, entry) => total + stagingOnlyCount(entry), 0); + const activity = deploymentActivity(props.found); + const label = pending > 0 ? `Deployments, ${pending} on staging only` : "Deployments"; + + const open = () => { + if (threadRef) useRightPanelStore.getState().open(threadRef, "deployments"); + else void navigate({ to: "/deployments" }); + if (isMobile) setOpenMobile(false); + }; + + return ( + + + + + + ); +} diff --git a/apps/web/src/components/sidebar/mainAppLocation.ts b/apps/web/src/components/sidebar/mainAppLocation.ts index 9048d5968c54c08a659c5b8c5beb6e9e4930c7e2..fd825fd38b9d08f7aef7eb78d2a6b801de133122 100644 --- a/apps/web/src/components/sidebar/mainAppLocation.ts +++ b/apps/web/src/components/sidebar/mainAppLocation.ts @@ -1,7 +1,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect } from "react"; -// Settings, Usage, and Pull Requests replace the sidebar utility row with a +// Settings, Usage, Deployments, and Pull Requests replace the sidebar utility row with a // Back button. Everything else is the main app. Legacy `/projects/` links // redirect into settings, so they count too and are never remembered. export function isSidebarUtilityPage(pathname: string) { @@ -10,6 +10,7 @@ export function isSidebarUtilityPage(pathname: string) { pathname.startsWith("/settings/") || pathname.startsWith("/projects/") || pathname === "/usage" || + pathname === "/deployments" || pathname === "/pull-requests" ); } diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index bbc9e8ecf61868fa47f053e65425d5b39fa875d6..383bbbeacd91480ccd5eeae3a360d481ff12484d 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -30,6 +30,7 @@ const RIGHT_PANEL_KINDS = [ "terminal", "pull-request", "pull-requests", + "deployments", ] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; @@ -53,6 +54,7 @@ export type RightPanelSurface = splitDirection?: "horizontal" | "vertical"; } | { id: "diff"; kind: "diff" } + | { id: "deployments"; kind: "deployments" } | { id: "files"; kind: "files" } | { id: `file:${string}` | `attachment:${string}`; @@ -230,6 +232,8 @@ const singletonSurface = ( return { id: "pull-requests", kind }; case "device": return { id: "device", kind }; + case "deployments": + return { id: "deployments", kind }; } }; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 7c0b13ef767658d5fd7cf92d28a1753f6977ec05..a795f2a327f403232e03b701cd93fff5bb918e41 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as WelcomeRouteImport } from './routes/welcome' import { Route as UsageRouteImport } from './routes/usage' import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' +import { Route as DeploymentsRouteImport } from './routes/deployments' import { Route as ConnectAgentRouteImport } from './routes/connect-agent' import { Route as ConnectRouteImport } from './routes/connect' import { Route as ChatRouteImport } from './routes/_chat' @@ -57,6 +58,11 @@ const PairRoute = PairRouteImport.update({ path: '/pair', getParentRoute: () => rootRouteImport, } as any) +const DeploymentsRoute = DeploymentsRouteImport.update({ + id: '/deployments', + path: '/deployments', + getParentRoute: () => rootRouteImport, +} as any) const ConnectAgentRoute = ConnectAgentRouteImport.update({ id: '/connect-agent', path: '/connect-agent', @@ -178,6 +184,7 @@ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/connect': typeof ConnectRoute '/connect-agent': typeof ConnectAgentRoute + '/deployments': typeof DeploymentsRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute @@ -205,6 +212,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/connect': typeof ConnectRoute '/connect-agent': typeof ConnectAgentRoute + '/deployments': typeof DeploymentsRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute @@ -235,6 +243,7 @@ export interface FileRoutesById { '/_chat': typeof ChatRouteWithChildren '/connect': typeof ConnectRoute '/connect-agent': typeof ConnectAgentRoute + '/deployments': typeof DeploymentsRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute @@ -266,6 +275,7 @@ export interface FileRouteTypes { | '/' | '/connect' | '/connect-agent' + | '/deployments' | '/pair' | '/settings' | '/usage' @@ -293,6 +303,7 @@ export interface FileRouteTypes { to: | '/connect' | '/connect-agent' + | '/deployments' | '/pair' | '/settings' | '/usage' @@ -322,6 +333,7 @@ export interface FileRouteTypes { | '/_chat' | '/connect' | '/connect-agent' + | '/deployments' | '/pair' | '/settings' | '/usage' @@ -352,6 +364,7 @@ export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren ConnectRoute: typeof ConnectRoute ConnectAgentRoute: typeof ConnectAgentRoute + DeploymentsRoute: typeof DeploymentsRoute PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren UsageRoute: typeof UsageRoute @@ -389,6 +402,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PairRouteImport parentRoute: typeof rootRouteImport } + '/deployments': { + id: '/deployments' + path: '/deployments' + fullPath: '/deployments' + preLoaderRoute: typeof DeploymentsRouteImport + parentRoute: typeof rootRouteImport + } '/connect-agent': { id: '/connect-agent' path: '/connect-agent' @@ -613,6 +633,7 @@ const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, ConnectRoute: ConnectRoute, ConnectAgentRoute: ConnectAgentRoute, + DeploymentsRoute: DeploymentsRoute, PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, UsageRoute: UsageRoute, diff --git a/apps/web/src/routes/deployments.tsx b/apps/web/src/routes/deployments.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3a425b3ab038828e66f740c9fd2dabe21001bedc --- /dev/null +++ b/apps/web/src/routes/deployments.tsx @@ -0,0 +1,44 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +import { DeploymentsPanel } from "../components/deployments/DeploymentsPanel"; +import { SidebarInset } from "../components/ui/sidebar"; +import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../components/WorkspaceBreadcrumb"; +import { WorkspacePageContainer } from "../components/WorkspacePageContainer"; +import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; +import { isElectron } from "../env"; +import { useEscapeToGoBack } from "../hooks/useNavigateBack"; +import { usePrimaryEnvironmentId } from "../state/environments"; + +function DeploymentsPage() { + useEscapeToGoBack(); + const environmentId = usePrimaryEnvironmentId(); + + return ( + +
+ + + +

Deployments

+
+
+
+ + {environmentId ? : null} + +
+
+ ); +} + +export const Route = createFileRoute("/deployments")({ + beforeLoad: async ({ context }) => { + if ( + context.authGateState.status !== "authenticated" && + context.authGateState.status !== "hosted-static" + ) { + throw redirect({ to: "/pair", replace: true }); + } + }, + component: DeploymentsPage, +}); diff --git a/apps/web/src/state/jj.ts b/apps/web/src/state/jj.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2e33cf923c699a704e2910158a90ad1d90e6268 --- /dev/null +++ b/apps/web/src/state/jj.ts @@ -0,0 +1,36 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentRpcSubscriptionAtomFamily, +} from "@t3tools/client-runtime/state/runtime"; +import { connectionAtomRuntime } from "../connection/runtime"; + +export const jjEnvironment = { + stack: createEnvironmentRpcSubscriptionAtomFamily(connectionAtomRuntime, { + label: "jj:stack", + tag: WS_METHODS.jjSubscribeStack, + }), + // A range of commit ids never changes, so a cached diff stays valid. + diff: createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, { + label: "jj:diff", + tag: WS_METHODS.jjDiff, + staleTimeMs: 60 * 60_000, + }), + snapshot: createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "jj:snapshot", + tag: WS_METHODS.jjSnapshot, + }), + describe: createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "jj:describe", + tag: WS_METHODS.jjDescribe, + }), + squash: createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "jj:squash", + tag: WS_METHODS.jjSquash, + }), + push: createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "jj:push", + tag: WS_METHODS.jjPush, + }), +}; diff --git a/apps/web/src/state/mergebot.ts b/apps/web/src/state/mergebot.ts index 4c5992bc21e2f8a0f3c8b4b5005a9fe068d5aa2c..cba227d7486e2bb9be848220dbe619d9fd8b60c1 100644 --- a/apps/web/src/state/mergebot.ts +++ b/apps/web/src/state/mergebot.ts @@ -1,14 +1,65 @@ import { useAtomValue } from "@effect/atom-react"; -import { createMergebotQueueAtomFamily } from "@t3tools/client-runtime/state/pull-requests"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { + createMergebotDeploymentsAtomFamily, + createMergebotPullRequestStatusAtomFamily, + createMergebotQueueAtomFamily, +} from "@t3tools/client-runtime/state/pull-requests"; +import { createEnvironmentRpcCommand } from "@t3tools/client-runtime/state/runtime"; +import { WS_METHODS, type EnvironmentId } from "@t3tools/contracts"; +import type { AsyncResult } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; const mergebotQueue = createMergebotQueueAtomFamily(connectionAtomRuntime); +const mergebotPullRequestStatus = createMergebotPullRequestStatusAtomFamily(connectionAtomRuntime); +const mergebotDeployments = createMergebotDeploymentsAtomFamily(connectionAtomRuntime); -export function useMergebotQueue(environmentId: EnvironmentId, repository: string) { - const result = useAtomValue(mergebotQueue({ environmentId, input: { repository } })); +/** A live Mergebot value, or null while disconnected, loading, or failed. */ +function useConnectedValue( + environmentId: EnvironmentId, + result: AsyncResult.AsyncResult, +) { const connection = useAtomValue(environmentCatalog.stateAtom(environmentId)); if (connection._tag !== "Success" || connection.value.phase !== "connected") return null; return result._tag === "Success" ? result.value : null; } + +export function useMergebotQueue(environmentId: EnvironmentId, repository: string) { + const result = useAtomValue(mergebotQueue({ environmentId, input: { repository } })); + return useConnectedValue(environmentId, result); +} + +export function useMergebotPullRequestStatus( + environmentId: EnvironmentId, + repository: string, + number: number, +) { + const result = useAtomValue( + mergebotPullRequestStatus({ environmentId, input: { repository, number } }), + ); + return useConnectedValue(environmentId, result); +} + +export function useMergebotDeployments(environmentId: EnvironmentId, repository: string) { + const result = useAtomValue(mergebotDeployments({ environmentId, input: { repository } })); + return useConnectedValue(environmentId, result); +} + +export const mergebotEnvironment = { + openPullRequest: createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "environment-data:mergebot:open-pull-request", + tag: WS_METHODS.mergebotOpenPullRequest, + }), + setAutoMerge: createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "environment-data:mergebot:set-auto-merge", + tag: WS_METHODS.mergebotSetAutoMerge, + }), + updatePullRequest: createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "environment-data:mergebot:update-pull-request", + tag: WS_METHODS.mergebotUpdatePullRequest, + }), + deployProduction: createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "environment-data:mergebot:deploy-production", + tag: WS_METHODS.mergebotDeployProduction, + }), +}; diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index c04217be3aede8254d1ba3a99bad024e7b563a8a..c20a4aab5c5a1858ca3a9e113b1d444369997344 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -43,6 +43,10 @@ "types": "./src/projectFaviconCache.ts", "default": "./src/projectFaviconCache.ts" }, + "./mergebot-status-pill": { + "types": "./src/mergebotStatusPill.ts", + "default": "./src/mergebotStatusPill.ts" + }, "./user-message": { "types": "./src/userMessage.ts", "default": "./src/userMessage.ts" diff --git a/packages/client-runtime/src/mergebotStatusPill.test.ts b/packages/client-runtime/src/mergebotStatusPill.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb9240521735a26db119ad163e5120698dd16464 --- /dev/null +++ b/packages/client-runtime/src/mergebotStatusPill.test.ts @@ -0,0 +1,130 @@ +import type { MergebotPullRequestLiveStatus } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { mergebotStatusPill } from "./mergebotStatusPill.ts"; + +const eta = "2026-10-15T12:00:00Z"; +const open: MergebotPullRequestLiveStatus = { + number: 12, + url: "https://mergebot.example/work/app/pull/12", + title: "Ship change", + body: null, + headSha: "head", + headBranch: "feature", + lifecycle: "open", + draft: false, + autoMerge: false, + ciStatus: "passed", + checks: { completed: 14, total: 14, failed: 0 }, + hasConflicts: false, + queueStatus: "not_queued", + position: null, + mergeEtaAt: null, + deploy: null, +}; +const merged = (deploy: MergebotPullRequestLiveStatus["deploy"]) => ({ + ...open, + lifecycle: "merged" as const, + deploy, +}); +const queued = { ...open, autoMerge: true, queueStatus: "queued" as const, mergeEtaAt: eta }; + +describe("mergebotStatusPill", () => { + it.each<[string, MergebotPullRequestLiveStatus, ReturnType]>([ + [ + "running CI with counts and a queue ETA", + { + ...queued, + ciStatus: "pending", + queueStatus: "waiting_ci", + position: 3, + checks: { completed: 3, total: 14, failed: 0 }, + }, + { kind: "running-ci", label: "Running CI (3/14)", tone: "yellow", etaAt: eta }, + ], + [ + "running CI before checks load", + { ...open, ciStatus: "pending", checks: null }, + { kind: "running-ci", label: "Running CI", tone: "yellow", etaAt: null }, + ], + [ + "rebasing", + { ...queued, queueStatus: "updating_branch", position: 1 }, + { kind: "rebasing", label: "Rebasing", tone: "yellow", etaAt: eta }, + ], + [ + "CI failed", + { ...open, ciStatus: "failed", checks: { completed: 14, total: 14, failed: 2 } }, + { kind: "ci-failed", label: "CI Failed", tone: "red", etaAt: null }, + ], + [ + "CI failed in the queue", + { ...queued, queueStatus: "ci_failed", position: 2 }, + { kind: "ci-failed", label: "CI Failed", tone: "red", etaAt: null }, + ], + [ + "merge conflicts outrank failing CI", + { ...open, ciStatus: "failed", hasConflicts: true }, + { kind: "merge-conflicts", label: "Merge Conflicts", tone: "orange", etaAt: null }, + ], + [ + "draft passing CI", + { ...open, draft: true }, + { kind: "draft-passes-ci", label: "Draft Passes CI", tone: "blue", etaAt: null }, + ], + ["ready to merge", open, { kind: "ready", label: "Ready to merge", tone: "blue", etaAt: null }], + [ + "unknown conflicts still read as ready", + { ...open, hasConflicts: null }, + { kind: "ready", label: "Ready to merge", tone: "blue", etaAt: null }, + ], + [ + "queued behind another PR", + { ...queued, position: 2 }, + { kind: "queued", label: "Queued #2", tone: "blue", etaAt: eta }, + ], + [ + "first in the queue with passing CI", + { ...queued, position: 1 }, + { kind: "merging", label: "Merging", tone: "green", etaAt: eta }, + ], + [ + "merging", + { ...queued, queueStatus: "merging", position: 1 }, + { kind: "merging", label: "Merging", tone: "green", etaAt: eta }, + ], + [ + "merged, waiting for the next deploy", + merged({ state: "waiting", environment: "staging", etaAt: null }), + { kind: "merged", label: "Merged", tone: "green", etaAt: null }, + ], + [ + "merged without deploy data", + merged(null), + { kind: "merged", label: "Merged", tone: "green", etaAt: null }, + ], + [ + "deploying", + merged({ state: "deploying", environment: "staging", etaAt: eta }), + { kind: "deploying", label: "Deploying", tone: "green", etaAt: eta }, + ], + [ + "deploy failed", + merged({ state: "failed", environment: "production", etaAt: null }), + { kind: "deploy-failed", label: "Deploy Failed", tone: "red", etaAt: null }, + ], + [ + "on staging", + merged({ state: "staging", environment: "staging", etaAt: null }), + { kind: "on-staging", label: "On Staging", tone: "green", etaAt: null }, + ], + [ + "shipped to production", + merged({ state: "production", environment: "production", etaAt: null }), + { kind: "shipped", label: "Shipped to Prod", tone: "muted", etaAt: null }, + ], + ["closed", { ...open, lifecycle: "closed" }, null], + ["unknown CI outside the queue", { ...open, ciStatus: null }, null], + ])("%s", (_, status, expected) => { + expect(mergebotStatusPill(status)).toEqual(expected); + }); +}); diff --git a/packages/client-runtime/src/mergebotStatusPill.ts b/packages/client-runtime/src/mergebotStatusPill.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca4ac4a217334bd2a766df0008cb481206aa84f2 --- /dev/null +++ b/packages/client-runtime/src/mergebotStatusPill.ts @@ -0,0 +1,88 @@ +import type { MergebotPullRequestLiveStatus } from "@t3tools/contracts"; + +export type MergebotStatusPillTone = "yellow" | "red" | "orange" | "blue" | "green" | "muted"; + +export interface MergebotStatusPill { + readonly kind: + | "running-ci" + | "rebasing" + | "ci-failed" + | "merge-conflicts" + | "draft-passes-ci" + | "ready" + | "queued" + | "merging" + | "merged" + | "deploying" + | "deploy-failed" + | "on-staging" + | "shipped"; + readonly label: string; + readonly tone: MergebotStatusPillTone; + readonly etaAt: string | null; +} + +/** + * One pill for a PR's place in the Mergebot lifecycle, or null when nothing is worth showing. + * Blockers outrank progress: conflicts and failures show even while the queue holds the PR. + */ +export function mergebotStatusPill( + status: MergebotPullRequestLiveStatus, +): MergebotStatusPill | null { + if (status.lifecycle === "closed") return null; + + if (status.lifecycle === "merged") { + switch (status.deploy?.state) { + case "deploying": + return { kind: "deploying", label: "Deploying", tone: "green", etaAt: status.deploy.etaAt }; + case "failed": + return { kind: "deploy-failed", label: "Deploy Failed", tone: "red", etaAt: null }; + case "staging": + return { kind: "on-staging", label: "On Staging", tone: "green", etaAt: null }; + case "production": + return { kind: "shipped", label: "Shipped to Prod", tone: "muted", etaAt: null }; + default: + return { kind: "merged", label: "Merged", tone: "green", etaAt: null }; + } + } + + const merging: MergebotStatusPill = { + kind: "merging", + label: "Merging", + tone: "green", + etaAt: status.mergeEtaAt, + }; + if (status.queueStatus === "merging") return merging; + if (status.hasConflicts === true) + return { kind: "merge-conflicts", label: "Merge Conflicts", tone: "orange", etaAt: null }; + if (status.ciStatus === "failed" || status.queueStatus === "ci_failed") + return { kind: "ci-failed", label: "CI Failed", tone: "red", etaAt: null }; + if (status.queueStatus === "updating_branch") + return { kind: "rebasing", label: "Rebasing", tone: "yellow", etaAt: status.mergeEtaAt }; + + if (status.ciStatus === "pending" || status.queueStatus === "waiting_ci") { + const counts = status.checks?.total + ? ` (${status.checks.completed}/${status.checks.total})` + : ""; + return { + kind: "running-ci", + label: `Running CI${counts}`, + tone: "yellow", + etaAt: status.mergeEtaAt, + }; + } + + if (status.position !== null && status.position >= 2) + return { + kind: "queued", + label: `Queued #${status.position}`, + tone: "blue", + etaAt: status.mergeEtaAt, + }; + if (status.ciStatus !== "passed") return null; + // The head of the queue with passing CI is merging, whatever its queue status says. + if (status.position === 1) return merging; + if (status.draft) + return { kind: "draft-passes-ci", label: "Draft Passes CI", tone: "blue", etaAt: null }; + return { kind: "ready", label: "Ready to merge", tone: "blue", etaAt: null }; +} diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index a5486babea066a7eda2416b561cd71a33b263ab4..3fd014de7f27d8ffb2905cd167b0160592fa856b 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -69,6 +69,9 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeResourceTelemetry | typeof WS_METHODS.pullRequestsSubscribeRefreshes | typeof WS_METHODS.mergebotSubscribe + | typeof WS_METHODS.mergebotSubscribePullRequestStatus + | typeof WS_METHODS.mergebotSubscribeDeployments + | typeof WS_METHODS.jjSubscribeStack | typeof WS_METHODS.subscribeVcsStatus | typeof WS_METHODS.subscribeWorktreeSetup | typeof WS_METHODS.subscribeProjectClones diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index ff4b046a296a55a5587b44e6d33a27eca0872386..9bac75c671fc3c98cc7ecad7f3cb97bdf137d714 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -86,6 +86,40 @@ describe("command permissions", () => { ), ); + it.effect("checks destination source-control grants for Jujutsu and Mergebot actions", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + registry.set( + sessions(env), + AsyncResult.success({ + ...grant(false), + scopes: [AuthSourceControlWriteScope], + permissions: [AuthSourceControlWriteScope], + }), + ); + registry.set(sessions(other), AsyncResult.success(grant(true))); + for (const method of [ + WS_METHODS.jjDescribe, + WS_METHODS.jjSquash, + WS_METHODS.jjPush, + WS_METHODS.mergebotOpenPullRequest, + WS_METHODS.mergebotSetAutoMerge, + WS_METHODS.mergebotUpdatePullRequest, + WS_METHODS.mergebotDeployProduction, + ]) { + const command = createCommandPermissions(runtime, method); + expect(registry.get(command.permissionAtom(env))).toBe(true); + yield* command.authorize(registry, env); + expect(registry.get(command.permissionAtom(other))).toBe(false); + expect((yield* command.authorize(registry, other).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + } + }), + ), + ); + it.effect("checks destination grants before creating a managed workspace", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index f6ba6a8079570a9b6a77965746f4feadf80fdc99..ad60f09edfa2373b0f68908428f109ade4f9b286 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -628,3 +628,21 @@ export function createMergebotQueueAtomFamily( tag: WS_METHODS.mergebotSubscribe, }); } + +export function createMergebotPullRequestStatusAtomFamily( + runtime: Atom.AtomRuntime, +) { + return createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "mergebot:pull-request-status", + tag: WS_METHODS.mergebotSubscribePullRequestStatus, + }); +} + +export function createMergebotDeploymentsAtomFamily( + runtime: Atom.AtomRuntime, +) { + return createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "mergebot:deployments", + tag: WS_METHODS.mergebotSubscribeDeployments, + }); +} diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index bb920df91216fe40f4c5cc4946a4bc8efeab1bb2..ad1b1cdecac47957f17778f6821615558ae58a51 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -23,6 +23,13 @@ export const CLIENT_GUARDED_RPC_SCOPES = { // Endpoint checks exist to configure exports, so they need the same grant as saving them. [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope, [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, + [WS_METHODS.mergebotOpenPullRequest]: AuthSourceControlWriteScope, + [WS_METHODS.mergebotSetAutoMerge]: AuthSourceControlWriteScope, + [WS_METHODS.mergebotUpdatePullRequest]: AuthSourceControlWriteScope, + [WS_METHODS.mergebotDeployProduction]: AuthSourceControlWriteScope, + [WS_METHODS.jjDescribe]: AuthSourceControlWriteScope, + [WS_METHODS.jjSquash]: AuthSourceControlWriteScope, + [WS_METHODS.jjPush]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsUpdateComment]: AuthSourceControlWriteScope, diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 2c97a62ab5eba892cc89caee2089f8dce9e85cc5..aa229abdd346b74ad3cea8b33f7d8f6b66dc8d61 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -225,6 +225,7 @@ const VcsStatusChangeRequest = Schema.Struct({ const VcsStatusLocalShape = { isRepo: Schema.Boolean, + isJjWorkspace: Schema.optional(Schema.Boolean), sourceControlProvider: Schema.optional(SourceControlProviderInfo), hasPrimaryRemote: Schema.Boolean, isDefaultRef: Schema.Boolean, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 78ebcfeced6158c5b2b64e93cecb497eadf60d2b..2adc80bcf27f43a2d6fa2f22a34427fb2a0f1c69 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -68,6 +68,7 @@ export * from "./clientRpcPermissions.ts"; export * from "./devServer.ts"; export * from "./managedWorkspaces.ts"; export * from "./mergebot.ts"; +export * from "./jj.ts"; export * from "./scriptProgress.ts"; export * from "./desktopTerminal.ts"; diff --git a/packages/contracts/src/jj.test.ts b/packages/contracts/src/jj.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4fa4faa1a68c1847ee5f33281eebf818cee90ac --- /dev/null +++ b/packages/contracts/src/jj.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { commitMessageToEditor, editorToCommitMessage } from "./jj.ts"; + +describe("commit message editor form", () => { + it.each([ + ["title only", "feat: title", "feat: title"], + ["title and body", "feat: title\n\nbody", "feat: title\nbody"], + ["body with blank lines", "fix: x\n\none\n\ntwo\n\n\nthree", "fix: x\none\n\ntwo\n\n\nthree"], + [ + "trailers", + "feat: x\n\nbody\n\nAssisted-by: claude-opus-5.5\nAssisted-by: gpt-6.1-sol", + "feat: x\nbody\n\nAssisted-by: claude-opus-5.5\nAssisted-by: gpt-6.1-sol", + ], + ["empty", "", ""], + ])("round-trips %s", (_, description, editor) => { + expect(commitMessageToEditor(description)).toBe(editor); + expect(editorToCommitMessage(editor)).toBe(description); + }); + + it("drops trailing newlines and whitespace", () => { + expect(commitMessageToEditor("feat: title\n\nbody\n")).toBe("feat: title\nbody"); + expect(editorToCommitMessage("feat: title\nbody \n\n")).toBe("feat: title\n\nbody"); + expect(editorToCommitMessage("feat: title\n")).toBe("feat: title"); + }); + + it("collapses blank lines typed between title and body into one separator", () => { + expect(editorToCommitMessage("feat: title\n\n\nbody")).toBe("feat: title\n\nbody"); + }); + + it("keeps a body whose title is empty", () => { + expect(editorToCommitMessage("\nbody")).toBe("\n\nbody"); + expect(commitMessageToEditor("\n\nbody")).toBe("\nbody"); + }); +}); diff --git a/packages/contracts/src/jj.ts b/packages/contracts/src/jj.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5db214383ee2870e5f34c083b2723244159f967 --- /dev/null +++ b/packages/contracts/src/jj.ts @@ -0,0 +1,131 @@ +import * as Schema from "effect/Schema"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ReviewDiffPreviewSource } from "./review.ts"; + +export const JjStackInput = Schema.Struct({ cwd: TrimmedNonEmptyString }); +export type JjStackInput = typeof JjStackInput.Type; + +export const JjTrailer = Schema.Struct({ key: Schema.String, value: Schema.String }); +export type JjTrailer = typeof JjTrailer.Type; + +export const JjBookmark = Schema.Struct({ + name: TrimmedNonEmptyString, + /** True when a tracked remote bookmark points at this same commit. */ + pushed: Schema.Boolean, +}); +export type JjBookmark = typeof JjBookmark.Type; + +export const JjChange = Schema.Struct({ + changeId: TrimmedNonEmptyString, + /** Shortest unique change-id prefix, at least four characters. */ + shortId: TrimmedNonEmptyString, + commitId: TrimmedNonEmptyString, + parentCommitIds: Schema.Array(TrimmedNonEmptyString), + description: Schema.String, + trailers: Schema.Array(JjTrailer), + bookmarks: Schema.Array(JjBookmark), + immutable: Schema.Boolean, + conflict: Schema.Boolean, + empty: Schema.Boolean, + workingCopy: Schema.Boolean, + additions: Schema.Number, + deletions: Schema.Number, + authoredAt: Schema.String, +}); +export type JjChange = typeof JjChange.Type; + +export const JjStack = Schema.Struct({ + workspaceRoot: TrimmedNonEmptyString, + /** `owner/name` of the GitHub push remote, when there is one. */ + repository: Schema.NullOr(Schema.String), + trunk: Schema.NullOr( + Schema.Struct({ + name: Schema.String, + commitId: TrimmedNonEmptyString, + /** Trunk commits the stack's ancestry lacks. */ + behind: Schema.Number, + }), + ), + /** Newest first. Empty changes are omitted, except the working copy. */ + changes: Schema.Array(JjChange), +}); +export type JjStack = typeof JjStack.Type; + +export const JjDiffInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + fromCommitId: TrimmedNonEmptyString, + toCommitId: TrimmedNonEmptyString, + ignoreWhitespace: Schema.optionalKey(Schema.Boolean), +}); +export type JjDiffInput = typeof JjDiffInput.Type; + +export const JjDiffResult = ReviewDiffPreviewSource; +export type JjDiffResult = typeof JjDiffResult.Type; + +export const JjDescribeInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + changeId: TrimmedNonEmptyString, + /** The commit the editor loaded; a concurrent rewrite with a different message is refused. */ + expectedCommitId: TrimmedNonEmptyString, + message: Schema.String, +}); +export type JjDescribeInput = typeof JjDescribeInput.Type; + +export const JjChangeInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + changeId: TrimmedNonEmptyString, +}); +export type JjChangeInput = typeof JjChangeInput.Type; + +export const JjPushInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + /** The newest change the pushed bookmark should point at. */ + changeId: TrimmedNonEmptyString, + /** Move and push this bookmark; omitted creates one from the repository's push-bookmark template. */ + bookmark: Schema.optionalKey(TrimmedNonEmptyString), + /** The change whose description names a new bookmark, usually the PR's first change. */ + nameFrom: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type JjPushInput = typeof JjPushInput.Type; + +export const JjPushResult = Schema.Struct({ + bookmark: TrimmedNonEmptyString, + remote: Schema.String, +}); +export type JjPushResult = typeof JjPushResult.Type; + +export class JjError extends Schema.TaggedError()("JjError", { + operation: Schema.String, + detail: Schema.String, +}) { + override get message(): string { + return `jj ${this.operation} failed: ${this.detail}`; + } +} + +/** Another writer changed the description after the editor loaded it. */ +export class JjDescriptionConflictError extends Schema.TaggedError()( + "JjDescriptionConflictError", + { changeId: Schema.String, description: Schema.String, commitId: Schema.String }, +) { + override get message(): string { + return "This change's description was edited elsewhere."; + } +} + +/** Splits jj's `title\n\nbody` into what the editor shows: title on line 1, body from line 2. */ +export function commitMessageToEditor(description: string): string { + const trimmed = description.replace(/\n+$/, ""); + const newline = trimmed.indexOf("\n"); + if (newline === -1) return trimmed; + return `${trimmed.slice(0, newline)}\n${trimmed.slice(newline + 1).replace(/^\n/, "")}`; +} + +/** Inverse of `commitMessageToEditor`: restores the blank line between title and body. */ +export function editorToCommitMessage(text: string): string { + const trimmed = text.replace(/\s+$/, ""); + const newline = trimmed.indexOf("\n"); + if (newline === -1) return trimmed; + const body = trimmed.slice(newline + 1).replace(/^\n+/, ""); + return body ? `${trimmed.slice(0, newline)}\n\n${body}` : trimmed.slice(0, newline); +} diff --git a/packages/contracts/src/mergebot.ts b/packages/contracts/src/mergebot.ts index 564ad0cfdba6af0e897b32849571ba6aa65cb1e9..c790e6870c2ee2abbc7147eaadba501e8b9c7041 100644 --- a/packages/contracts/src/mergebot.ts +++ b/packages/contracts/src/mergebot.ts @@ -154,3 +154,120 @@ export function parseMergebotPullRequestUrl( ? { host: "github.com", repository, number } : null; } + +export const MergebotOpenPullRequestInput = Schema.Struct({ + repository: TrimmedNonEmptyString, + head: TrimmedNonEmptyString, + base: Schema.optionalKey(TrimmedNonEmptyString), + title: TrimmedNonEmptyString, + body: Schema.String, + draft: Schema.Boolean, + autoMerge: Schema.Boolean, +}); +export type MergebotOpenPullRequestInput = typeof MergebotOpenPullRequestInput.Type; + +export const MergebotOpenPullRequestResult = Schema.Struct({ + number: PositiveInt, + url: Schema.String, +}); +export type MergebotOpenPullRequestResult = typeof MergebotOpenPullRequestResult.Type; + +export const MergebotSetAutoMergeInput = Schema.Struct({ + repository: TrimmedNonEmptyString, + number: PositiveInt, + enabled: Schema.Boolean, +}); +export type MergebotSetAutoMergeInput = typeof MergebotSetAutoMergeInput.Type; + +export const MergebotUpdatePullRequestInput = Schema.Struct({ + repository: TrimmedNonEmptyString, + number: PositiveInt, + title: Schema.optionalKey(TrimmedNonEmptyString), + body: Schema.optionalKey(Schema.String), + draft: Schema.optionalKey(Schema.Boolean), +}); +export type MergebotUpdatePullRequestInput = typeof MergebotUpdatePullRequestInput.Type; + +export class MergebotOperationError extends Schema.TaggedError()( + "MergebotOperationError", + { operation: Schema.String, status: Schema.NullOr(Schema.Number), detail: Schema.String }, +) { + override get message(): string { + return `Mergebot could not ${this.operation}: ${this.detail}`; + } +} + +/** Everything a status pill needs for one PR, composed from Mergebot's overview, detail, and deploy data. */ +export const MergebotPullRequestLiveStatus = Schema.Struct({ + number: PositiveInt, + url: Schema.String, + title: Schema.String, + body: Schema.NullOr(Schema.String), + headSha: Schema.String, + headBranch: Schema.String, + lifecycle: Schema.Literals(["open", "closed", "merged"]), + draft: Schema.Boolean, + autoMerge: Schema.Boolean, + ciStatus: Schema.NullOr(Schema.Literals(["passed", "pending", "failed", "unknown"])), + /** From the detail channel; null until it loads. */ + checks: Schema.NullOr( + Schema.Struct({ completed: Schema.Number, total: Schema.Number, failed: Schema.Number }), + ), + hasConflicts: Schema.NullOr(Schema.Boolean), + queueStatus: MergebotPullRequestStatus.fields.queueStatus, + position: Schema.NullOr(PositiveInt), + mergeEtaAt: Schema.NullOr(Schema.String), + deploy: Schema.NullOr( + Schema.Struct({ + state: Schema.Literals(["waiting", "deploying", "staging", "production", "failed"]), + environment: Schema.NullOr(Schema.Literals(["staging", "production"])), + etaAt: Schema.NullOr(Schema.String), + }), + ), +}); +export type MergebotPullRequestLiveStatus = typeof MergebotPullRequestLiveStatus.Type; + +export const MergebotDeploymentEnvironment = Schema.Struct({ + sha: Schema.String, + deployedSha: Schema.NullOr(Schema.String), + status: Schema.Literals(["passed", "pending", "failed", "unknown"]), + currentStep: Schema.NullOr(Schema.String), + etaAt: Schema.NullOr(Schema.String), + checksUrl: Schema.String, +}); +export type MergebotDeploymentEnvironment = typeof MergebotDeploymentEnvironment.Type; + +export const MergebotMergedCommit = Schema.Struct({ + sha: Schema.String, + pullRequestNumber: Schema.NullOr(PositiveInt), + authorLogin: Schema.NullOr(Schema.String), + authorName: Schema.NullOr(Schema.String), + authorAvatarUrl: Schema.NullOr(Schema.String), + message: Schema.String, + mergedAt: Schema.String, + url: Schema.String, + labels: Schema.Array(PullRequestLabel), + status: Schema.Literals(["default", "recent", "hotfix"]), + isProductionHead: Schema.Boolean, +}); +export type MergebotMergedCommit = typeof MergebotMergedCommit.Type; + +export const MergebotDeployments = Schema.Struct({ + repository: TrimmedNonEmptyString, + url: Schema.String, + defaultBranch: Schema.String, + productionBranch: Schema.NullOr(Schema.String), + stagingUrl: Schema.NullOr(Schema.String), + productionUrl: Schema.NullOr(Schema.String), + staging: Schema.NullOr(MergebotDeploymentEnvironment), + production: Schema.NullOr(MergebotDeploymentEnvironment), + /** Ordered by default-branch history, newest first. */ + mergedCommits: Schema.Array(MergebotMergedCommit), +}); +export type MergebotDeployments = typeof MergebotDeployments.Type; + +export const MergebotDeployProductionInput = Schema.Struct({ + repository: TrimmedNonEmptyString, + sha: TrimmedNonEmptyString, +}); +export type MergebotDeployProductionInput = typeof MergebotDeployProductionInput.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f0596e1466dd2945650791d2af900955928bf102..7710c5a96f223b667dcfef58e4d76cd48021a6e5 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -16,7 +16,31 @@ import { ManagedWorkspaceArchiveInput, ManagedWorkspaceArchived, } from "./managedWorkspaces.ts"; -import { MergebotQueueInput, MergebotRepoQueue } from "./mergebot.ts"; +import { + MergebotDeployProductionInput, + MergebotDeployments, + MergebotOpenPullRequestInput, + MergebotOpenPullRequestResult, + MergebotOperationError, + MergebotPullRequestInput, + MergebotPullRequestLiveStatus, + MergebotQueueInput, + MergebotRepoQueue, + MergebotSetAutoMergeInput, + MergebotUpdatePullRequestInput, +} from "./mergebot.ts"; +import { + JjChangeInput, + JjDescribeInput, + JjDescriptionConflictError, + JjDiffInput, + JjDiffResult, + JjError, + JjPushInput, + JjPushResult, + JjStack, + JjStackInput, +} from "./jj.ts"; import { DevServerTarget, DevServerStatus, DevServerError } from "./devServer.ts"; import { OrchestrationV2SearchThreadError, @@ -560,6 +584,18 @@ export const WS_METHODS = { // Pull request methods mergebotQueue: "mergebot.queue", mergebotSubscribe: "mergebot.subscribe", + mergebotOpenPullRequest: "mergebot.openPullRequest", + mergebotSetAutoMerge: "mergebot.setAutoMerge", + mergebotUpdatePullRequest: "mergebot.updatePullRequest", + mergebotSubscribePullRequestStatus: "mergebot.subscribePullRequestStatus", + mergebotSubscribeDeployments: "mergebot.subscribeDeployments", + mergebotDeployProduction: "mergebot.deployProduction", + jjSubscribeStack: "jj.subscribeStack", + jjSnapshot: "jj.snapshot", + jjDiff: "jj.diff", + jjDescribe: "jj.describe", + jjSquash: "jj.squash", + jjPush: "jj.push", pullRequestsList: "pullRequests.list", pullRequestsListStats: "pullRequests.listStats", pullRequestsSummary: "pullRequests.summary", @@ -1019,6 +1055,88 @@ const WsMergebotSubscribeRpc = Rpc.make(WS_METHODS.mergebotSubscribe, { stream: true, }); +const MergebotRpcError = Schema.Union([MergebotOperationError, EnvironmentAuthorizationError]); + +const WsMergebotOpenPullRequestRpc = Rpc.make(WS_METHODS.mergebotOpenPullRequest, { + payload: MergebotOpenPullRequestInput, + success: MergebotOpenPullRequestResult, + error: MergebotRpcError, +}); + +const WsMergebotSetAutoMergeRpc = Rpc.make(WS_METHODS.mergebotSetAutoMerge, { + payload: MergebotSetAutoMergeInput, + success: Schema.Void, + error: MergebotRpcError, +}); + +const WsMergebotUpdatePullRequestRpc = Rpc.make(WS_METHODS.mergebotUpdatePullRequest, { + payload: MergebotUpdatePullRequestInput, + success: Schema.Void, + error: MergebotRpcError, +}); + +const WsMergebotSubscribePullRequestStatusRpc = Rpc.make( + WS_METHODS.mergebotSubscribePullRequestStatus, + { + payload: MergebotPullRequestInput, + success: Schema.NullOr(MergebotPullRequestLiveStatus), + error: EnvironmentAuthorizationError, + stream: true, + }, +); + +const WsMergebotSubscribeDeploymentsRpc = Rpc.make(WS_METHODS.mergebotSubscribeDeployments, { + payload: MergebotQueueInput, + success: Schema.NullOr(MergebotDeployments), + error: EnvironmentAuthorizationError, + stream: true, +}); + +const WsMergebotDeployProductionRpc = Rpc.make(WS_METHODS.mergebotDeployProduction, { + payload: MergebotDeployProductionInput, + success: Schema.Void, + error: MergebotRpcError, +}); + +const JjRpcError = Schema.Union([JjError, EnvironmentAuthorizationError]); + +const WsJjSubscribeStackRpc = Rpc.make(WS_METHODS.jjSubscribeStack, { + payload: JjStackInput, + success: Schema.NullOr(JjStack), + error: JjRpcError, + stream: true, +}); + +const WsJjSnapshotRpc = Rpc.make(WS_METHODS.jjSnapshot, { + payload: JjStackInput, + success: Schema.Void, + error: JjRpcError, +}); + +const WsJjDiffRpc = Rpc.make(WS_METHODS.jjDiff, { + payload: JjDiffInput, + success: JjDiffResult, + error: JjRpcError, +}); + +const WsJjDescribeRpc = Rpc.make(WS_METHODS.jjDescribe, { + payload: JjDescribeInput, + success: Schema.Struct({ commitId: Schema.String }), + error: Schema.Union([JjError, JjDescriptionConflictError, EnvironmentAuthorizationError]), +}); + +const WsJjSquashRpc = Rpc.make(WS_METHODS.jjSquash, { + payload: JjChangeInput, + success: Schema.Void, + error: JjRpcError, +}); + +const WsJjPushRpc = Rpc.make(WS_METHODS.jjPush, { + payload: JjPushInput, + success: JjPushResult, + error: JjRpcError, +}); + const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, { payload: PullRequestListInput, success: PullRequestListResult, @@ -2066,6 +2184,18 @@ export const WsRpcGroup = RpcGroup.make( WsCloudInstallRelayClientRpc, WsMergebotQueueRpc, WsMergebotSubscribeRpc, + WsMergebotOpenPullRequestRpc, + WsMergebotSetAutoMergeRpc, + WsMergebotUpdatePullRequestRpc, + WsMergebotSubscribePullRequestStatusRpc, + WsMergebotSubscribeDeploymentsRpc, + WsMergebotDeployProductionRpc, + WsJjSubscribeStackRpc, + WsJjSnapshotRpc, + WsJjDiffRpc, + WsJjDescribeRpc, + WsJjSquashRpc, + WsJjPushRpc, WsPullRequestsListRpc, WsPullRequestsListStatsRpc, WsPullRequestsSummaryRpc, diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 7ca2678d4c42d5806baad745e21cb1f14fabf0bd..7d0102ce361a727d7f251ec08f6f43733ea49500 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -259,6 +259,7 @@ describe("applyGitStatusStreamEvent", () => { it("preserves local-only fields when applying a remote update", () => { const current: VcsStatusResult = { isRepo: true, + isJjWorkspace: true, sourceControlProvider: { kind: SourceControlProviderKind.make("github"), name: "GitHub", diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 3d9af4b7ed8f88a7d5ea4dc1f44917b16764a5db..2c9fb75ee8adec866309340cff8105398106b7c1 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -388,6 +388,7 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult { function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult { return { isRepo: status.isRepo, + ...(status.isJjWorkspace === undefined ? {} : { isJjWorkspace: status.isJjWorkspace }), ...(status.sourceControlProvider ? { sourceControlProvider: status.sourceControlProvider } : {}), -- 2.54.0