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