| author | |
| committer | |
| log | 3d3586800ebfbefb97a4a782dae3439e90ba9065 |
| tree | be2f3d4c0a39131ec3da574118f3d203471d6626 |
| parent | 767de271e972b38de0633c04efcd62a929790b3e |
- Add git APIs to resolve PR refs and prepare PR thread workspaces
- Support fetching PR head refs into local branches and reusing existing worktrees
- Wire new websocket/contracts/web composer flows for PR-based thread creation
- Expand server/web/contracts tests for PR routing and branch/worktree behavior27 files changed, 1545 insertions(+), 75 deletions(-)
apps/server/src/git/Layers/GitCore.test.ts+63| ... | ... | @@ -105,6 +105,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => |
| 105 | 105 | readConfigValue: (cwd, key) => core.readConfigValue(cwd, key), |
| 106 | 106 | listBranches: (input) => core.listBranches(input), |
| 107 | 107 | createWorktree: (input) => core.createWorktree(input), |
| 108 | fetchPullRequestBranch: (input) => core.fetchPullRequestBranch(input), | |
| 108 | 109 | removeWorktree: (input) => core.removeWorktree(input), |
| 109 | 110 | renameBranch: (input) => core.renameBranch(input), |
| 110 | 111 | createBranch: (input) => core.createBranch(input), |
| ... | ... | @@ -149,6 +150,15 @@ function createGitWorktree(input: Parameters<GitCoreShape["createWorktree"]>[0]) |
| 149 | 150 | }); |
| 150 | 151 | } |
| 151 | 152 | |
| 153 | function fetchGitPullRequestBranch( | |
| 154 | input: Parameters<GitCoreShape["fetchPullRequestBranch"]>[0], | |
| 155 | ) { | |
| 156 | return Effect.gen(function* () { | |
| 157 | const core = yield* GitCore; | |
| 158 | return yield* core.fetchPullRequestBranch(input); | |
| 159 | }); | |
| 160 | } | |
| 161 | ||
| 152 | 162 | function removeGitWorktree(input: Parameters<GitCoreShape["removeWorktree"]>[0]) { |
| 153 | 163 | return Effect.gen(function* () { |
| 154 | 164 | const core = yield* GitCore; |
| ... | ... | @@ -1006,6 +1016,28 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1006 | 1016 | }), |
| 1007 | 1017 | ); |
| 1008 | 1018 | |
| 1019 | it.effect("creates a worktree for an existing branch when newBranch is omitted", () => | |
| 1020 | Effect.gen(function* () { | |
| 1021 | const tmp = yield* makeTmpDir(); | |
| 1022 | yield* initRepoWithCommit(tmp); | |
| 1023 | yield* createGitBranch({ cwd: tmp, branch: "feature/existing-worktree" }); | |
| 1024 | ||
| 1025 | const wtPath = path.join(tmp, "wt-existing"); | |
| 1026 | const result = yield* createGitWorktree({ | |
| 1027 | cwd: tmp, | |
| 1028 | branch: "feature/existing-worktree", | |
| 1029 | path: wtPath, | |
| 1030 | }); | |
| 1031 | ||
| 1032 | expect(result.worktree.path).toBe(wtPath); | |
| 1033 | expect(result.worktree.branch).toBe("feature/existing-worktree"); | |
| 1034 | const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); | |
| 1035 | expect(branchOutput).toBe("feature/existing-worktree"); | |
| 1036 | ||
| 1037 | yield* removeGitWorktree({ cwd: tmp, path: wtPath }); | |
| 1038 | }), | |
| 1039 | ); | |
| 1040 | ||
| 1009 | 1041 | it.effect("throws when new branch name already exists", () => |
| 1010 | 1042 | Effect.gen(function* () { |
| 1011 | 1043 | const tmp = yield* makeTmpDir(); |
| ... | ... | @@ -1168,6 +1200,37 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1168 | 1200 | ); |
| 1169 | 1201 | }); |
| 1170 | 1202 | |
| 1203 | describe("fetchPullRequestBranch", () => { | |
| 1204 | it.effect("fetches a GitHub pull request ref into a local branch without checkout", () => | |
| 1205 | Effect.gen(function* () { | |
| 1206 | const tmp = yield* makeTmpDir(); | |
| 1207 | yield* initRepoWithCommit(tmp); | |
| 1208 | const remoteDir = yield* makeTmpDir("git-remote-"); | |
| 1209 | yield* git(remoteDir, ["init", "--bare"]); | |
| 1210 | yield* git(tmp, ["remote", "add", "origin", remoteDir]); | |
| 1211 | yield* git(tmp, ["push", "-u", "origin", "main"]); | |
| 1212 | yield* git(tmp, ["checkout", "-b", "feature/pr-fetch"]); | |
| 1213 | yield* writeTextFile(path.join(tmp, "pr-fetch.txt"), "fetch me\n"); | |
| 1214 | yield* git(tmp, ["add", "pr-fetch.txt"]); | |
| 1215 | yield* git(tmp, ["commit", "-m", "Add PR fetch branch"]); | |
| 1216 | yield* git(tmp, ["push", "-u", "origin", "feature/pr-fetch"]); | |
| 1217 | yield* git(tmp, ["push", "origin", "HEAD:refs/pull/55/head"]); | |
| 1218 | yield* git(tmp, ["checkout", "main"]); | |
| 1219 | ||
| 1220 | yield* fetchGitPullRequestBranch({ | |
| 1221 | cwd: tmp, | |
| 1222 | prNumber: 55, | |
| 1223 | branch: "feature/pr-fetch", | |
| 1224 | }); | |
| 1225 | ||
| 1226 | const localBranches = yield* git(tmp, ["branch", "--list", "feature/pr-fetch"]); | |
| 1227 | expect(localBranches).toContain("feature/pr-fetch"); | |
| 1228 | const currentBranch = yield* git(tmp, ["branch", "--show-current"]); | |
| 1229 | expect(currentBranch).toBe("main"); | |
| 1230 | }), | |
| 1231 | ); | |
| 1232 | }); | |
| 1233 | ||
| 1171 | 1234 | // ── Full flow: thread switching simulation ── |
| 1172 | 1235 | |
| 1173 | 1236 | describe("full flow: thread switching (checkout toggling)", () => { |
apps/server/src/git/Layers/GitCore.ts+50-3| ... | ... | @@ -418,6 +418,29 @@ const makeGitCore = Effect.gen(function* () { |
| 418 | 418 | allowNonZeroExit: true, |
| 419 | 419 | }).pipe(Effect.map((result) => result.code === 0)); |
| 420 | 420 | |
| 421 | const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> => | |
| 422 | runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( | |
| 423 | Effect.map((stdout) => parseRemoteNames(stdout).toReversed()), | |
| 424 | ); | |
| 425 | ||
| 426 | const resolvePrimaryRemoteName = (cwd: string): Effect.Effect<string, GitCommandError> => | |
| 427 | Effect.gen(function* () { | |
| 428 | if (yield* originRemoteExists(cwd)) { | |
| 429 | return "origin"; | |
| 430 | } | |
| 431 | const remotes = yield* listRemoteNames(cwd); | |
| 432 | const [firstRemote] = remotes; | |
| 433 | if (firstRemote) { | |
| 434 | return firstRemote; | |
| 435 | } | |
| 436 | return yield* createGitCommandError( | |
| 437 | "GitCore.resolvePrimaryRemoteName", | |
| 438 | cwd, | |
| 439 | ["remote"], | |
| 440 | "No git remote is configured for this repository.", | |
| 441 | ); | |
| 442 | }); | |
| 443 | ||
| 421 | 444 | const resolveBaseBranchForNoUpstream = ( |
| 422 | 445 | cwd: string, |
| 423 | 446 | branch: string, |
| ... | ... | @@ -1015,16 +1038,20 @@ const makeGitCore = Effect.gen(function* () { |
| 1015 | 1038 | |
| 1016 | 1039 | const createWorktree: GitCoreShape["createWorktree"] = (input) => |
| 1017 | 1040 | Effect.gen(function* () { |
| 1018 | const sanitizedBranch = input.newBranch.replace(/\//g, "-"); | |
| 1041 | const targetBranch = input.newBranch ?? input.branch; | |
| 1042 | const sanitizedBranch = targetBranch.replace(/\//g, "-"); | |
| 1019 | 1043 | const repoName = path.basename(input.cwd); |
| 1020 | 1044 | const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp"; |
| 1021 | 1045 | const worktreePath = |
| 1022 | 1046 | input.path ?? path.join(homeDir, ".t3", "worktrees", repoName, sanitizedBranch); |
| 1047 | const args = input.newBranch | |
| 1048 | ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch] | |
| 1049 | : ["worktree", "add", worktreePath, input.branch]; | |
| 1023 | 1050 | |
| 1024 | 1051 | yield* executeGit( |
| 1025 | 1052 | "GitCore.createWorktree", |
| 1026 | 1053 | input.cwd, |
| 1027 | ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch], | |
| 1054 | args, | |
| 1028 | 1055 | { |
| 1029 | 1056 | fallbackErrorMessage: "git worktree add failed", |
| 1030 | 1057 | }, |
| ... | ... | @@ -1033,11 +1060,30 @@ const makeGitCore = Effect.gen(function* () { |
| 1033 | 1060 | return { |
| 1034 | 1061 | worktree: { |
| 1035 | 1062 | path: worktreePath, |
| 1036 | branch: input.newBranch, | |
| 1063 | branch: targetBranch, | |
| 1037 | 1064 | }, |
| 1038 | 1065 | }; |
| 1039 | 1066 | }); |
| 1040 | 1067 | |
| 1068 | const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = (input) => | |
| 1069 | Effect.gen(function* () { | |
| 1070 | const remoteName = yield* resolvePrimaryRemoteName(input.cwd); | |
| 1071 | yield* executeGit( | |
| 1072 | "GitCore.fetchPullRequestBranch", | |
| 1073 | input.cwd, | |
| 1074 | [ | |
| 1075 | "fetch", | |
| 1076 | "--quiet", | |
| 1077 | "--no-tags", | |
| 1078 | remoteName, | |
| 1079 | `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, | |
| 1080 | ], | |
| 1081 | { | |
| 1082 | fallbackErrorMessage: "git fetch pull request branch failed", | |
| 1083 | }, | |
| 1084 | ); | |
| 1085 | }).pipe(Effect.asVoid); | |
| 1086 | ||
| 1041 | 1087 | const removeWorktree: GitCoreShape["removeWorktree"] = (input) => |
| 1042 | 1088 | Effect.gen(function* () { |
| 1043 | 1089 | const args = ["worktree", "remove"]; |
| ... | ... | @@ -1197,6 +1243,7 @@ const makeGitCore = Effect.gen(function* () { |
| 1197 | 1243 | readConfigValue, |
| 1198 | 1244 | listBranches, |
| 1199 | 1245 | createWorktree, |
| 1246 | fetchPullRequestBranch, | |
| 1200 | 1247 | removeWorktree, |
| 1201 | 1248 | renameBranch, |
| 1202 | 1249 | createBranch, |
apps/server/src/git/Layers/GitHubCli.test.ts created+76| ... | ... | @@ -0,0 +1,76 @@ |
| 1 | import { describe, expect, it, vi } from "vitest"; | |
| 2 | import { Effect } from "effect"; | |
| 3 | ||
| 4 | vi.mock("../../processRunner", () => ({ | |
| 5 | runProcess: vi.fn(), | |
| 6 | })); | |
| 7 | ||
| 8 | import { runProcess } from "../../processRunner"; | |
| 9 | import { GitHubCli } from "../Services/GitHubCli.ts"; | |
| 10 | import { GitHubCliLive } from "./GitHubCli.ts"; | |
| 11 | ||
| 12 | const mockedRunProcess = vi.mocked(runProcess); | |
| 13 | ||
| 14 | describe("GitHubCliLive", () => { | |
| 15 | it("parses pull request view output", async () => { | |
| 16 | mockedRunProcess.mockResolvedValueOnce({ | |
| 17 | stdout: JSON.stringify({ | |
| 18 | number: 42, | |
| 19 | title: "Add PR thread creation", | |
| 20 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | |
| 21 | baseRefName: "main", | |
| 22 | headRefName: "feature/pr-threads", | |
| 23 | state: "OPEN", | |
| 24 | mergedAt: null, | |
| 25 | }), | |
| 26 | stderr: "", | |
| 27 | code: 0, | |
| 28 | signal: null, | |
| 29 | timedOut: false, | |
| 30 | }); | |
| 31 | ||
| 32 | const result = await Effect.runPromise( | |
| 33 | Effect.gen(function* () { | |
| 34 | const gh = yield* GitHubCli; | |
| 35 | return yield* gh.getPullRequest({ | |
| 36 | cwd: "/repo", | |
| 37 | reference: "#42", | |
| 38 | }); | |
| 39 | }).pipe(Effect.provide(GitHubCliLive)), | |
| 40 | ); | |
| 41 | ||
| 42 | expect(result).toEqual({ | |
| 43 | number: 42, | |
| 44 | title: "Add PR thread creation", | |
| 45 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | |
| 46 | baseRefName: "main", | |
| 47 | headRefName: "feature/pr-threads", | |
| 48 | state: "open", | |
| 49 | }); | |
| 50 | expect(mockedRunProcess).toHaveBeenCalledWith( | |
| 51 | "gh", | |
| 52 | ["pr", "view", "#42", "--json", "number,title,url,baseRefName,headRefName,state,mergedAt"], | |
| 53 | expect.objectContaining({ cwd: "/repo" }), | |
| 54 | ); | |
| 55 | }); | |
| 56 | ||
| 57 | it("surfaces a friendly error when the pull request is not found", async () => { | |
| 58 | mockedRunProcess.mockRejectedValueOnce( | |
| 59 | new Error( | |
| 60 | "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", | |
| 61 | ), | |
| 62 | ); | |
| 63 | ||
| 64 | const error = await Effect.runPromise( | |
| 65 | Effect.gen(function* () { | |
| 66 | const gh = yield* GitHubCli; | |
| 67 | return yield* gh.getPullRequest({ | |
| 68 | cwd: "/repo", | |
| 69 | reference: "4888", | |
| 70 | }); | |
| 71 | }).pipe(Effect.provide(GitHubCliLive), Effect.flip), | |
| 72 | ); | |
| 73 | ||
| 74 | expect(error.message).toContain("Pull request not found"); | |
| 75 | }); | |
| 76 | }); |
apps/server/src/git/Layers/GitHubCli.ts+112-41| ... | ... | @@ -2,7 +2,11 @@ import { Effect, Layer } from "effect"; |
| 2 | 2 | |
| 3 | 3 | import { runProcess } from "../../processRunner"; |
| 4 | 4 | import { GitHubCliError } from "../Errors.ts"; |
| 5 | import { GitHubCli, type GitHubCliShape } from "../Services/GitHubCli.ts"; | |
| 5 | import { | |
| 6 | GitHubCli, | |
| 7 | type GitHubCliShape, | |
| 8 | type GitHubPullRequestSummary, | |
| 9 | } from "../Services/GitHubCli.ts"; | |
| 6 | 10 | |
| 7 | 11 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 8 | 12 | |
| ... | ... | @@ -30,6 +34,19 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown |
| 30 | 34 | }); |
| 31 | 35 | } |
| 32 | 36 | |
| 37 | if ( | |
| 38 | lower.includes("could not resolve to a pullrequest") || | |
| 39 | lower.includes("repository.pullrequest") || | |
| 40 | lower.includes("no pull requests found for branch") || | |
| 41 | lower.includes("pull request not found") | |
| 42 | ) { | |
| 43 | return new GitHubCliError({ | |
| 44 | operation, | |
| 45 | detail: "Pull request not found. Check the PR number or URL and try again.", | |
| 46 | cause: error, | |
| 47 | }); | |
| 48 | } | |
| 49 | ||
| 33 | 50 | return new GitHubCliError({ |
| 34 | 51 | operation, |
| 35 | 52 | detail: `GitHub CLI command failed: ${error.message}`, |
| ... | ... | @@ -44,13 +61,52 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown |
| 44 | 61 | }); |
| 45 | 62 | } |
| 46 | 63 | |
| 47 | function parseOpenPullRequests(raw: string): ReadonlyArray<{ | |
| 48 | number: number; | |
| 49 | title: string; | |
| 50 | url: string; | |
| 51 | baseRefName: string; | |
| 52 | headRefName: string; | |
| 53 | }> { | |
| 64 | function normalizePullRequestState(record: Record<string, unknown>): "open" | "closed" | "merged" { | |
| 65 | const mergedAt = record.mergedAt; | |
| 66 | const state = record.state; | |
| 67 | if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { | |
| 68 | return "merged"; | |
| 69 | } | |
| 70 | if (state === "CLOSED") { | |
| 71 | return "closed"; | |
| 72 | } | |
| 73 | return "open"; | |
| 74 | } | |
| 75 | ||
| 76 | function parsePullRequestSummary( | |
| 77 | raw: unknown, | |
| 78 | ): GitHubPullRequestSummary | null { | |
| 79 | if (!raw || typeof raw !== "object" || Array.isArray(raw)) { | |
| 80 | return null; | |
| 81 | } | |
| 82 | const record = raw as Record<string, unknown>; | |
| 83 | const number = record.number; | |
| 84 | const title = record.title; | |
| 85 | const url = record.url; | |
| 86 | const baseRefName = record.baseRefName; | |
| 87 | const headRefName = record.headRefName; | |
| 88 | if ( | |
| 89 | typeof number !== "number" || | |
| 90 | !Number.isInteger(number) || | |
| 91 | number <= 0 || | |
| 92 | typeof title !== "string" || | |
| 93 | typeof url !== "string" || | |
| 94 | typeof baseRefName !== "string" || | |
| 95 | typeof headRefName !== "string" | |
| 96 | ) { | |
| 97 | return null; | |
| 98 | } | |
| 99 | return { | |
| 100 | number, | |
| 101 | title, | |
| 102 | url, | |
| 103 | baseRefName, | |
| 104 | headRefName, | |
| 105 | state: normalizePullRequestState(record), | |
| 106 | }; | |
| 107 | } | |
| 108 | ||
| 109 | function parseOpenPullRequests(raw: string): ReadonlyArray<GitHubPullRequestSummary> { | |
| 54 | 110 | const trimmed = raw.trim(); |
| 55 | 111 | if (trimmed.length === 0) return []; |
| 56 | 112 | |
| ... | ... | @@ -59,41 +115,12 @@ function parseOpenPullRequests(raw: string): ReadonlyArray<{ |
| 59 | 115 | throw new Error("GitHub CLI returned non-array JSON."); |
| 60 | 116 | } |
| 61 | 117 | |
| 62 | const result: Array<{ | |
| 63 | number: number; | |
| 64 | title: string; | |
| 65 | url: string; | |
| 66 | baseRefName: string; | |
| 67 | headRefName: string; | |
| 68 | }> = []; | |
| 118 | const result: Array<GitHubPullRequestSummary> = []; | |
| 69 | 119 | for (const entry of parsed) { |
| 70 | if (!entry || typeof entry !== "object") { | |
| 71 | continue; | |
| 120 | const parsedEntry = parsePullRequestSummary(entry); | |
| 121 | if (parsedEntry) { | |
| 122 | result.push(parsedEntry); | |
| 72 | 123 | } |
| 73 | const record = entry as Record<string, unknown>; | |
| 74 | const number = record.number; | |
| 75 | const title = record.title; | |
| 76 | const url = record.url; | |
| 77 | const baseRefName = record.baseRefName; | |
| 78 | const headRefName = record.headRefName; | |
| 79 | if ( | |
| 80 | typeof number !== "number" || | |
| 81 | !Number.isInteger(number) || | |
| 82 | number <= 0 || | |
| 83 | typeof title !== "string" || | |
| 84 | typeof url !== "string" || | |
| 85 | typeof baseRefName !== "string" || | |
| 86 | typeof headRefName !== "string" | |
| 87 | ) { | |
| 88 | continue; | |
| 89 | } | |
| 90 | result.push({ | |
| 91 | number, | |
| 92 | title, | |
| 93 | url, | |
| 94 | baseRefName, | |
| 95 | headRefName, | |
| 96 | }); | |
| 97 | 124 | } |
| 98 | 125 | |
| 99 | 126 | return result; |
| ... | ... | @@ -144,6 +171,40 @@ const makeGitHubCli = Effect.sync(() => { |
| 144 | 171 | }), |
| 145 | 172 | ), |
| 146 | 173 | ), |
| 174 | getPullRequest: (input) => | |
| 175 | execute({ | |
| 176 | cwd: input.cwd, | |
| 177 | args: [ | |
| 178 | "pr", | |
| 179 | "view", | |
| 180 | input.reference, | |
| 181 | "--json", | |
| 182 | "number,title,url,baseRefName,headRefName,state,mergedAt", | |
| 183 | ], | |
| 184 | }).pipe( | |
| 185 | Effect.map((result) => result.stdout.trim()), | |
| 186 | Effect.flatMap((raw) => | |
| 187 | Effect.try({ | |
| 188 | try: () => { | |
| 189 | const parsed = raw.length > 0 ? JSON.parse(raw) : null; | |
| 190 | const summary = parsePullRequestSummary(parsed); | |
| 191 | if (!summary) { | |
| 192 | throw new Error("GitHub CLI returned invalid pull request JSON."); | |
| 193 | } | |
| 194 | return summary; | |
| 195 | }, | |
| 196 | catch: (error: unknown) => | |
| 197 | new GitHubCliError({ | |
| 198 | operation: "getPullRequest", | |
| 199 | detail: | |
| 200 | error instanceof Error | |
| 201 | ? `GitHub CLI returned invalid pull request JSON: ${error.message}` | |
| 202 | : "GitHub CLI returned invalid pull request JSON.", | |
| 203 | ...(error !== undefined ? { cause: error } : {}), | |
| 204 | }), | |
| 205 | }), | |
| 206 | ), | |
| 207 | ), | |
| 147 | 208 | createPullRequest: (input) => |
| 148 | 209 | execute({ |
| 149 | 210 | cwd: input.cwd, |
| ... | ... | @@ -170,6 +231,16 @@ const makeGitHubCli = Effect.sync(() => { |
| 170 | 231 | return trimmed.length > 0 ? trimmed : null; |
| 171 | 232 | }), |
| 172 | 233 | ), |
| 234 | checkoutPullRequest: (input) => | |
| 235 | execute({ | |
| 236 | cwd: input.cwd, | |
| 237 | args: [ | |
| 238 | "pr", | |
| 239 | "checkout", | |
| 240 | input.reference, | |
| 241 | ...(input.force ? ["--force"] : []), | |
| 242 | ], | |
| 243 | }).pipe(Effect.asVoid), | |
| 173 | 244 | } satisfies GitHubCliShape; |
| 174 | 245 | |
| 175 | 246 | return service; |
apps/server/src/git/Layers/GitManager.test.ts+247| ... | ... | @@ -23,6 +23,14 @@ interface FakeGhScenario { |
| 23 | 23 | prListSequence?: string[]; |
| 24 | 24 | createdPrUrl?: string; |
| 25 | 25 | defaultBranch?: string; |
| 26 | pullRequest?: { | |
| 27 | number: number; | |
| 28 | title: string; | |
| 29 | url: string; | |
| 30 | baseRefName: string; | |
| 31 | headRefName: string; | |
| 32 | state?: "open" | "closed" | "merged"; | |
| 33 | }; | |
| 26 | 34 | failWith?: GitHubCliError; |
| 27 | 35 | } |
| 28 | 36 | |
| ... | ... | @@ -203,6 +211,26 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 203 | 211 | } |
| 204 | 212 | |
| 205 | 213 | if (args[0] === "pr" && args[1] === "view") { |
| 214 | return Effect.succeed({ | |
| 215 | stdout: | |
| 216 | JSON.stringify( | |
| 217 | scenario.pullRequest ?? { | |
| 218 | number: 101, | |
| 219 | title: "Pull request", | |
| 220 | url: "https://github.com/pingdotgg/codething-mvp/pull/101", | |
| 221 | baseRefName: "main", | |
| 222 | headRefName: "feature/pull-request", | |
| 223 | state: "OPEN", | |
| 224 | }, | |
| 225 | ) + "\n", | |
| 226 | stderr: "", | |
| 227 | code: 0, | |
| 228 | signal: null, | |
| 229 | timedOut: false, | |
| 230 | }); | |
| 231 | } | |
| 232 | ||
| 233 | if (args[0] === "pr" && args[1] === "checkout") { | |
| 206 | 234 | return Effect.succeed({ |
| 207 | 235 | stdout: "", |
| 208 | 236 | stderr: "", |
| ... | ... | @@ -279,6 +307,29 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 279 | 307 | return value.length > 0 ? value : null; |
| 280 | 308 | }), |
| 281 | 309 | ), |
| 310 | getPullRequest: (input) => | |
| 311 | execute({ | |
| 312 | cwd: input.cwd, | |
| 313 | args: [ | |
| 314 | "pr", | |
| 315 | "view", | |
| 316 | input.reference, | |
| 317 | "--json", | |
| 318 | "number,title,url,baseRefName,headRefName,state,mergedAt", | |
| 319 | ], | |
| 320 | }).pipe( | |
| 321 | Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary), | |
| 322 | ), | |
| 323 | checkoutPullRequest: (input) => | |
| 324 | execute({ | |
| 325 | cwd: input.cwd, | |
| 326 | args: [ | |
| 327 | "pr", | |
| 328 | "checkout", | |
| 329 | input.reference, | |
| 330 | ...(input.force ? ["--force"] : []), | |
| 331 | ], | |
| 332 | }).pipe(Effect.asVoid), | |
| 282 | 333 | }, |
| 283 | 334 | ghCalls, |
| 284 | 335 | }; |
| ... | ... | @@ -296,6 +347,17 @@ function runStackedAction( |
| 296 | 347 | return manager.runStackedAction(input); |
| 297 | 348 | } |
| 298 | 349 | |
| 350 | function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) { | |
| 351 | return manager.resolvePullRequest(input); | |
| 352 | } | |
| 353 | ||
| 354 | function preparePullRequestThread( | |
| 355 | manager: GitManagerShape, | |
| 356 | input: { cwd: string; reference: string; mode: "local" | "worktree" }, | |
| 357 | ) { | |
| 358 | return manager.preparePullRequestThread(input); | |
| 359 | } | |
| 360 | ||
| 299 | 361 | function makeManager(input?: { |
| 300 | 362 | ghScenario?: FakeGhScenario; |
| 301 | 363 | textGeneration?: Partial<FakeGitTextGeneration>; |
| ... | ... | @@ -924,4 +986,189 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 924 | 986 | expect(errorMessage).toContain("gh auth login"); |
| 925 | 987 | }), |
| 926 | 988 | ); |
| 989 | ||
| 990 | it.effect("resolves pull requests from #number references", () => | |
| 991 | Effect.gen(function* () { | |
| 992 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 993 | yield* initRepo(repoDir); | |
| 994 | ||
| 995 | const { manager, ghCalls } = yield* makeManager({ | |
| 996 | ghScenario: { | |
| 997 | pullRequest: { | |
| 998 | number: 42, | |
| 999 | title: "Resolve PR", | |
| 1000 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | |
| 1001 | baseRefName: "main", | |
| 1002 | headRefName: "feature/resolve-pr", | |
| 1003 | state: "open", | |
| 1004 | }, | |
| 1005 | }, | |
| 1006 | }); | |
| 1007 | ||
| 1008 | const result = yield* resolvePullRequest(manager, { | |
| 1009 | cwd: repoDir, | |
| 1010 | reference: "#42", | |
| 1011 | }); | |
| 1012 | ||
| 1013 | expect(result.pullRequest).toEqual({ | |
| 1014 | number: 42, | |
| 1015 | title: "Resolve PR", | |
| 1016 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | |
| 1017 | baseBranch: "main", | |
| 1018 | headBranch: "feature/resolve-pr", | |
| 1019 | state: "open", | |
| 1020 | }); | |
| 1021 | expect(ghCalls.some((call) => call.startsWith("pr view 42 "))).toBe(true); | |
| 1022 | }), | |
| 1023 | ); | |
| 1024 | ||
| 1025 | it.effect("prepares pull request threads in local mode by checking out the PR branch", () => | |
| 1026 | Effect.gen(function* () { | |
| 1027 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 1028 | yield* initRepo(repoDir); | |
| 1029 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local"]); | |
| 1030 | fs.writeFileSync(path.join(repoDir, "local.txt"), "local\n"); | |
| 1031 | yield* runGit(repoDir, ["add", "local.txt"]); | |
| 1032 | yield* runGit(repoDir, ["commit", "-m", "Local PR branch"]); | |
| 1033 | ||
| 1034 | const { manager, ghCalls } = yield* makeManager({ | |
| 1035 | ghScenario: { | |
| 1036 | pullRequest: { | |
| 1037 | number: 64, | |
| 1038 | title: "Local PR", | |
| 1039 | url: "https://github.com/pingdotgg/codething-mvp/pull/64", | |
| 1040 | baseRefName: "main", | |
| 1041 | headRefName: "feature/pr-local", | |
| 1042 | state: "open", | |
| 1043 | }, | |
| 1044 | }, | |
| 1045 | }); | |
| 1046 | ||
| 1047 | const result = yield* preparePullRequestThread(manager, { | |
| 1048 | cwd: repoDir, | |
| 1049 | reference: "#64", | |
| 1050 | mode: "local", | |
| 1051 | }); | |
| 1052 | ||
| 1053 | expect(result.branch).toBe("feature/pr-local"); | |
| 1054 | expect(result.worktreePath).toBeNull(); | |
| 1055 | const branch = (yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim(); | |
| 1056 | expect(branch).toBe("feature/pr-local"); | |
| 1057 | expect(ghCalls).toContain("pr checkout 64 --force"); | |
| 1058 | }), | |
| 1059 | ); | |
| 1060 | ||
| 1061 | it.effect("prepares pull request threads in worktree mode on the PR head branch", () => | |
| 1062 | Effect.gen(function* () { | |
| 1063 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 1064 | yield* initRepo(repoDir); | |
| 1065 | const remoteDir = yield* createBareRemote(); | |
| 1066 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 1067 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | |
| 1068 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree"]); | |
| 1069 | fs.writeFileSync(path.join(repoDir, "worktree.txt"), "worktree\n"); | |
| 1070 | yield* runGit(repoDir, ["add", "worktree.txt"]); | |
| 1071 | yield* runGit(repoDir, ["commit", "-m", "PR worktree branch"]); | |
| 1072 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree"]); | |
| 1073 | yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/77/head"]); | |
| 1074 | yield* runGit(repoDir, ["checkout", "main"]); | |
| 1075 | ||
| 1076 | const { manager } = yield* makeManager({ | |
| 1077 | ghScenario: { | |
| 1078 | pullRequest: { | |
| 1079 | number: 77, | |
| 1080 | title: "Worktree PR", | |
| 1081 | url: "https://github.com/pingdotgg/codething-mvp/pull/77", | |
| 1082 | baseRefName: "main", | |
| 1083 | headRefName: "feature/pr-worktree", | |
| 1084 | state: "open", | |
| 1085 | }, | |
| 1086 | }, | |
| 1087 | }); | |
| 1088 | ||
| 1089 | const result = yield* preparePullRequestThread(manager, { | |
| 1090 | cwd: repoDir, | |
| 1091 | reference: "77", | |
| 1092 | mode: "worktree", | |
| 1093 | }); | |
| 1094 | ||
| 1095 | expect(result.branch).toBe("feature/pr-worktree"); | |
| 1096 | expect(result.worktreePath).not.toBeNull(); | |
| 1097 | expect(fs.existsSync(result.worktreePath as string)).toBe(true); | |
| 1098 | const worktreeBranch = ( | |
| 1099 | yield* runGit(result.worktreePath as string, ["branch", "--show-current"]) | |
| 1100 | ).stdout.trim(); | |
| 1101 | expect(worktreeBranch).toBe("feature/pr-worktree"); | |
| 1102 | }), | |
| 1103 | ); | |
| 1104 | ||
| 1105 | it.effect("reuses an existing dedicated worktree for the PR head branch", () => | |
| 1106 | Effect.gen(function* () { | |
| 1107 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 1108 | yield* initRepo(repoDir); | |
| 1109 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-existing-worktree"]); | |
| 1110 | fs.writeFileSync(path.join(repoDir, "existing.txt"), "existing\n"); | |
| 1111 | yield* runGit(repoDir, ["add", "existing.txt"]); | |
| 1112 | yield* runGit(repoDir, ["commit", "-m", "Existing worktree branch"]); | |
| 1113 | yield* runGit(repoDir, ["checkout", "main"]); | |
| 1114 | const worktreePath = path.join(repoDir, "..", `pr-existing-${Date.now()}`); | |
| 1115 | yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]); | |
| 1116 | ||
| 1117 | const { manager } = yield* makeManager({ | |
| 1118 | ghScenario: { | |
| 1119 | pullRequest: { | |
| 1120 | number: 78, | |
| 1121 | title: "Existing worktree PR", | |
| 1122 | url: "https://github.com/pingdotgg/codething-mvp/pull/78", | |
| 1123 | baseRefName: "main", | |
| 1124 | headRefName: "feature/pr-existing-worktree", | |
| 1125 | state: "open", | |
| 1126 | }, | |
| 1127 | }, | |
| 1128 | }); | |
| 1129 | ||
| 1130 | const result = yield* preparePullRequestThread(manager, { | |
| 1131 | cwd: repoDir, | |
| 1132 | reference: "78", | |
| 1133 | mode: "worktree", | |
| 1134 | }); | |
| 1135 | ||
| 1136 | expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( | |
| 1137 | fs.realpathSync.native(worktreePath), | |
| 1138 | ); | |
| 1139 | expect(result.branch).toBe("feature/pr-existing-worktree"); | |
| 1140 | }), | |
| 1141 | ); | |
| 1142 | ||
| 1143 | it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () => | |
| 1144 | Effect.gen(function* () { | |
| 1145 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 1146 | yield* initRepo(repoDir); | |
| 1147 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-root-only"]); | |
| 1148 | ||
| 1149 | const { manager } = yield* makeManager({ | |
| 1150 | ghScenario: { | |
| 1151 | pullRequest: { | |
| 1152 | number: 79, | |
| 1153 | title: "Root-only PR", | |
| 1154 | url: "https://github.com/pingdotgg/codething-mvp/pull/79", | |
| 1155 | baseRefName: "main", | |
| 1156 | headRefName: "feature/pr-root-only", | |
| 1157 | state: "open", | |
| 1158 | }, | |
| 1159 | }, | |
| 1160 | }); | |
| 1161 | ||
| 1162 | const errorMessage = yield* preparePullRequestThread(manager, { | |
| 1163 | cwd: repoDir, | |
| 1164 | reference: "79", | |
| 1165 | mode: "worktree", | |
| 1166 | }).pipe( | |
| 1167 | Effect.flip, | |
| 1168 | Effect.map((error) => error.message), | |
| 1169 | ); | |
| 1170 | ||
| 1171 | expect(errorMessage).toContain("already checked out in the main repo"); | |
| 1172 | }), | |
| 1173 | ); | |
| 927 | 1174 | }); |
apps/server/src/git/Layers/GitManager.ts+153| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { randomUUID } from "node:crypto"; |
| 2 | import { realpathSync } from "node:fs"; | |
| 2 | 3 | |
| 3 | 4 | import { Effect, FileSystem, Layer, Path } from "effect"; |
| 4 | 5 | import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| ... | ... | @@ -22,6 +23,15 @@ interface PullRequestInfo extends OpenPrInfo { |
| 22 | 23 | updatedAt: string | null; |
| 23 | 24 | } |
| 24 | 25 | |
| 26 | interface ResolvedPullRequest { | |
| 27 | number: number; | |
| 28 | title: string; | |
| 29 | url: string; | |
| 30 | baseBranch: string; | |
| 31 | headBranch: string; | |
| 32 | state: "open" | "closed" | "merged"; | |
| 33 | } | |
| 34 | ||
| 25 | 35 | function parsePullRequestList(raw: unknown): PullRequestInfo[] { |
| 26 | 36 | if (!Array.isArray(raw)) return []; |
| 27 | 37 | |
| ... | ... | @@ -175,6 +185,38 @@ function toStatusPr(pr: PullRequestInfo): { |
| 175 | 185 | }; |
| 176 | 186 | } |
| 177 | 187 | |
| 188 | function normalizePullRequestReference(reference: string): string { | |
| 189 | const trimmed = reference.trim(); | |
| 190 | const hashNumber = /^#(\d+)$/.exec(trimmed); | |
| 191 | return hashNumber?.[1] ?? trimmed; | |
| 192 | } | |
| 193 | ||
| 194 | function canonicalizeExistingPath(value: string): string { | |
| 195 | try { | |
| 196 | return realpathSync.native(value); | |
| 197 | } catch { | |
| 198 | return value; | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 202 | function toResolvedPullRequest(pr: { | |
| 203 | number: number; | |
| 204 | title: string; | |
| 205 | url: string; | |
| 206 | baseRefName: string; | |
| 207 | headRefName: string; | |
| 208 | state?: "open" | "closed" | "merged"; | |
| 209 | }): ResolvedPullRequest { | |
| 210 | return { | |
| 211 | number: pr.number, | |
| 212 | title: pr.title, | |
| 213 | url: pr.url, | |
| 214 | baseBranch: pr.baseRefName, | |
| 215 | headBranch: pr.headRefName, | |
| 216 | state: pr.state ?? "open", | |
| 217 | }; | |
| 218 | } | |
| 219 | ||
| 178 | 220 | export const makeGitManager = Effect.gen(function* () { |
| 179 | 221 | const gitCore = yield* GitCore; |
| 180 | 222 | const gitHubCli = yield* GitHubCli; |
| ... | ... | @@ -445,6 +487,115 @@ export const makeGitManager = Effect.gen(function* () { |
| 445 | 487 | }; |
| 446 | 488 | }); |
| 447 | 489 | |
| 490 | const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fnUntraced(function* ( | |
| 491 | input, | |
| 492 | ) { | |
| 493 | const pullRequest = yield* gitHubCli | |
| 494 | .getPullRequest({ | |
| 495 | cwd: input.cwd, | |
| 496 | reference: normalizePullRequestReference(input.reference), | |
| 497 | }) | |
| 498 | .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); | |
| 499 | ||
| 500 | return { pullRequest }; | |
| 501 | }); | |
| 502 | ||
| 503 | const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fnUntraced( | |
| 504 | function* (input) { | |
| 505 | const normalizedReference = normalizePullRequestReference(input.reference); | |
| 506 | const rootWorktreePath = canonicalizeExistingPath(input.cwd); | |
| 507 | const pullRequest = yield* gitHubCli | |
| 508 | .getPullRequest({ | |
| 509 | cwd: input.cwd, | |
| 510 | reference: normalizedReference, | |
| 511 | }) | |
| 512 | .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); | |
| 513 | ||
| 514 | if (input.mode === "local") { | |
| 515 | yield* gitHubCli.checkoutPullRequest({ | |
| 516 | cwd: input.cwd, | |
| 517 | reference: normalizedReference, | |
| 518 | force: true, | |
| 519 | }); | |
| 520 | const details = yield* gitCore.statusDetails(input.cwd); | |
| 521 | return { | |
| 522 | pullRequest, | |
| 523 | branch: details.branch ?? pullRequest.headBranch, | |
| 524 | worktreePath: null, | |
| 525 | }; | |
| 526 | } | |
| 527 | ||
| 528 | const findLocalHeadBranch = (cwd: string) => | |
| 529 | gitCore.listBranches({ cwd }).pipe( | |
| 530 | Effect.map((result) => | |
| 531 | result.branches.find( | |
| 532 | (branch) => !branch.isRemote && branch.name === pullRequest.headBranch, | |
| 533 | ), | |
| 534 | ), | |
| 535 | ); | |
| 536 | ||
| 537 | const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); | |
| 538 | const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath | |
| 539 | ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) | |
| 540 | : null; | |
| 541 | if ( | |
| 542 | existingBranchBeforeFetch?.worktreePath && | |
| 543 | existingBranchBeforeFetchPath !== rootWorktreePath | |
| 544 | ) { | |
| 545 | return { | |
| 546 | pullRequest, | |
| 547 | branch: pullRequest.headBranch, | |
| 548 | worktreePath: existingBranchBeforeFetch.worktreePath, | |
| 549 | }; | |
| 550 | } | |
| 551 | if (existingBranchBeforeFetchPath === rootWorktreePath) { | |
| 552 | return yield* gitManagerError( | |
| 553 | "preparePullRequestThread", | |
| 554 | "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", | |
| 555 | ); | |
| 556 | } | |
| 557 | ||
| 558 | yield* gitCore.fetchPullRequestBranch({ | |
| 559 | cwd: input.cwd, | |
| 560 | prNumber: pullRequest.number, | |
| 561 | branch: pullRequest.headBranch, | |
| 562 | }); | |
| 563 | ||
| 564 | const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); | |
| 565 | const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath | |
| 566 | ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) | |
| 567 | : null; | |
| 568 | if ( | |
| 569 | existingBranchAfterFetch?.worktreePath && | |
| 570 | existingBranchAfterFetchPath !== rootWorktreePath | |
| 571 | ) { | |
| 572 | return { | |
| 573 | pullRequest, | |
| 574 | branch: pullRequest.headBranch, | |
| 575 | worktreePath: existingBranchAfterFetch.worktreePath, | |
| 576 | }; | |
| 577 | } | |
| 578 | if (existingBranchAfterFetchPath === rootWorktreePath) { | |
| 579 | return yield* gitManagerError( | |
| 580 | "preparePullRequestThread", | |
| 581 | "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", | |
| 582 | ); | |
| 583 | } | |
| 584 | ||
| 585 | const worktree = yield* gitCore.createWorktree({ | |
| 586 | cwd: input.cwd, | |
| 587 | branch: pullRequest.headBranch, | |
| 588 | path: null, | |
| 589 | }); | |
| 590 | ||
| 591 | return { | |
| 592 | pullRequest, | |
| 593 | branch: worktree.worktree.branch, | |
| 594 | worktreePath: worktree.worktree.path, | |
| 595 | }; | |
| 596 | }, | |
| 597 | ); | |
| 598 | ||
| 448 | 599 | const runFeatureBranchStep = (cwd: string, branch: string | null, commitMessage?: string) => |
| 449 | 600 | Effect.gen(function* () { |
| 450 | 601 | const suggestion = yield* resolveCommitAndBranchSuggestion({ |
| ... | ... | @@ -536,6 +687,8 @@ export const makeGitManager = Effect.gen(function* () { |
| 536 | 687 | |
| 537 | 688 | return { |
| 538 | 689 | status, |
| 690 | resolvePullRequest, | |
| 691 | preparePullRequestThread, | |
| 539 | 692 | runStackedAction, |
| 540 | 693 | } satisfies GitManagerShape; |
| 541 | 694 | }); |
apps/server/src/git/Services/GitCore.ts+13| ... | ... | @@ -56,6 +56,12 @@ export interface GitRenameBranchResult { |
| 56 | 56 | branch: string; |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | export interface GitFetchPullRequestBranchInput { | |
| 60 | cwd: string; | |
| 61 | prNumber: number; | |
| 62 | branch: string; | |
| 63 | } | |
| 64 | ||
| 59 | 65 | /** |
| 60 | 66 | * GitCoreShape - Service API for low-level Git repository interactions. |
| 61 | 67 | */ |
| ... | ... | @@ -129,6 +135,13 @@ export interface GitCoreShape { |
| 129 | 135 | input: GitCreateWorktreeInput, |
| 130 | 136 | ) => Effect.Effect<GitCreateWorktreeResult, GitCommandError>; |
| 131 | 137 | |
| 138 | /** | |
| 139 | * Materialize a GitHub pull request head as a local branch without switching checkout. | |
| 140 | */ | |
| 141 | readonly fetchPullRequestBranch: ( | |
| 142 | input: GitFetchPullRequestBranchInput, | |
| 143 | ) => Effect.Effect<void, GitCommandError>; | |
| 144 | ||
| 132 | 145 | /** |
| 133 | 146 | * Remove an existing worktree. |
| 134 | 147 | */ |
apps/server/src/git/Services/GitHubCli.ts+18| ... | ... | @@ -17,6 +17,7 @@ export interface GitHubPullRequestSummary { |
| 17 | 17 | readonly url: string; |
| 18 | 18 | readonly baseRefName: string; |
| 19 | 19 | readonly headRefName: string; |
| 20 | readonly state?: "open" | "closed" | "merged"; | |
| 20 | 21 | } |
| 21 | 22 | |
| 22 | 23 | /** |
| ... | ... | @@ -41,6 +42,14 @@ export interface GitHubCliShape { |
| 41 | 42 | readonly limit?: number; |
| 42 | 43 | }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>; |
| 43 | 44 | |
| 45 | /** | |
| 46 | * Resolve a pull request by URL, number, or branch-ish identifier. | |
| 47 | */ | |
| 48 | readonly getPullRequest: (input: { | |
| 49 | readonly cwd: string; | |
| 50 | readonly reference: string; | |
| 51 | }) => Effect.Effect<GitHubPullRequestSummary, GitHubCliError>; | |
| 52 | ||
| 44 | 53 | /** |
| 45 | 54 | * Create a pull request from branch context and body file. |
| 46 | 55 | */ |
| ... | ... | @@ -58,6 +67,15 @@ export interface GitHubCliShape { |
| 58 | 67 | readonly getDefaultBranch: (input: { |
| 59 | 68 | readonly cwd: string; |
| 60 | 69 | }) => Effect.Effect<string | null, GitHubCliError>; |
| 70 | ||
| 71 | /** | |
| 72 | * Checkout a pull request into the current repository worktree. | |
| 73 | */ | |
| 74 | readonly checkoutPullRequest: (input: { | |
| 75 | readonly cwd: string; | |
| 76 | readonly reference: string; | |
| 77 | readonly force?: boolean; | |
| 78 | }) => Effect.Effect<void, GitHubCliError>; | |
| 61 | 79 | } |
| 62 | 80 | |
| 63 | 81 | /** |
apps/server/src/git/Services/GitManager.ts+18| ... | ... | @@ -7,6 +7,10 @@ |
| 7 | 7 | * @module GitManager |
| 8 | 8 | */ |
| 9 | 9 | import { |
| 10 | GitPreparePullRequestThreadInput, | |
| 11 | GitPreparePullRequestThreadResult, | |
| 12 | GitPullRequestRefInput, | |
| 13 | GitResolvePullRequestResult, | |
| 10 | 14 | GitRunStackedActionInput, |
| 11 | 15 | GitRunStackedActionResult, |
| 12 | 16 | GitStatusInput, |
| ... | ... | @@ -27,6 +31,20 @@ export interface GitManagerShape { |
| 27 | 31 | input: GitStatusInput, |
| 28 | 32 | ) => Effect.Effect<GitStatusResult, GitManagerServiceError>; |
| 29 | 33 | |
| 34 | /** | |
| 35 | * Resolve a pull request by URL/number against the current repository. | |
| 36 | */ | |
| 37 | readonly resolvePullRequest: ( | |
| 38 | input: GitPullRequestRefInput, | |
| 39 | ) => Effect.Effect<GitResolvePullRequestResult, GitManagerServiceError>; | |
| 40 | ||
| 41 | /** | |
| 42 | * Prepare a new thread workspace from a pull request in local or worktree mode. | |
| 43 | */ | |
| 44 | readonly preparePullRequestThread: ( | |
| 45 | input: GitPreparePullRequestThreadInput, | |
| 46 | ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>; | |
| 47 | ||
| 30 | 48 | /** |
| 31 | 49 | * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`). |
| 32 | 50 | * When `featureBranch` is set, creates and checks out a feature branch first. |
apps/server/src/wsServer.test.ts+67-1| ... | ... | @@ -1638,7 +1638,14 @@ describe("WebSocket Server", () => { |
| 1638 | 1638 | |
| 1639 | 1639 | const status = vi.fn(() => Effect.succeed(statusResult)); |
| 1640 | 1640 | const runStackedAction = vi.fn(() => Effect.void as any); |
| 1641 | const gitManager: GitManagerShape = { status, runStackedAction }; | |
| 1641 | const resolvePullRequest = vi.fn(() => Effect.void as any); | |
| 1642 | const preparePullRequestThread = vi.fn(() => Effect.void as any); | |
| 1643 | const gitManager: GitManagerShape = { | |
| 1644 | status, | |
| 1645 | resolvePullRequest, | |
| 1646 | preparePullRequestThread, | |
| 1647 | runStackedAction, | |
| 1648 | }; | |
| 1642 | 1649 | |
| 1643 | 1650 | server = await createTestServer({ cwd: "/test", gitManager }); |
| 1644 | 1651 | const addr = server.address(); |
| ... | ... | @@ -1656,6 +1663,63 @@ describe("WebSocket Server", () => { |
| 1656 | 1663 | expect(status).toHaveBeenCalledWith({ cwd: "/test" }); |
| 1657 | 1664 | }); |
| 1658 | 1665 | |
| 1666 | it("supports git pull request routing over websocket", async () => { | |
| 1667 | const resolvePullRequestResult = { | |
| 1668 | pullRequest: { | |
| 1669 | number: 42, | |
| 1670 | title: "PR thread flow", | |
| 1671 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | |
| 1672 | baseBranch: "main", | |
| 1673 | headBranch: "feature/pr-threads", | |
| 1674 | state: "open" as const, | |
| 1675 | }, | |
| 1676 | }; | |
| 1677 | const preparePullRequestThreadResult = { | |
| 1678 | ...resolvePullRequestResult, | |
| 1679 | branch: "feature/pr-threads", | |
| 1680 | worktreePath: "/tmp/pr-threads", | |
| 1681 | }; | |
| 1682 | ||
| 1683 | const gitManager: GitManagerShape = { | |
| 1684 | status: vi.fn(() => Effect.void as any), | |
| 1685 | resolvePullRequest: vi.fn(() => Effect.succeed(resolvePullRequestResult)), | |
| 1686 | preparePullRequestThread: vi.fn(() => Effect.succeed(preparePullRequestThreadResult)), | |
| 1687 | runStackedAction: vi.fn(() => Effect.void as any), | |
| 1688 | }; | |
| 1689 | ||
| 1690 | server = await createTestServer({ cwd: "/test", gitManager }); | |
| 1691 | const addr = server.address(); | |
| 1692 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1693 | ||
| 1694 | const ws = await connectWs(port); | |
| 1695 | connections.push(ws); | |
| 1696 | await waitForMessage(ws); | |
| 1697 | ||
| 1698 | const resolveResponse = await sendRequest(ws, WS_METHODS.gitResolvePullRequest, { | |
| 1699 | cwd: "/test", | |
| 1700 | reference: "#42", | |
| 1701 | }); | |
| 1702 | expect(resolveResponse.error).toBeUndefined(); | |
| 1703 | expect(resolveResponse.result).toEqual(resolvePullRequestResult); | |
| 1704 | ||
| 1705 | const prepareResponse = await sendRequest(ws, WS_METHODS.gitPreparePullRequestThread, { | |
| 1706 | cwd: "/test", | |
| 1707 | reference: "42", | |
| 1708 | mode: "worktree", | |
| 1709 | }); | |
| 1710 | expect(prepareResponse.error).toBeUndefined(); | |
| 1711 | expect(prepareResponse.result).toEqual(preparePullRequestThreadResult); | |
| 1712 | expect(gitManager.resolvePullRequest).toHaveBeenCalledWith({ | |
| 1713 | cwd: "/test", | |
| 1714 | reference: "#42", | |
| 1715 | }); | |
| 1716 | expect(gitManager.preparePullRequestThread).toHaveBeenCalledWith({ | |
| 1717 | cwd: "/test", | |
| 1718 | reference: "42", | |
| 1719 | mode: "worktree", | |
| 1720 | }); | |
| 1721 | }); | |
| 1722 | ||
| 1659 | 1723 | it("returns errors from git.runStackedAction", async () => { |
| 1660 | 1724 | const runStackedAction = vi.fn(() => |
| 1661 | 1725 | Effect.fail( |
| ... | ... | @@ -1667,6 +1731,8 @@ describe("WebSocket Server", () => { |
| 1667 | 1731 | ); |
| 1668 | 1732 | const gitManager: GitManagerShape = { |
| 1669 | 1733 | status: vi.fn(() => Effect.void as any), |
| 1734 | resolvePullRequest: vi.fn(() => Effect.void as any), | |
| 1735 | preparePullRequestThread: vi.fn(() => Effect.void as any), | |
| 1670 | 1736 | runStackedAction, |
| 1671 | 1737 | }; |
| 1672 | 1738 |
apps/server/src/wsServer.ts+10| ... | ... | @@ -832,6 +832,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 832 | 832 | return yield* gitManager.runStackedAction(body); |
| 833 | 833 | } |
| 834 | 834 | |
| 835 | case WS_METHODS.gitResolvePullRequest: { | |
| 836 | const body = stripRequestTag(request.body); | |
| 837 | return yield* gitManager.resolvePullRequest(body); | |
| 838 | } | |
| 839 | ||
| 840 | case WS_METHODS.gitPreparePullRequestThread: { | |
| 841 | const body = stripRequestTag(request.body); | |
| 842 | return yield* gitManager.preparePullRequestThread(body); | |
| 843 | } | |
| 844 | ||
| 835 | 845 | case WS_METHODS.gitListBranches: { |
| 836 | 846 | const body = stripRequestTag(request.body); |
| 837 | 847 | return yield* git.listBranches(body); |
apps/web/src/components/BranchToolbar.tsx+5| ... | ... | @@ -17,6 +17,7 @@ interface BranchToolbarProps { |
| 17 | 17 | threadId: ThreadId; |
| 18 | 18 | onEnvModeChange: (mode: EnvMode) => void; |
| 19 | 19 | envLocked: boolean; |
| 20 | onCheckoutPullRequestRequest?: (reference: string) => void; | |
| 20 | 21 | onComposerFocusRequest?: () => void; |
| 21 | 22 | } |
| 22 | 23 | |
| ... | ... | @@ -24,6 +25,7 @@ export default function BranchToolbar({ |
| 24 | 25 | threadId, |
| 25 | 26 | onEnvModeChange, |
| 26 | 27 | envLocked, |
| 28 | onCheckoutPullRequestRequest, | |
| 27 | 29 | onComposerFocusRequest, |
| 28 | 30 | }: BranchToolbarProps) { |
| 29 | 31 | const threads = useStore((store) => store.threads); |
| ... | ... | @@ -128,6 +130,9 @@ export default function BranchToolbar({ |
| 128 | 130 | effectiveEnvMode={effectiveEnvMode} |
| 129 | 131 | envLocked={envLocked} |
| 130 | 132 | onSetThreadBranch={setThreadBranch} |
| 133 | {...(onCheckoutPullRequestRequest | |
| 134 | ? { onCheckoutPullRequestRequest } | |
| 135 | : {})} | |
| 131 | 136 | {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} |
| 132 | 137 | /> |
| 133 | 138 | </div> |
apps/web/src/components/BranchToolbarBranchSelector.tsx+49-5| ... | ... | @@ -14,6 +14,7 @@ import { |
| 14 | 14 | |
| 15 | 15 | import { gitBranchesQueryOptions, gitQueryKeys, invalidateGitQueries } from "../lib/gitReactQuery"; |
| 16 | 16 | import { readNativeApi } from "../nativeApi"; |
| 17 | import { parsePullRequestReference } from "../pullRequestReference"; | |
| 17 | 18 | import { |
| 18 | 19 | dedupeRemoteBranchesWithLocalMatches, |
| 19 | 20 | deriveLocalBranchNameFromRemoteRef, |
| ... | ... | @@ -40,6 +41,7 @@ interface BranchToolbarBranchSelectorProps { |
| 40 | 41 | effectiveEnvMode: EnvMode; |
| 41 | 42 | envLocked: boolean; |
| 42 | 43 | onSetThreadBranch: (branch: string | null, worktreePath: string | null) => void; |
| 44 | onCheckoutPullRequestRequest?: (reference: string) => void; | |
| 43 | 45 | onComposerFocusRequest?: () => void; |
| 44 | 46 | } |
| 45 | 47 | |
| ... | ... | @@ -70,6 +72,7 @@ export function BranchToolbarBranchSelector({ |
| 70 | 72 | effectiveEnvMode, |
| 71 | 73 | envLocked, |
| 72 | 74 | onSetThreadBranch, |
| 75 | onCheckoutPullRequestRequest, | |
| 73 | 76 | onComposerFocusRequest, |
| 74 | 77 | }: BranchToolbarBranchSelectorProps) { |
| 75 | 78 | const queryClient = useQueryClient(); |
| ... | ... | @@ -95,17 +98,27 @@ export function BranchToolbarBranchSelector({ |
| 95 | 98 | ); |
| 96 | 99 | const trimmedBranchQuery = branchQuery.trim(); |
| 97 | 100 | const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); |
| 101 | const prReference = parsePullRequestReference(trimmedBranchQuery); | |
| 102 | const checkoutPullRequestItemValue = prReference && onCheckoutPullRequestRequest | |
| 103 | ? `__checkout_pull_request__:${prReference}` | |
| 104 | : null; | |
| 98 | 105 | const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0; |
| 99 | 106 | const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); |
| 100 | 107 | const createBranchItemValue = canCreateBranch |
| 101 | 108 | ? `__create_new_branch__:${trimmedBranchQuery}` |
| 102 | 109 | : null; |
| 103 | 110 | const branchPickerItems = useMemo( |
| 104 | () => | |
| 105 | createBranchItemValue && !hasExactBranchMatch | |
| 106 | ? [...branchNames, createBranchItemValue] | |
| 107 | : branchNames, | |
| 108 | [branchNames, createBranchItemValue, hasExactBranchMatch], | |
| 111 | () => { | |
| 112 | const items = [...branchNames]; | |
| 113 | if (createBranchItemValue && !hasExactBranchMatch) { | |
| 114 | items.push(createBranchItemValue); | |
| 115 | } | |
| 116 | if (checkoutPullRequestItemValue) { | |
| 117 | items.unshift(checkoutPullRequestItemValue); | |
| 118 | } | |
| 119 | return items; | |
| 120 | }, | |
| 121 | [branchNames, checkoutPullRequestItemValue, createBranchItemValue, hasExactBranchMatch], | |
| 109 | 122 | ); |
| 110 | 123 | const filteredBranchPickerItems = useMemo( |
| 111 | 124 | () => |
| ... | ... | @@ -338,6 +351,37 @@ export function BranchToolbarBranchSelector({ |
| 338 | 351 | {virtualBranchRows.map((virtualRow) => { |
| 339 | 352 | const itemValue = filteredBranchPickerItems[virtualRow.index]; |
| 340 | 353 | if (!itemValue) return null; |
| 354 | if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { | |
| 355 | return ( | |
| 356 | <ComboboxItem | |
| 357 | hideIndicator | |
| 358 | key={itemValue} | |
| 359 | index={virtualRow.index} | |
| 360 | value={itemValue} | |
| 361 | style={{ | |
| 362 | position: "absolute", | |
| 363 | top: 0, | |
| 364 | left: 0, | |
| 365 | width: "100%", | |
| 366 | transform: `translateY(${virtualRow.start}px)`, | |
| 367 | }} | |
| 368 | onClick={() => { | |
| 369 | if (!prReference || !onCheckoutPullRequestRequest) { | |
| 370 | return; | |
| 371 | } | |
| 372 | setIsBranchMenuOpen(false); | |
| 373 | setBranchQuery(""); | |
| 374 | onComposerFocusRequest?.(); | |
| 375 | onCheckoutPullRequestRequest(prReference); | |
| 376 | }} | |
| 377 | > | |
| 378 | <div className="flex min-w-0 flex-col items-start"> | |
| 379 | <span className="truncate font-medium">Checkout Pull Request</span> | |
| 380 | <span className="truncate text-muted-foreground text-xs">{prReference}</span> | |
| 381 | </div> | |
| 382 | </ComboboxItem> | |
| 383 | ); | |
| 384 | } | |
| 341 | 385 | if (createBranchItemValue && itemValue === createBranchItemValue) { |
| 342 | 386 | return ( |
| 343 | 387 | <ComboboxItem |
apps/web/src/components/ChatView.tsx+153-20| ... | ... | @@ -119,6 +119,7 @@ import { |
| 119 | 119 | type TurnDiffTreeNode, |
| 120 | 120 | } from "../lib/turnDiffTree"; |
| 121 | 121 | import BranchToolbar from "./BranchToolbar"; |
| 122 | import { ComposerPullRequestCommandMenu } from "./ComposerPullRequestCommandMenu"; | |
| 122 | 123 | import GitActionsControl from "./GitActionsControl"; |
| 123 | 124 | import { |
| 124 | 125 | isOpenFavoriteEditorShortcut, |
| ... | ... | @@ -407,6 +408,11 @@ type ComposerCommandItem = |
| 407 | 408 | |
| 408 | 409 | type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; |
| 409 | 410 | |
| 411 | interface PullRequestComposerMenuState { | |
| 412 | initialReference: string | null; | |
| 413 | key: number; | |
| 414 | } | |
| 415 | ||
| 410 | 416 | function readFileAsDataUrl(file: File): Promise<string> { |
| 411 | 417 | return new Promise((resolve, reject) => { |
| 412 | 418 | const reader = new FileReader(); |
| ... | ... | @@ -609,6 +615,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 609 | 615 | const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); |
| 610 | 616 | const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread); |
| 611 | 617 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 618 | const getDraftThreadByProjectId = useComposerDraftStore((store) => store.getDraftThreadByProjectId); | |
| 619 | const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); | |
| 620 | const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); | |
| 621 | const clearProjectDraftThreadId = useComposerDraftStore( | |
| 622 | (store) => store.clearProjectDraftThreadId, | |
| 623 | ); | |
| 612 | 624 | const draftThread = useComposerDraftStore( |
| 613 | 625 | (store) => store.draftThreadsByThreadId[threadId] ?? null, |
| 614 | 626 | ); |
| ... | ... | @@ -645,6 +657,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 645 | 657 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 646 | 658 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 647 | 659 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); |
| 660 | const [pullRequestComposerMenuState, setPullRequestComposerMenuState] = | |
| 661 | useState<PullRequestComposerMenuState | null>(null); | |
| 648 | 662 | const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< |
| 649 | 663 | Record<string, string[]> |
| 650 | 664 | >({}); |
| ... | ... | @@ -749,6 +763,80 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 749 | 763 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 750 | 764 | const activeProject = projects.find((p) => p.id === activeThread?.projectId); |
| 751 | 765 | |
| 766 | const openPullRequestComposerMenu = useCallback((reference?: string) => { | |
| 767 | setPullRequestComposerMenuState({ | |
| 768 | initialReference: reference ?? null, | |
| 769 | key: Date.now(), | |
| 770 | }); | |
| 771 | setComposerHighlightedItemId(null); | |
| 772 | }, []); | |
| 773 | ||
| 774 | const closePullRequestComposerMenu = useCallback(() => { | |
| 775 | setPullRequestComposerMenuState(null); | |
| 776 | }, []); | |
| 777 | ||
| 778 | const openOrReuseProjectDraftThread = useCallback( | |
| 779 | async (input: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => { | |
| 780 | if (!activeProject) { | |
| 781 | throw new Error("No active project is available for this pull request."); | |
| 782 | } | |
| 783 | const storedDraftThread = getDraftThreadByProjectId(activeProject.id); | |
| 784 | if (storedDraftThread) { | |
| 785 | setDraftThreadContext(storedDraftThread.threadId, input); | |
| 786 | setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input); | |
| 787 | if (storedDraftThread.threadId !== threadId) { | |
| 788 | await navigate({ | |
| 789 | to: "/$threadId", | |
| 790 | params: { threadId: storedDraftThread.threadId }, | |
| 791 | }); | |
| 792 | } | |
| 793 | return; | |
| 794 | } | |
| 795 | ||
| 796 | const activeDraftThread = getDraftThread(threadId); | |
| 797 | if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { | |
| 798 | setDraftThreadContext(threadId, input); | |
| 799 | setProjectDraftThreadId(activeProject.id, threadId, input); | |
| 800 | return; | |
| 801 | } | |
| 802 | ||
| 803 | clearProjectDraftThreadId(activeProject.id); | |
| 804 | const nextThreadId = newThreadId(); | |
| 805 | setProjectDraftThreadId(activeProject.id, nextThreadId, { | |
| 806 | createdAt: new Date().toISOString(), | |
| 807 | runtimeMode: DEFAULT_RUNTIME_MODE, | |
| 808 | interactionMode: DEFAULT_INTERACTION_MODE, | |
| 809 | ...input, | |
| 810 | }); | |
| 811 | await navigate({ | |
| 812 | to: "/$threadId", | |
| 813 | params: { threadId: nextThreadId }, | |
| 814 | }); | |
| 815 | }, | |
| 816 | [ | |
| 817 | activeProject, | |
| 818 | clearProjectDraftThreadId, | |
| 819 | getDraftThread, | |
| 820 | getDraftThreadByProjectId, | |
| 821 | isServerThread, | |
| 822 | navigate, | |
| 823 | setDraftThreadContext, | |
| 824 | setProjectDraftThreadId, | |
| 825 | threadId, | |
| 826 | ], | |
| 827 | ); | |
| 828 | ||
| 829 | const handlePreparedPullRequestThread = useCallback( | |
| 830 | async (input: { branch: string; worktreePath: string | null }) => { | |
| 831 | await openOrReuseProjectDraftThread({ | |
| 832 | branch: input.branch, | |
| 833 | worktreePath: input.worktreePath, | |
| 834 | envMode: input.worktreePath ? "worktree" : "local", | |
| 835 | }); | |
| 836 | }, | |
| 837 | [openOrReuseProjectDraftThread], | |
| 838 | ); | |
| 839 | ||
| 752 | 840 | useEffect(() => { |
| 753 | 841 | if (!activeThread?.id) return; |
| 754 | 842 | if (!latestTurnSettled) return; |
| ... | ... | @@ -1162,6 +1250,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1162 | 1250 | timelineEntries, |
| 1163 | 1251 | ]); |
| 1164 | 1252 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; |
| 1253 | const pullRequestComposerMenuOpen = pullRequestComposerMenuState !== null; | |
| 1165 | 1254 | const composerTriggerKind = composerTrigger?.kind ?? null; |
| 1166 | 1255 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; |
| 1167 | 1256 | const isPathTrigger = composerTriggerKind === "path"; |
| ... | ... | @@ -1218,6 +1307,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1218 | 1307 | label: "/default", |
| 1219 | 1308 | description: "Switch this thread back to normal chat mode", |
| 1220 | 1309 | }, |
| 1310 | { | |
| 1311 | id: "slash:checkout-pr", | |
| 1312 | type: "slash-command", | |
| 1313 | command: "checkout-pr", | |
| 1314 | label: "/checkout-pr", | |
| 1315 | description: "Create a new thread from a GitHub pull request", | |
| 1316 | }, | |
| 1221 | 1317 | ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>; |
| 1222 | 1318 | const query = composerTrigger.query.trim().toLowerCase(); |
| 1223 | 1319 | if (!query) { |
| ... | ... | @@ -1245,13 +1341,15 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1245 | 1341 | description: `${providerLabel} · ${slug}`, |
| 1246 | 1342 | })); |
| 1247 | 1343 | }, [composerTrigger, searchableModelOptions, workspaceEntries]); |
| 1248 | const composerMenuOpen = Boolean(composerTrigger); | |
| 1344 | const composerMenuOpen = Boolean(composerTrigger) || pullRequestComposerMenuOpen; | |
| 1249 | 1345 | const activeComposerMenuItem = useMemo( |
| 1250 | 1346 | () => |
| 1251 | composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? | |
| 1252 | composerMenuItems[0] ?? | |
| 1253 | null, | |
| 1254 | [composerHighlightedItemId, composerMenuItems], | |
| 1347 | pullRequestComposerMenuOpen | |
| 1348 | ? null | |
| 1349 | : (composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? | |
| 1350 | composerMenuItems[0] ?? | |
| 1351 | null), | |
| 1352 | [composerHighlightedItemId, composerMenuItems, pullRequestComposerMenuOpen], | |
| 1255 | 1353 | ); |
| 1256 | 1354 | composerMenuOpenRef.current = composerMenuOpen; |
| 1257 | 1355 | composerMenuItemsRef.current = composerMenuItems; |
| ... | ... | @@ -1950,6 +2048,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1950 | 2048 | |
| 1951 | 2049 | useEffect(() => { |
| 1952 | 2050 | setExpandedWorkGroups({}); |
| 2051 | setPullRequestComposerMenuState(null); | |
| 1953 | 2052 | if (planSidebarOpenOnNextThreadRef.current) { |
| 1954 | 2053 | planSidebarOpenOnNextThreadRef.current = false; |
| 1955 | 2054 | setPlanSidebarOpen(true); |
| ... | ... | @@ -1964,12 +2063,16 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1964 | 2063 | setComposerHighlightedItemId(null); |
| 1965 | 2064 | return; |
| 1966 | 2065 | } |
| 2066 | if (pullRequestComposerMenuOpen) { | |
| 2067 | setComposerHighlightedItemId(null); | |
| 2068 | return; | |
| 2069 | } | |
| 1967 | 2070 | setComposerHighlightedItemId((existing) => |
| 1968 | 2071 | existing && composerMenuItems.some((item) => item.id === existing) |
| 1969 | 2072 | ? existing |
| 1970 | 2073 | : (composerMenuItems[0]?.id ?? null), |
| 1971 | 2074 | ); |
| 1972 | }, [composerMenuItems, composerMenuOpen]); | |
| 2075 | }, [composerMenuItems, composerMenuOpen, pullRequestComposerMenuOpen]); | |
| 1973 | 2076 | |
| 1974 | 2077 | useEffect(() => { |
| 1975 | 2078 | setIsRevertingCheckpoint(false); |
| ... | ... | @@ -2480,7 +2583,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2480 | 2583 | const standaloneSlashCommand = |
| 2481 | 2584 | composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; |
| 2482 | 2585 | if (standaloneSlashCommand) { |
| 2483 | await handleInteractionModeChange(standaloneSlashCommand); | |
| 2586 | if (standaloneSlashCommand === "checkout-pr") { | |
| 2587 | openPullRequestComposerMenu(); | |
| 2588 | } else { | |
| 2589 | await handleInteractionModeChange(standaloneSlashCommand); | |
| 2590 | } | |
| 2484 | 2591 | promptRef.current = ""; |
| 2485 | 2592 | clearComposerDraftContent(activeThread.id); |
| 2486 | 2593 | setComposerHighlightedItemId(null); |
| ... | ... | @@ -3269,7 +3376,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3269 | 3376 | } |
| 3270 | 3377 | return; |
| 3271 | 3378 | } |
| 3272 | void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); | |
| 3379 | if (item.command === "checkout-pr") { | |
| 3380 | openPullRequestComposerMenu(); | |
| 3381 | } else { | |
| 3382 | void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); | |
| 3383 | } | |
| 3273 | 3384 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { |
| 3274 | 3385 | expectedText: expectedToken, |
| 3275 | 3386 | }); |
| ... | ... | @@ -3289,6 +3400,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3289 | 3400 | [ |
| 3290 | 3401 | applyPromptReplacement, |
| 3291 | 3402 | handleInteractionModeChange, |
| 3403 | openPullRequestComposerMenu, | |
| 3292 | 3404 | onProviderModelSelect, |
| 3293 | 3405 | resolveActiveComposerTrigger, |
| 3294 | 3406 | ], |
| ... | ... | @@ -3363,6 +3475,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3363 | 3475 | const { trigger } = resolveActiveComposerTrigger(); |
| 3364 | 3476 | const menuIsActive = composerMenuOpenRef.current || trigger !== null; |
| 3365 | 3477 | |
| 3478 | if (pullRequestComposerMenuOpen) { | |
| 3479 | if (key === "ArrowDown" || key === "ArrowUp" || key === "Tab" || key === "Enter") { | |
| 3480 | return true; | |
| 3481 | } | |
| 3482 | } | |
| 3483 | ||
| 3366 | 3484 | if (menuIsActive) { |
| 3367 | 3485 | const currentItems = composerMenuItemsRef.current; |
| 3368 | 3486 | if (key === "ArrowDown" && currentItems.length > 0) { |
| ... | ... | @@ -3584,15 +3702,28 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3584 | 3702 | > |
| 3585 | 3703 | {composerMenuOpen && !isComposerApprovalState && ( |
| 3586 | 3704 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> |
| 3587 | <ComposerCommandMenu | |
| 3588 | items={composerMenuItems} | |
| 3589 | resolvedTheme={resolvedTheme} | |
| 3590 | isLoading={isComposerMenuLoading} | |
| 3591 | triggerKind={composerTriggerKind} | |
| 3592 | activeItemId={activeComposerMenuItem?.id ?? null} | |
| 3593 | onHighlightedItemChange={onComposerMenuItemHighlighted} | |
| 3594 | onSelect={onSelectComposerItem} | |
| 3595 | /> | |
| 3705 | {pullRequestComposerMenuState ? ( | |
| 3706 | <ComposerPullRequestCommandMenu | |
| 3707 | key={pullRequestComposerMenuState.key} | |
| 3708 | cwd={activeProject?.cwd ?? null} | |
| 3709 | initialReference={pullRequestComposerMenuState.initialReference} | |
| 3710 | onCancel={closePullRequestComposerMenu} | |
| 3711 | onPrepared={async (input) => { | |
| 3712 | await handlePreparedPullRequestThread(input); | |
| 3713 | closePullRequestComposerMenu(); | |
| 3714 | }} | |
| 3715 | /> | |
| 3716 | ) : ( | |
| 3717 | <ComposerCommandMenu | |
| 3718 | items={composerMenuItems} | |
| 3719 | resolvedTheme={resolvedTheme} | |
| 3720 | isLoading={isComposerMenuLoading} | |
| 3721 | triggerKind={composerTriggerKind} | |
| 3722 | activeItemId={activeComposerMenuItem?.id ?? null} | |
| 3723 | onHighlightedItemChange={onComposerMenuItemHighlighted} | |
| 3724 | onSelect={onSelectComposerItem} | |
| 3725 | /> | |
| 3726 | )} | |
| 3596 | 3727 | </div> |
| 3597 | 3728 | )} |
| 3598 | 3729 | |
| ... | ... | @@ -3688,7 +3819,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3688 | 3819 | ? "Add feedback to refine the plan, or leave this blank to implement it" |
| 3689 | 3820 | : phase === "disconnected" |
| 3690 | 3821 | ? "Ask for follow-up changes or attach images" |
| 3691 | : "Ask anything, @tag files/folders, or use /model" | |
| 3822 | : "Ask anything, @tag files/folders, or use / to show available commands" | |
| 3692 | 3823 | } |
| 3693 | 3824 | disabled={isConnecting || isComposerApprovalState} |
| 3694 | 3825 | /> |
| ... | ... | @@ -4018,10 +4149,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 4018 | 4149 | onEnvModeChange={onEnvModeChange} |
| 4019 | 4150 | envLocked={envLocked} |
| 4020 | 4151 | onComposerFocusRequest={scheduleComposerFocus} |
| 4152 | {...(canCheckoutPullRequestIntoThread | |
| 4153 | ? { onCheckoutPullRequestRequest: openPullRequestComposerMenu } | |
| 4154 | : {})} | |
| 4021 | 4155 | /> |
| 4022 | 4156 | )} |
| 4023 | </div> | |
| 4024 | {/* end chat column */} | |
| 4157 | </div>{/* end chat column */} | |
| 4025 | 4158 | |
| 4026 | 4159 | {/* Plan sidebar */} |
| 4027 | 4160 | {planSidebarOpen ? ( |
apps/web/src/components/ComposerPullRequestCommandMenu.tsx created+259| ... | ... | @@ -0,0 +1,259 @@ |
| 1 | import type { GitResolvePullRequestResult } from "@t3tools/contracts"; | |
| 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | import { useDebouncedValue } from "@tanstack/react-pacer"; | |
| 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 5 | ||
| 6 | import { | |
| 7 | gitPreparePullRequestThreadMutationOptions, | |
| 8 | gitResolvePullRequestQueryOptions, | |
| 9 | } from "~/lib/gitReactQuery"; | |
| 10 | import { cn } from "~/lib/utils"; | |
| 11 | import { parsePullRequestReference } from "~/pullRequestReference"; | |
| 12 | import { Button } from "./ui/button"; | |
| 13 | import { Input } from "./ui/input"; | |
| 14 | import { Spinner } from "./ui/spinner"; | |
| 15 | ||
| 16 | interface ComposerPullRequestCommandMenuProps { | |
| 17 | cwd: string | null; | |
| 18 | initialReference: string | null; | |
| 19 | onCancel: () => void; | |
| 20 | onPrepared: (input: { branch: string; worktreePath: string | null }) => Promise<void> | void; | |
| 21 | } | |
| 22 | ||
| 23 | export function ComposerPullRequestCommandMenu({ | |
| 24 | cwd, | |
| 25 | initialReference, | |
| 26 | onCancel, | |
| 27 | onPrepared, | |
| 28 | }: ComposerPullRequestCommandMenuProps) { | |
| 29 | const queryClient = useQueryClient(); | |
| 30 | const referenceInputRef = useRef<HTMLInputElement>(null); | |
| 31 | const [reference, setReference] = useState(initialReference ?? ""); | |
| 32 | const [referenceDirty, setReferenceDirty] = useState(false); | |
| 33 | const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null); | |
| 34 | const [debouncedReference, referenceDebouncer] = useDebouncedValue( | |
| 35 | reference, | |
| 36 | { wait: 450 }, | |
| 37 | (debouncerState) => ({ isPending: debouncerState.isPending }), | |
| 38 | ); | |
| 39 | ||
| 40 | useEffect(() => { | |
| 41 | setReference(initialReference ?? ""); | |
| 42 | setReferenceDirty(false); | |
| 43 | setPreparingMode(null); | |
| 44 | }, [initialReference]); | |
| 45 | ||
| 46 | useEffect(() => { | |
| 47 | const frame = window.requestAnimationFrame(() => { | |
| 48 | referenceInputRef.current?.focus(); | |
| 49 | referenceInputRef.current?.select(); | |
| 50 | }); | |
| 51 | return () => { | |
| 52 | window.cancelAnimationFrame(frame); | |
| 53 | }; | |
| 54 | }, []); | |
| 55 | ||
| 56 | const parsedReference = parsePullRequestReference(reference); | |
| 57 | const parsedDebouncedReference = parsePullRequestReference(debouncedReference); | |
| 58 | const resolvePullRequestQuery = useQuery( | |
| 59 | gitResolvePullRequestQueryOptions({ | |
| 60 | cwd, | |
| 61 | reference: parsedDebouncedReference, | |
| 62 | }), | |
| 63 | ); | |
| 64 | const cachedPullRequest = useMemo(() => { | |
| 65 | if (!cwd || !parsedReference) { | |
| 66 | return null; | |
| 67 | } | |
| 68 | const cached = queryClient.getQueryData<GitResolvePullRequestResult>([ | |
| 69 | "git", | |
| 70 | "pull-request", | |
| 71 | cwd, | |
| 72 | parsedReference, | |
| 73 | ]); | |
| 74 | return cached?.pullRequest ?? null; | |
| 75 | }, [cwd, parsedReference, queryClient]); | |
| 76 | const preparePullRequestThreadMutation = useMutation( | |
| 77 | gitPreparePullRequestThreadMutationOptions({ cwd, queryClient }), | |
| 78 | ); | |
| 79 | ||
| 80 | const liveResolvedPullRequest = | |
| 81 | parsedReference !== null && parsedReference === parsedDebouncedReference | |
| 82 | ? (resolvePullRequestQuery.data?.pullRequest ?? null) | |
| 83 | : null; | |
| 84 | const resolvedPullRequest = liveResolvedPullRequest ?? cachedPullRequest; | |
| 85 | const isResolving = | |
| 86 | parsedReference !== null && | |
| 87 | resolvedPullRequest === null && | |
| 88 | (referenceDebouncer.state.isPending || | |
| 89 | parsedReference !== parsedDebouncedReference || | |
| 90 | resolvePullRequestQuery.isPending || | |
| 91 | resolvePullRequestQuery.isFetching); | |
| 92 | const statusTone = useMemo(() => { | |
| 93 | switch (resolvedPullRequest?.state) { | |
| 94 | case "merged": | |
| 95 | return "text-violet-600 dark:text-violet-300/90"; | |
| 96 | case "closed": | |
| 97 | return "text-zinc-500 dark:text-zinc-400/80"; | |
| 98 | case "open": | |
| 99 | return "text-emerald-600 dark:text-emerald-300/90"; | |
| 100 | default: | |
| 101 | return "text-muted-foreground"; | |
| 102 | } | |
| 103 | }, [resolvedPullRequest?.state]); | |
| 104 | ||
| 105 | const handleConfirm = useCallback(async (mode: "local" | "worktree") => { | |
| 106 | if (!parsedReference) { | |
| 107 | setReferenceDirty(true); | |
| 108 | return; | |
| 109 | } | |
| 110 | if (!parsedReference || !resolvedPullRequest || !cwd) { | |
| 111 | return; | |
| 112 | } | |
| 113 | setPreparingMode(mode); | |
| 114 | try { | |
| 115 | const result = await preparePullRequestThreadMutation.mutateAsync({ | |
| 116 | reference: parsedReference, | |
| 117 | mode, | |
| 118 | }); | |
| 119 | await onPrepared({ | |
| 120 | branch: result.branch, | |
| 121 | worktreePath: result.worktreePath, | |
| 122 | }); | |
| 123 | } finally { | |
| 124 | setPreparingMode(null); | |
| 125 | } | |
| 126 | }, [ | |
| 127 | cwd, | |
| 128 | onPrepared, | |
| 129 | parsedReference, | |
| 130 | preparePullRequestThreadMutation, | |
| 131 | resolvedPullRequest, | |
| 132 | ]); | |
| 133 | ||
| 134 | const validationMessage = | |
| 135 | !referenceDirty | |
| 136 | ? null | |
| 137 | : reference.trim().length === 0 | |
| 138 | ? "Paste a GitHub pull request URL or enter 123 / #123." | |
| 139 | : parsedReference === null | |
| 140 | ? "Use a GitHub pull request URL, 123, or #123." | |
| 141 | : null; | |
| 142 | const errorMessage = | |
| 143 | validationMessage ?? | |
| 144 | (resolvedPullRequest === null && resolvePullRequestQuery.isError | |
| 145 | ? (resolvePullRequestQuery.error instanceof Error | |
| 146 | ? resolvePullRequestQuery.error.message | |
| 147 | : "Failed to resolve pull request.") | |
| 148 | : (preparePullRequestThreadMutation.error instanceof Error | |
| 149 | ? preparePullRequestThreadMutation.error.message | |
| 150 | : preparePullRequestThreadMutation.error | |
| 151 | ? "Failed to prepare pull request thread." | |
| 152 | : null)); | |
| 153 | ||
| 154 | return ( | |
| 155 | <div className="overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"> | |
| 156 | <div className="space-y-4 p-3"> | |
| 157 | <div className="space-y-2"> | |
| 158 | <div className="flex items-center justify-between gap-3"> | |
| 159 | <div> | |
| 160 | <p className="font-medium text-sm">Checkout Pull Request</p> | |
| 161 | <p className="text-muted-foreground text-xs"> | |
| 162 | Resolve a PR, then choose Local or Worktree for the new draft thread. | |
| 163 | </p> | |
| 164 | </div> | |
| 165 | </div> | |
| 166 | <Input | |
| 167 | autoFocus | |
| 168 | ref={referenceInputRef} | |
| 169 | placeholder="https://github.com/owner/repo/pull/42 or #42" | |
| 170 | value={reference} | |
| 171 | onChange={(event) => { | |
| 172 | setReferenceDirty(true); | |
| 173 | setReference(event.target.value); | |
| 174 | }} | |
| 175 | onKeyDown={(event) => { | |
| 176 | if (event.key !== "Enter") { | |
| 177 | return; | |
| 178 | } | |
| 179 | event.preventDefault(); | |
| 180 | if (!isResolving && !preparePullRequestThreadMutation.isPending) { | |
| 181 | void handleConfirm("local"); | |
| 182 | } | |
| 183 | }} | |
| 184 | /> | |
| 185 | </div> | |
| 186 | ||
| 187 | {resolvedPullRequest ? ( | |
| 188 | <div className="rounded-xl border border-border/70 bg-muted/24 p-3"> | |
| 189 | <div className="flex items-start justify-between gap-3"> | |
| 190 | <div className="min-w-0"> | |
| 191 | <p className="truncate font-medium text-sm">{resolvedPullRequest.title}</p> | |
| 192 | <p className="truncate text-muted-foreground text-xs"> | |
| 193 | #{resolvedPullRequest.number} · {resolvedPullRequest.headBranch} to{" "} | |
| 194 | {resolvedPullRequest.baseBranch} | |
| 195 | </p> | |
| 196 | </div> | |
| 197 | <span className={cn("shrink-0 text-xs capitalize", statusTone)}> | |
| 198 | {resolvedPullRequest.state} | |
| 199 | </span> | |
| 200 | </div> | |
| 201 | </div> | |
| 202 | ) : null} | |
| 203 | ||
| 204 | {isResolving ? ( | |
| 205 | <div className="flex items-center gap-2 text-muted-foreground text-xs"> | |
| 206 | <Spinner className="size-3.5" /> | |
| 207 | Resolving pull request... | |
| 208 | </div> | |
| 209 | ) : null} | |
| 210 | ||
| 211 | {errorMessage ? <p className="text-destructive text-xs">{errorMessage}</p> : null} | |
| 212 | </div> | |
| 213 | ||
| 214 | <div className="flex items-center justify-between gap-2 border-t px-3 py-2"> | |
| 215 | <p className="text-muted-foreground text-xs"> | |
| 216 | {resolvedPullRequest | |
| 217 | ? "Create the draft thread in the main repo or in a dedicated worktree." | |
| 218 | : "Resolve the pull request first."} | |
| 219 | </p> | |
| 220 | <div className="flex items-center gap-2"> | |
| 221 | <Button type="button" variant="ghost" size="sm" onClick={onCancel}> | |
| 222 | Cancel | |
| 223 | </Button> | |
| 224 | <Button | |
| 225 | type="button" | |
| 226 | size="sm" | |
| 227 | variant="outline" | |
| 228 | onClick={() => { | |
| 229 | void handleConfirm("local"); | |
| 230 | }} | |
| 231 | disabled={ | |
| 232 | !cwd || | |
| 233 | !resolvedPullRequest || | |
| 234 | isResolving || | |
| 235 | preparePullRequestThreadMutation.isPending | |
| 236 | } | |
| 237 | > | |
| 238 | {preparingMode === "local" ? "Preparing local..." : "Local"} | |
| 239 | </Button> | |
| 240 | <Button | |
| 241 | type="button" | |
| 242 | size="sm" | |
| 243 | onClick={() => { | |
| 244 | void handleConfirm("worktree"); | |
| 245 | }} | |
| 246 | disabled={ | |
| 247 | !cwd || | |
| 248 | !resolvedPullRequest || | |
| 249 | isResolving || | |
| 250 | preparePullRequestThreadMutation.isPending | |
| 251 | } | |
| 252 | > | |
| 253 | {preparingMode === "worktree" ? "Preparing worktree..." : "Worktree"} | |
| 254 | </Button> | |
| 255 | </div> | |
| 256 | </div> | |
| 257 | </div> | |
| 258 | ); | |
| 259 | } |
apps/web/src/composer-logic.test.ts+4| ... | ... | @@ -134,6 +134,10 @@ describe("parseStandaloneComposerSlashCommand", () => { |
| 134 | 134 | expect(parseStandaloneComposerSlashCommand("/default")).toBe("default"); |
| 135 | 135 | }); |
| 136 | 136 | |
| 137 | it("parses standalone /checkout-pr command", () => { | |
| 138 | expect(parseStandaloneComposerSlashCommand(" /checkout-pr ")).toBe("checkout-pr"); | |
| 139 | }); | |
| 140 | ||
| 137 | 141 | it("ignores slash commands with extra message text", () => { |
| 138 | 142 | expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull(); |
| 139 | 143 | }); |
apps/web/src/composer-logic.ts+12-4| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; |
| 2 | 2 | |
| 3 | 3 | export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; |
| 4 | export type ComposerSlashCommand = "model" | "plan" | "default"; | |
| 4 | export type ComposerSlashCommand = "model" | "plan" | "default" | "checkout-pr"; | |
| 5 | 5 | |
| 6 | 6 | export interface ComposerTrigger { |
| 7 | 7 | kind: ComposerTriggerKind; |
| ... | ... | @@ -10,7 +10,12 @@ export interface ComposerTrigger { |
| 10 | 10 | rangeEnd: number; |
| 11 | 11 | } |
| 12 | 12 | |
| 13 | const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; | |
| 13 | const SLASH_COMMANDS: readonly ComposerSlashCommand[] = [ | |
| 14 | "model", | |
| 15 | "plan", | |
| 16 | "default", | |
| 17 | "checkout-pr", | |
| 18 | ]; | |
| 14 | 19 | |
| 15 | 20 | function clampCursor(text: string, cursor: number): number { |
| 16 | 21 | if (!Number.isFinite(cursor)) return text.length; |
| ... | ... | @@ -164,11 +169,14 @@ export function parseStandaloneComposerSlashCommand(text: string): Exclude< |
| 164 | 169 | ComposerSlashCommand, |
| 165 | 170 | "model" |
| 166 | 171 | > | null { |
| 167 | const match = /^\/(plan|default)\s*$/i.exec(text.trim()); | |
| 172 | const match = /^\/(plan|default|checkout-pr)\s*$/i.exec(text.trim()); | |
| 168 | 173 | if (!match) { |
| 169 | 174 | return null; |
| 170 | 175 | } |
| 171 | return match[1]?.toLowerCase() === "plan" ? "plan" : "default"; | |
| 176 | const command = match[1]?.toLowerCase(); | |
| 177 | if (command === "plan") return "plan"; | |
| 178 | if (command === "checkout-pr") return "checkout-pr"; | |
| 179 | return "default"; | |
| 172 | 180 | } |
| 173 | 181 | |
| 174 | 182 | export function replaceTextRange( |
apps/web/src/lib/gitReactQuery.test.ts+15| ... | ... | @@ -2,6 +2,7 @@ import { QueryClient } from "@tanstack/react-query"; |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | import { |
| 4 | 4 | gitMutationKeys, |
| 5 | gitPreparePullRequestThreadMutationOptions, | |
| 5 | 6 | gitPullMutationOptions, |
| 6 | 7 | gitRunStackedActionMutationOptions, |
| 7 | 8 | } from "./gitReactQuery"; |
| ... | ... | @@ -16,6 +17,12 @@ describe("gitMutationKeys", () => { |
| 16 | 17 | it("scopes pull keys by cwd", () => { |
| 17 | 18 | expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b")); |
| 18 | 19 | }); |
| 20 | ||
| 21 | it("scopes pull request thread preparation keys by cwd", () => { | |
| 22 | expect(gitMutationKeys.preparePullRequestThread("/repo/a")).not.toEqual( | |
| 23 | gitMutationKeys.preparePullRequestThread("/repo/b"), | |
| 24 | ); | |
| 25 | }); | |
| 19 | 26 | }); |
| 20 | 27 | |
| 21 | 28 | describe("git mutation options", () => { |
| ... | ... | @@ -30,4 +37,12 @@ describe("git mutation options", () => { |
| 30 | 37 | const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); |
| 31 | 38 | expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a")); |
| 32 | 39 | }); |
| 40 | ||
| 41 | it("attaches cwd-scoped mutation key for preparePullRequestThread", () => { | |
| 42 | const options = gitPreparePullRequestThreadMutationOptions({ | |
| 43 | cwd: "/repo/a", | |
| 44 | queryClient, | |
| 45 | }); | |
| 46 | expect(options.mutationKey).toEqual(gitMutationKeys.preparePullRequestThread("/repo/a")); | |
| 47 | }); | |
| 33 | 48 | }); |
apps/web/src/lib/gitReactQuery.ts+49| ... | ... | @@ -18,6 +18,8 @@ export const gitMutationKeys = { |
| 18 | 18 | checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const, |
| 19 | 19 | runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const, |
| 20 | 20 | pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const, |
| 21 | preparePullRequestThread: (cwd: string | null) => | |
| 22 | ["git", "mutation", "prepare-pull-request-thread", cwd] as const, | |
| 21 | 23 | }; |
| 22 | 24 | |
| 23 | 25 | export function invalidateGitQueries(queryClient: QueryClient) { |
| ... | ... | @@ -56,6 +58,26 @@ export function gitBranchesQueryOptions(cwd: string | null) { |
| 56 | 58 | }); |
| 57 | 59 | } |
| 58 | 60 | |
| 61 | export function gitResolvePullRequestQueryOptions(input: { | |
| 62 | cwd: string | null; | |
| 63 | reference: string | null; | |
| 64 | }) { | |
| 65 | return queryOptions({ | |
| 66 | queryKey: ["git", "pull-request", input.cwd, input.reference] as const, | |
| 67 | queryFn: async () => { | |
| 68 | const api = ensureNativeApi(); | |
| 69 | if (!input.cwd || !input.reference) { | |
| 70 | throw new Error("Pull request lookup is unavailable."); | |
| 71 | } | |
| 72 | return api.git.resolvePullRequest({ cwd: input.cwd, reference: input.reference }); | |
| 73 | }, | |
| 74 | enabled: input.cwd !== null && input.reference !== null, | |
| 75 | staleTime: 30_000, | |
| 76 | refetchOnWindowFocus: false, | |
| 77 | refetchOnReconnect: false, | |
| 78 | }); | |
| 79 | } | |
| 80 | ||
| 59 | 81 | export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { |
| 60 | 82 | return mutationOptions({ |
| 61 | 83 | mutationKey: gitMutationKeys.init(input.cwd), |
| ... | ... | @@ -168,3 +190,30 @@ export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClie |
| 168 | 190 | }, |
| 169 | 191 | }); |
| 170 | 192 | } |
| 193 | ||
| 194 | export function gitPreparePullRequestThreadMutationOptions(input: { | |
| 195 | cwd: string | null; | |
| 196 | queryClient: QueryClient; | |
| 197 | }) { | |
| 198 | return mutationOptions({ | |
| 199 | mutationFn: async ({ | |
| 200 | reference, | |
| 201 | mode, | |
| 202 | }: { | |
| 203 | reference: string; | |
| 204 | mode: "local" | "worktree"; | |
| 205 | }) => { | |
| 206 | const api = ensureNativeApi(); | |
| 207 | if (!input.cwd) throw new Error("Pull request thread preparation is unavailable."); | |
| 208 | return api.git.preparePullRequestThread({ | |
| 209 | cwd: input.cwd, | |
| 210 | reference, | |
| 211 | mode, | |
| 212 | }); | |
| 213 | }, | |
| 214 | mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), | |
| 215 | onSettled: async () => { | |
| 216 | await invalidateGitQueries(input.queryClient); | |
| 217 | }, | |
| 218 | }); | |
| 219 | } |
apps/web/src/pullRequestReference.test.ts created+23| ... | ... | @@ -0,0 +1,23 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { parsePullRequestReference } from "./pullRequestReference"; | |
| 4 | ||
| 5 | describe("parsePullRequestReference", () => { | |
| 6 | it("accepts GitHub pull request URLs", () => { | |
| 7 | expect( | |
| 8 | parsePullRequestReference("https://github.com/pingdotgg/t3code/pull/42"), | |
| 9 | ).toBe("https://github.com/pingdotgg/t3code/pull/42"); | |
| 10 | }); | |
| 11 | ||
| 12 | it("accepts raw numbers", () => { | |
| 13 | expect(parsePullRequestReference("42")).toBe("42"); | |
| 14 | }); | |
| 15 | ||
| 16 | it("accepts #number references", () => { | |
| 17 | expect(parsePullRequestReference("#42")).toBe("#42"); | |
| 18 | }); | |
| 19 | ||
| 20 | it("rejects non-pull-request input", () => { | |
| 21 | expect(parsePullRequestReference("feature/my-branch")).toBeNull(); | |
| 22 | }); | |
| 23 | }); |
apps/web/src/pullRequestReference.ts created+22| ... | ... | @@ -0,0 +1,22 @@ |
| 1 | const GITHUB_PULL_REQUEST_URL_PATTERN = | |
| 2 | /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; | |
| 3 | const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; | |
| 4 | ||
| 5 | export function parsePullRequestReference(input: string): string | null { | |
| 6 | const trimmed = input.trim(); | |
| 7 | if (trimmed.length === 0) { | |
| 8 | return null; | |
| 9 | } | |
| 10 | ||
| 11 | const urlMatch = GITHUB_PULL_REQUEST_URL_PATTERN.exec(trimmed); | |
| 12 | if (urlMatch?.[1]) { | |
| 13 | return trimmed; | |
| 14 | } | |
| 15 | ||
| 16 | const numberMatch = PULL_REQUEST_NUMBER_PATTERN.exec(trimmed); | |
| 17 | if (numberMatch?.[1]) { | |
| 18 | return trimmed.startsWith("#") ? trimmed : numberMatch[1]; | |
| 19 | } | |
| 20 | ||
| 21 | return null; | |
| 22 | } |
apps/web/src/wsNativeApi.ts+3| ... | ... | @@ -170,6 +170,9 @@ export function createWsNativeApi(): NativeApi { |
| 170 | 170 | createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input), |
| 171 | 171 | checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), |
| 172 | 172 | init: (input) => transport.request(WS_METHODS.gitInit, input), |
| 173 | resolvePullRequest: (input) => transport.request(WS_METHODS.gitResolvePullRequest, input), | |
| 174 | preparePullRequestThread: (input) => | |
| 175 | transport.request(WS_METHODS.gitPreparePullRequestThread, input), | |
| 173 | 176 | }, |
| 174 | 177 | contextMenu: { |
| 175 | 178 | show: async <T extends string>( |
packages/contracts/src/git.test.ts created+56| ... | ... | @@ -0,0 +1,56 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | import { Schema } from "effect"; | |
| 3 | ||
| 4 | import { | |
| 5 | GitCreateWorktreeInput, | |
| 6 | GitPreparePullRequestThreadInput, | |
| 7 | GitResolvePullRequestResult, | |
| 8 | } from "./git"; | |
| 9 | ||
| 10 | const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInput); | |
| 11 | const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync(GitPreparePullRequestThreadInput); | |
| 12 | const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); | |
| 13 | ||
| 14 | describe("GitCreateWorktreeInput", () => { | |
| 15 | it("accepts omitted newBranch for existing-branch worktrees", () => { | |
| 16 | const parsed = decodeCreateWorktreeInput({ | |
| 17 | cwd: "/repo", | |
| 18 | branch: "feature/existing", | |
| 19 | path: "/tmp/worktree", | |
| 20 | }); | |
| 21 | ||
| 22 | expect(parsed.newBranch).toBeUndefined(); | |
| 23 | expect(parsed.branch).toBe("feature/existing"); | |
| 24 | }); | |
| 25 | }); | |
| 26 | ||
| 27 | describe("GitPreparePullRequestThreadInput", () => { | |
| 28 | it("accepts pull request references and mode", () => { | |
| 29 | const parsed = decodePreparePullRequestThreadInput({ | |
| 30 | cwd: "/repo", | |
| 31 | reference: "#42", | |
| 32 | mode: "worktree", | |
| 33 | }); | |
| 34 | ||
| 35 | expect(parsed.reference).toBe("#42"); | |
| 36 | expect(parsed.mode).toBe("worktree"); | |
| 37 | }); | |
| 38 | }); | |
| 39 | ||
| 40 | describe("GitResolvePullRequestResult", () => { | |
| 41 | it("decodes resolved pull request metadata", () => { | |
| 42 | const parsed = decodeResolvePullRequestResult({ | |
| 43 | pullRequest: { | |
| 44 | number: 42, | |
| 45 | title: "PR threads", | |
| 46 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | |
| 47 | baseBranch: "main", | |
| 48 | headBranch: "feature/pr-threads", | |
| 49 | state: "open", | |
| 50 | }, | |
| 51 | }); | |
| 52 | ||
| 53 | expect(parsed.pullRequest.number).toBe(42); | |
| 54 | expect(parsed.pullRequest.headBranch).toBe("feature/pr-threads"); | |
| 55 | }); | |
| 56 | }); |
packages/contracts/src/git.ts+38-1| ... | ... | @@ -20,6 +20,9 @@ const GitPrStepStatus = Schema.Literals([ |
| 20 | 20 | "skipped_not_requested", |
| 21 | 21 | ]); |
| 22 | 22 | const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); |
| 23 | const GitPullRequestReference = TrimmedNonEmptyStringSchema; | |
| 24 | const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); | |
| 25 | const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]); | |
| 23 | 26 | |
| 24 | 27 | export const GitBranch = Schema.Struct({ |
| 25 | 28 | name: TrimmedNonEmptyStringSchema, |
| ... | ... | @@ -35,6 +38,15 @@ const GitWorktree = Schema.Struct({ |
| 35 | 38 | path: TrimmedNonEmptyStringSchema, |
| 36 | 39 | branch: TrimmedNonEmptyStringSchema, |
| 37 | 40 | }); |
| 41 | const GitResolvedPullRequest = Schema.Struct({ | |
| 42 | number: PositiveInt, | |
| 43 | title: TrimmedNonEmptyStringSchema, | |
| 44 | url: Schema.String, | |
| 45 | baseBranch: TrimmedNonEmptyStringSchema, | |
| 46 | headBranch: TrimmedNonEmptyStringSchema, | |
| 47 | state: GitPullRequestState, | |
| 48 | }); | |
| 49 | export type GitResolvedPullRequest = typeof GitResolvedPullRequest.Type; | |
| 38 | 50 | |
| 39 | 51 | // RPC Inputs |
| 40 | 52 | |
| ... | ... | @@ -64,11 +76,24 @@ export type GitListBranchesInput = typeof GitListBranchesInput.Type; |
| 64 | 76 | export const GitCreateWorktreeInput = Schema.Struct({ |
| 65 | 77 | cwd: TrimmedNonEmptyStringSchema, |
| 66 | 78 | branch: TrimmedNonEmptyStringSchema, |
| 67 | newBranch: TrimmedNonEmptyStringSchema, | |
| 79 | newBranch: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 68 | 80 | path: Schema.NullOr(TrimmedNonEmptyStringSchema), |
| 69 | 81 | }); |
| 70 | 82 | export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type; |
| 71 | 83 | |
| 84 | export const GitPullRequestRefInput = Schema.Struct({ | |
| 85 | cwd: TrimmedNonEmptyStringSchema, | |
| 86 | reference: GitPullRequestReference, | |
| 87 | }); | |
| 88 | export type GitPullRequestRefInput = typeof GitPullRequestRefInput.Type; | |
| 89 | ||
| 90 | export const GitPreparePullRequestThreadInput = Schema.Struct({ | |
| 91 | cwd: TrimmedNonEmptyStringSchema, | |
| 92 | reference: GitPullRequestReference, | |
| 93 | mode: GitPreparePullRequestThreadMode, | |
| 94 | }); | |
| 95 | export type GitPreparePullRequestThreadInput = typeof GitPreparePullRequestThreadInput.Type; | |
| 96 | ||
| 72 | 97 | export const GitRemoveWorktreeInput = Schema.Struct({ |
| 73 | 98 | cwd: TrimmedNonEmptyStringSchema, |
| 74 | 99 | path: TrimmedNonEmptyStringSchema, |
| ... | ... | @@ -136,6 +161,18 @@ export const GitCreateWorktreeResult = Schema.Struct({ |
| 136 | 161 | }); |
| 137 | 162 | export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type; |
| 138 | 163 | |
| 164 | export const GitResolvePullRequestResult = Schema.Struct({ | |
| 165 | pullRequest: GitResolvedPullRequest, | |
| 166 | }); | |
| 167 | export type GitResolvePullRequestResult = typeof GitResolvePullRequestResult.Type; | |
| 168 | ||
| 169 | export const GitPreparePullRequestThreadResult = Schema.Struct({ | |
| 170 | pullRequest: GitResolvedPullRequest, | |
| 171 | branch: TrimmedNonEmptyStringSchema, | |
| 172 | worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), | |
| 173 | }); | |
| 174 | export type GitPreparePullRequestThreadResult = typeof GitPreparePullRequestThreadResult.Type; | |
| 175 | ||
| 139 | 176 | export const GitRunStackedActionResult = Schema.Struct({ |
| 140 | 177 | action: GitStackedAction, |
| 141 | 178 | branch: Schema.Struct({ |
packages/contracts/src/ipc.ts+8| ... | ... | @@ -1,6 +1,9 @@ |
| 1 | 1 | import type { |
| 2 | 2 | GitCheckoutInput, |
| 3 | 3 | GitCreateBranchInput, |
| 4 | GitPreparePullRequestThreadInput, | |
| 5 | GitPreparePullRequestThreadResult, | |
| 6 | GitPullRequestRefInput, | |
| 4 | 7 | GitCreateWorktreeInput, |
| 5 | 8 | GitCreateWorktreeResult, |
| 6 | 9 | GitInitInput, |
| ... | ... | @@ -9,6 +12,7 @@ import type { |
| 9 | 12 | GitPullInput, |
| 10 | 13 | GitPullResult, |
| 11 | 14 | GitRemoveWorktreeInput, |
| 15 | GitResolvePullRequestResult, | |
| 12 | 16 | GitRunStackedActionInput, |
| 13 | 17 | GitRunStackedActionResult, |
| 14 | 18 | GitStatusInput, |
| ... | ... | @@ -134,6 +138,10 @@ export interface NativeApi { |
| 134 | 138 | createBranch: (input: GitCreateBranchInput) => Promise<void>; |
| 135 | 139 | checkout: (input: GitCheckoutInput) => Promise<void>; |
| 136 | 140 | init: (input: GitInitInput) => Promise<void>; |
| 141 | resolvePullRequest: (input: GitPullRequestRefInput) => Promise<GitResolvePullRequestResult>; | |
| 142 | preparePullRequestThread: ( | |
| 143 | input: GitPreparePullRequestThreadInput, | |
| 144 | ) => Promise<GitPreparePullRequestThreadResult>; | |
| 137 | 145 | // Stacked action API |
| 138 | 146 | pull: (input: GitPullInput) => Promise<GitPullResult>; |
| 139 | 147 | status: (input: GitStatusInput) => Promise<GitStatusResult>; |
packages/contracts/src/ws.test.ts+16| ... | ... | @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; |
| 2 | 2 | import { Effect, Schema } from "effect"; |
| 3 | 3 | |
| 4 | 4 | import { ORCHESTRATION_WS_METHODS } from "./orchestration"; |
| 5 | import { WS_METHODS } from "./ws"; | |
| 5 | 6 | import { WebSocketRequest } from "./ws"; |
| 6 | 7 | |
| 7 | 8 | const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest); |
| ... | ... | @@ -56,3 +57,18 @@ it.effect("trims websocket request id and nested orchestration ids", () => |
| 56 | 57 | } |
| 57 | 58 | }), |
| 58 | 59 | ); |
| 60 | ||
| 61 | it.effect("accepts git.preparePullRequestThread requests", () => | |
| 62 | Effect.gen(function* () { | |
| 63 | const parsed = yield* decodeWebSocketRequest({ | |
| 64 | id: "req-pr-1", | |
| 65 | body: { | |
| 66 | _tag: WS_METHODS.gitPreparePullRequestThread, | |
| 67 | cwd: "/repo", | |
| 68 | reference: "#42", | |
| 69 | mode: "worktree", | |
| 70 | }, | |
| 71 | }); | |
| 72 | assert.strictEqual(parsed.body._tag, WS_METHODS.gitPreparePullRequestThread); | |
| 73 | }), | |
| 74 | ); |
packages/contracts/src/ws.ts+6| ... | ... | @@ -12,10 +12,12 @@ import { |
| 12 | 12 | import { |
| 13 | 13 | GitCheckoutInput, |
| 14 | 14 | GitCreateBranchInput, |
| 15 | GitPreparePullRequestThreadInput, | |
| 15 | 16 | GitCreateWorktreeInput, |
| 16 | 17 | GitInitInput, |
| 17 | 18 | GitListBranchesInput, |
| 18 | 19 | GitPullInput, |
| 20 | GitPullRequestRefInput, | |
| 19 | 21 | GitRemoveWorktreeInput, |
| 20 | 22 | GitRunStackedActionInput, |
| 21 | 23 | GitStatusInput, |
| ... | ... | @@ -55,6 +57,8 @@ export const WS_METHODS = { |
| 55 | 57 | gitCreateBranch: "git.createBranch", |
| 56 | 58 | gitCheckout: "git.checkout", |
| 57 | 59 | gitInit: "git.init", |
| 60 | gitResolvePullRequest: "git.resolvePullRequest", | |
| 61 | gitPreparePullRequestThread: "git.preparePullRequestThread", | |
| 58 | 62 | |
| 59 | 63 | // Terminal methods |
| 60 | 64 | terminalOpen: "terminal.open", |
| ... | ... | @@ -117,6 +121,8 @@ const WebSocketRequestBody = Schema.Union([ |
| 117 | 121 | tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput), |
| 118 | 122 | tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput), |
| 119 | 123 | tagRequestBody(WS_METHODS.gitInit, GitInitInput), |
| 124 | tagRequestBody(WS_METHODS.gitResolvePullRequest, GitPullRequestRefInput), | |
| 125 | tagRequestBody(WS_METHODS.gitPreparePullRequestThread, GitPreparePullRequestThreadInput), | |
| 120 | 126 | |
| 121 | 127 | // Terminal methods |
| 122 | 128 | tagRequestBody(WS_METHODS.terminalOpen, TerminalOpenInput), |