| 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,6 +105,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => |
| 105 | readConfigValue: (cwd, key) => core.readConfigValue(cwd, key), | 105 | readConfigValue: (cwd, key) => core.readConfigValue(cwd, key), |
| 106 | listBranches: (input) => core.listBranches(input), | 106 | listBranches: (input) => core.listBranches(input), |
| 107 | createWorktree: (input) => core.createWorktree(input), | 107 | createWorktree: (input) => core.createWorktree(input), |
| 108 | fetchPullRequestBranch: (input) => core.fetchPullRequestBranch(input), | ||
| 108 | removeWorktree: (input) => core.removeWorktree(input), | 109 | removeWorktree: (input) => core.removeWorktree(input), |
| 109 | renameBranch: (input) => core.renameBranch(input), | 110 | renameBranch: (input) => core.renameBranch(input), |
| 110 | createBranch: (input) => core.createBranch(input), | 111 | createBranch: (input) => core.createBranch(input), |
| ... | @@ -149,6 +150,15 @@ function createGitWorktree(input: Parameters<GitCoreShape["createWorktree"]>[0]) | ... | @@ -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 | function removeGitWorktree(input: Parameters<GitCoreShape["removeWorktree"]>[0]) { | 162 | function removeGitWorktree(input: Parameters<GitCoreShape["removeWorktree"]>[0]) { |
| 153 | return Effect.gen(function* () { | 163 | return Effect.gen(function* () { |
| 154 | const core = yield* GitCore; | 164 | const core = yield* GitCore; |
| ... | @@ -1006,6 +1016,28 @@ it.layer(TestLayer)("git integration", (it) => { | ... | @@ -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 | it.effect("throws when new branch name already exists", () => | 1041 | it.effect("throws when new branch name already exists", () => |
| 1010 | Effect.gen(function* () { | 1042 | Effect.gen(function* () { |
| 1011 | const tmp = yield* makeTmpDir(); | 1043 | const tmp = yield* makeTmpDir(); |
| ... | @@ -1168,6 +1200,37 @@ it.layer(TestLayer)("git integration", (it) => { | ... | @@ -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 | // ── Full flow: thread switching simulation ── | 1234 | // ── Full flow: thread switching simulation ── |
| 1172 | 1235 | ||
| 1173 | describe("full flow: thread switching (checkout toggling)", () => { | 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,6 +418,29 @@ const makeGitCore = Effect.gen(function* () { |
| 418 | allowNonZeroExit: true, | 418 | allowNonZeroExit: true, |
| 419 | }).pipe(Effect.map((result) => result.code === 0)); | 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 | const resolveBaseBranchForNoUpstream = ( | 444 | const resolveBaseBranchForNoUpstream = ( |
| 422 | cwd: string, | 445 | cwd: string, |
| 423 | branch: string, | 446 | branch: string, |
| ... | @@ -1015,16 +1038,20 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -1015,16 +1038,20 @@ const makeGitCore = Effect.gen(function* () { |
| 1015 | 1038 | ||
| 1016 | const createWorktree: GitCoreShape["createWorktree"] = (input) => | 1039 | const createWorktree: GitCoreShape["createWorktree"] = (input) => |
| 1017 | Effect.gen(function* () { | 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 | const repoName = path.basename(input.cwd); | 1043 | const repoName = path.basename(input.cwd); |
| 1020 | const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp"; | 1044 | const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp"; |
| 1021 | const worktreePath = | 1045 | const worktreePath = |
| 1022 | input.path ?? path.join(homeDir, ".t3", "worktrees", repoName, sanitizedBranch); | 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 | yield* executeGit( | 1051 | yield* executeGit( |
| 1025 | "GitCore.createWorktree", | 1052 | "GitCore.createWorktree", |
| 1026 | input.cwd, | 1053 | input.cwd, |
| 1027 | ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch], | 1054 | args, |
| 1028 | { | 1055 | { |
| 1029 | fallbackErrorMessage: "git worktree add failed", | 1056 | fallbackErrorMessage: "git worktree add failed", |
| 1030 | }, | 1057 | }, |
| ... | @@ -1033,11 +1060,30 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -1033,11 +1060,30 @@ const makeGitCore = Effect.gen(function* () { |
| 1033 | return { | 1060 | return { |
| 1034 | worktree: { | 1061 | worktree: { |
| 1035 | path: worktreePath, | 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 | const removeWorktree: GitCoreShape["removeWorktree"] = (input) => | 1087 | const removeWorktree: GitCoreShape["removeWorktree"] = (input) => |
| 1042 | Effect.gen(function* () { | 1088 | Effect.gen(function* () { |
| 1043 | const args = ["worktree", "remove"]; | 1089 | const args = ["worktree", "remove"]; |
| ... | @@ -1197,6 +1243,7 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -1197,6 +1243,7 @@ const makeGitCore = Effect.gen(function* () { |
| 1197 | readConfigValue, | 1243 | readConfigValue, |
| 1198 | listBranches, | 1244 | listBranches, |
| 1199 | createWorktree, | 1245 | createWorktree, |
| 1246 | fetchPullRequestBranch, | ||
| 1200 | removeWorktree, | 1247 | removeWorktree, |
| 1201 | renameBranch, | 1248 | renameBranch, |
| 1202 | createBranch, | 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,7 +2,11 @@ import { Effect, Layer } from "effect"; |
| 2 | 2 | ||
| 3 | import { runProcess } from "../../processRunner"; | 3 | import { runProcess } from "../../processRunner"; |
| 4 | import { GitHubCliError } from "../Errors.ts"; | 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 | const DEFAULT_TIMEOUT_MS = 30_000; | 11 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 8 | 12 | ||
| ... | @@ -30,6 +34,19 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown | ... | @@ -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 | return new GitHubCliError({ | 50 | return new GitHubCliError({ |
| 34 | operation, | 51 | operation, |
| 35 | detail: `GitHub CLI command failed: ${error.message}`, | 52 | detail: `GitHub CLI command failed: ${error.message}`, |
| ... | @@ -44,13 +61,52 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown | ... | @@ -44,13 +61,52 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown |
| 44 | }); | 61 | }); |
| 45 | } | 62 | } |
| 46 | 63 | ||
| 47 | function parseOpenPullRequests(raw: string): ReadonlyArray<{ | 64 | function normalizePullRequestState(record: Record<string, unknown>): "open" | "closed" | "merged" { |
| 48 | number: number; | 65 | const mergedAt = record.mergedAt; |
| 49 | title: string; | 66 | const state = record.state; |
| 50 | url: string; | 67 | if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { |
| 51 | baseRefName: string; | 68 | return "merged"; |
| 52 | headRefName: string; | 69 | } |
| 53 | }> { | 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 | const trimmed = raw.trim(); | 110 | const trimmed = raw.trim(); |
| 55 | if (trimmed.length === 0) return []; | 111 | if (trimmed.length === 0) return []; |
| 56 | 112 | ||
| ... | @@ -59,41 +115,12 @@ function parseOpenPullRequests(raw: string): ReadonlyArray<{ | ... | @@ -59,41 +115,12 @@ function parseOpenPullRequests(raw: string): ReadonlyArray<{ |
| 59 | throw new Error("GitHub CLI returned non-array JSON."); | 115 | throw new Error("GitHub CLI returned non-array JSON."); |
| 60 | } | 116 | } |
| 61 | 117 | ||
| 62 | const result: Array<{ | 118 | const result: Array<GitHubPullRequestSummary> = []; |
| 63 | number: number; | ||
| 64 | title: string; | ||
| 65 | url: string; | ||
| 66 | baseRefName: string; | ||
| 67 | headRefName: string; | ||
| 68 | }> = []; | ||
| 69 | for (const entry of parsed) { | 119 | for (const entry of parsed) { |
| 70 | if (!entry || typeof entry !== "object") { | 120 | const parsedEntry = parsePullRequestSummary(entry); |
| 71 | continue; | 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 | return result; | 126 | return result; |
| ... | @@ -144,6 +171,40 @@ const makeGitHubCli = Effect.sync(() => { | ... | @@ -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 | createPullRequest: (input) => | 208 | createPullRequest: (input) => |
| 148 | execute({ | 209 | execute({ |
| 149 | cwd: input.cwd, | 210 | cwd: input.cwd, |
| ... | @@ -170,6 +231,16 @@ const makeGitHubCli = Effect.sync(() => { | ... | @@ -170,6 +231,16 @@ const makeGitHubCli = Effect.sync(() => { |
| 170 | return trimmed.length > 0 ? trimmed : null; | 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 | } satisfies GitHubCliShape; | 244 | } satisfies GitHubCliShape; |
| 174 | 245 | ||
| 175 | return service; | 246 | return service; |
apps/server/src/git/Layers/GitManager.test.ts+247| ... | @@ -23,6 +23,14 @@ interface FakeGhScenario { | ... | @@ -23,6 +23,14 @@ interface FakeGhScenario { |
| 23 | prListSequence?: string[]; | 23 | prListSequence?: string[]; |
| 24 | createdPrUrl?: string; | 24 | createdPrUrl?: string; |
| 25 | defaultBranch?: string; | 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 | failWith?: GitHubCliError; | 34 | failWith?: GitHubCliError; |
| 27 | } | 35 | } |
| 28 | 36 | ||
| ... | @@ -203,6 +211,26 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | ... | @@ -203,6 +211,26 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 203 | } | 211 | } |
| 204 | 212 | ||
| 205 | if (args[0] === "pr" && args[1] === "view") { | 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 | return Effect.succeed({ | 234 | return Effect.succeed({ |
| 207 | stdout: "", | 235 | stdout: "", |
| 208 | stderr: "", | 236 | stderr: "", |
| ... | @@ -279,6 +307,29 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | ... | @@ -279,6 +307,29 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 279 | return value.length > 0 ? value : null; | 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 | ghCalls, | 334 | ghCalls, |
| 284 | }; | 335 | }; |
| ... | @@ -296,6 +347,17 @@ function runStackedAction( | ... | @@ -296,6 +347,17 @@ function runStackedAction( |
| 296 | return manager.runStackedAction(input); | 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 | function makeManager(input?: { | 361 | function makeManager(input?: { |
| 300 | ghScenario?: FakeGhScenario; | 362 | ghScenario?: FakeGhScenario; |
| 301 | textGeneration?: Partial<FakeGitTextGeneration>; | 363 | textGeneration?: Partial<FakeGitTextGeneration>; |
| ... | @@ -924,4 +986,189 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -924,4 +986,189 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 924 | expect(errorMessage).toContain("gh auth login"); | 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,4 +1,5 @@ |
| 1 | import { randomUUID } from "node:crypto"; | 1 | import { randomUUID } from "node:crypto"; |
| 2 | import { realpathSync } from "node:fs"; | ||
| 2 | 3 | ||
| 3 | import { Effect, FileSystem, Layer, Path } from "effect"; | 4 | import { Effect, FileSystem, Layer, Path } from "effect"; |
| 4 | import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | 5 | import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| ... | @@ -22,6 +23,15 @@ interface PullRequestInfo extends OpenPrInfo { | ... | @@ -22,6 +23,15 @@ interface PullRequestInfo extends OpenPrInfo { |
| 22 | updatedAt: string | null; | 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 | function parsePullRequestList(raw: unknown): PullRequestInfo[] { | 35 | function parsePullRequestList(raw: unknown): PullRequestInfo[] { |
| 26 | if (!Array.isArray(raw)) return []; | 36 | if (!Array.isArray(raw)) return []; |
| 27 | 37 | ||
| ... | @@ -175,6 +185,38 @@ function toStatusPr(pr: PullRequestInfo): { | ... | @@ -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 | export const makeGitManager = Effect.gen(function* () { | 220 | export const makeGitManager = Effect.gen(function* () { |
| 179 | const gitCore = yield* GitCore; | 221 | const gitCore = yield* GitCore; |
| 180 | const gitHubCli = yield* GitHubCli; | 222 | const gitHubCli = yield* GitHubCli; |
| ... | @@ -445,6 +487,115 @@ export const makeGitManager = Effect.gen(function* () { | ... | @@ -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 | const runFeatureBranchStep = (cwd: string, branch: string | null, commitMessage?: string) => | 599 | const runFeatureBranchStep = (cwd: string, branch: string | null, commitMessage?: string) => |
| 449 | Effect.gen(function* () { | 600 | Effect.gen(function* () { |
| 450 | const suggestion = yield* resolveCommitAndBranchSuggestion({ | 601 | const suggestion = yield* resolveCommitAndBranchSuggestion({ |
| ... | @@ -536,6 +687,8 @@ export const makeGitManager = Effect.gen(function* () { | ... | @@ -536,6 +687,8 @@ export const makeGitManager = Effect.gen(function* () { |
| 536 | 687 | ||
| 537 | return { | 688 | return { |
| 538 | status, | 689 | status, |
| 690 | resolvePullRequest, | ||
| 691 | preparePullRequestThread, | ||
| 539 | runStackedAction, | 692 | runStackedAction, |
| 540 | } satisfies GitManagerShape; | 693 | } satisfies GitManagerShape; |
| 541 | }); | 694 | }); |
apps/server/src/git/Services/GitCore.ts+13| ... | @@ -56,6 +56,12 @@ export interface GitRenameBranchResult { | ... | @@ -56,6 +56,12 @@ export interface GitRenameBranchResult { |
| 56 | branch: string; | 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 | * GitCoreShape - Service API for low-level Git repository interactions. | 66 | * GitCoreShape - Service API for low-level Git repository interactions. |
| 61 | */ | 67 | */ |
| ... | @@ -129,6 +135,13 @@ export interface GitCoreShape { | ... | @@ -129,6 +135,13 @@ export interface GitCoreShape { |
| 129 | input: GitCreateWorktreeInput, | 135 | input: GitCreateWorktreeInput, |
| 130 | ) => Effect.Effect<GitCreateWorktreeResult, GitCommandError>; | 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 | * Remove an existing worktree. | 146 | * Remove an existing worktree. |
| 134 | */ | 147 | */ |
apps/server/src/git/Services/GitHubCli.ts+18| ... | @@ -17,6 +17,7 @@ export interface GitHubPullRequestSummary { | ... | @@ -17,6 +17,7 @@ export interface GitHubPullRequestSummary { |
| 17 | readonly url: string; | 17 | readonly url: string; |
| 18 | readonly baseRefName: string; | 18 | readonly baseRefName: string; |
| 19 | readonly headRefName: string; | 19 | readonly headRefName: string; |
| 20 | readonly state?: "open" | "closed" | "merged"; | ||
| 20 | } | 21 | } |
| 21 | 22 | ||
| 22 | /** | 23 | /** |
| ... | @@ -41,6 +42,14 @@ export interface GitHubCliShape { | ... | @@ -41,6 +42,14 @@ export interface GitHubCliShape { |
| 41 | readonly limit?: number; | 42 | readonly limit?: number; |
| 42 | }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>; | 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 | * Create a pull request from branch context and body file. | 54 | * Create a pull request from branch context and body file. |
| 46 | */ | 55 | */ |
| ... | @@ -58,6 +67,15 @@ export interface GitHubCliShape { | ... | @@ -58,6 +67,15 @@ export interface GitHubCliShape { |
| 58 | readonly getDefaultBranch: (input: { | 67 | readonly getDefaultBranch: (input: { |
| 59 | readonly cwd: string; | 68 | readonly cwd: string; |
| 60 | }) => Effect.Effect<string | null, GitHubCliError>; | 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,6 +7,10 @@ |
| 7 | * @module GitManager | 7 | * @module GitManager |
| 8 | */ | 8 | */ |
| 9 | import { | 9 | import { |
| 10 | GitPreparePullRequestThreadInput, | ||
| 11 | GitPreparePullRequestThreadResult, | ||
| 12 | GitPullRequestRefInput, | ||
| 13 | GitResolvePullRequestResult, | ||
| 10 | GitRunStackedActionInput, | 14 | GitRunStackedActionInput, |
| 11 | GitRunStackedActionResult, | 15 | GitRunStackedActionResult, |
| 12 | GitStatusInput, | 16 | GitStatusInput, |
| ... | @@ -27,6 +31,20 @@ export interface GitManagerShape { | ... | @@ -27,6 +31,20 @@ export interface GitManagerShape { |
| 27 | input: GitStatusInput, | 31 | input: GitStatusInput, |
| 28 | ) => Effect.Effect<GitStatusResult, GitManagerServiceError>; | 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 | * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`). | 49 | * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`). |
| 32 | * When `featureBranch` is set, creates and checks out a feature branch first. | 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,7 +1638,14 @@ describe("WebSocket Server", () => { |
| 1638 | 1638 | ||
| 1639 | const status = vi.fn(() => Effect.succeed(statusResult)); | 1639 | const status = vi.fn(() => Effect.succeed(statusResult)); |
| 1640 | const runStackedAction = vi.fn(() => Effect.void as any); | 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 | server = await createTestServer({ cwd: "/test", gitManager }); | 1650 | server = await createTestServer({ cwd: "/test", gitManager }); |
| 1644 | const addr = server.address(); | 1651 | const addr = server.address(); |
| ... | @@ -1656,6 +1663,63 @@ describe("WebSocket Server", () => { | ... | @@ -1656,6 +1663,63 @@ describe("WebSocket Server", () => { |
| 1656 | expect(status).toHaveBeenCalledWith({ cwd: "/test" }); | 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 | it("returns errors from git.runStackedAction", async () => { | 1723 | it("returns errors from git.runStackedAction", async () => { |
| 1660 | const runStackedAction = vi.fn(() => | 1724 | const runStackedAction = vi.fn(() => |
| 1661 | Effect.fail( | 1725 | Effect.fail( |
| ... | @@ -1667,6 +1731,8 @@ describe("WebSocket Server", () => { | ... | @@ -1667,6 +1731,8 @@ describe("WebSocket Server", () => { |
| 1667 | ); | 1731 | ); |
| 1668 | const gitManager: GitManagerShape = { | 1732 | const gitManager: GitManagerShape = { |
| 1669 | status: vi.fn(() => Effect.void as any), | 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 | runStackedAction, | 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,6 +832,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 832 | return yield* gitManager.runStackedAction(body); | 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 | case WS_METHODS.gitListBranches: { | 845 | case WS_METHODS.gitListBranches: { |
| 836 | const body = stripRequestTag(request.body); | 846 | const body = stripRequestTag(request.body); |
| 837 | return yield* git.listBranches(body); | 847 | return yield* git.listBranches(body); |
apps/web/src/components/BranchToolbar.tsx+5| ... | @@ -17,6 +17,7 @@ interface BranchToolbarProps { | ... | @@ -17,6 +17,7 @@ interface BranchToolbarProps { |
| 17 | threadId: ThreadId; | 17 | threadId: ThreadId; |
| 18 | onEnvModeChange: (mode: EnvMode) => void; | 18 | onEnvModeChange: (mode: EnvMode) => void; |
| 19 | envLocked: boolean; | 19 | envLocked: boolean; |
| 20 | onCheckoutPullRequestRequest?: (reference: string) => void; | ||
| 20 | onComposerFocusRequest?: () => void; | 21 | onComposerFocusRequest?: () => void; |
| 21 | } | 22 | } |
| 22 | 23 | ||
| ... | @@ -24,6 +25,7 @@ export default function BranchToolbar({ | ... | @@ -24,6 +25,7 @@ export default function BranchToolbar({ |
| 24 | threadId, | 25 | threadId, |
| 25 | onEnvModeChange, | 26 | onEnvModeChange, |
| 26 | envLocked, | 27 | envLocked, |
| 28 | onCheckoutPullRequestRequest, | ||
| 27 | onComposerFocusRequest, | 29 | onComposerFocusRequest, |
| 28 | }: BranchToolbarProps) { | 30 | }: BranchToolbarProps) { |
| 29 | const threads = useStore((store) => store.threads); | 31 | const threads = useStore((store) => store.threads); |
| ... | @@ -128,6 +130,9 @@ export default function BranchToolbar({ | ... | @@ -128,6 +130,9 @@ export default function BranchToolbar({ |
| 128 | effectiveEnvMode={effectiveEnvMode} | 130 | effectiveEnvMode={effectiveEnvMode} |
| 129 | envLocked={envLocked} | 131 | envLocked={envLocked} |
| 130 | onSetThreadBranch={setThreadBranch} | 132 | onSetThreadBranch={setThreadBranch} |
| 133 | {...(onCheckoutPullRequestRequest | ||
| 134 | ? { onCheckoutPullRequestRequest } | ||
| 135 | : {})} | ||
| 131 | {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} | 136 | {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} |
| 132 | /> | 137 | /> |
| 133 | </div> | 138 | </div> |
apps/web/src/components/BranchToolbarBranchSelector.tsx+49-5| ... | @@ -14,6 +14,7 @@ import { | ... | @@ -14,6 +14,7 @@ import { |
| 14 | 14 | ||
| 15 | import { gitBranchesQueryOptions, gitQueryKeys, invalidateGitQueries } from "../lib/gitReactQuery"; | 15 | import { gitBranchesQueryOptions, gitQueryKeys, invalidateGitQueries } from "../lib/gitReactQuery"; |
| 16 | import { readNativeApi } from "../nativeApi"; | 16 | import { readNativeApi } from "../nativeApi"; |
| 17 | import { parsePullRequestReference } from "../pullRequestReference"; | ||
| 17 | import { | 18 | import { |
| 18 | dedupeRemoteBranchesWithLocalMatches, | 19 | dedupeRemoteBranchesWithLocalMatches, |
| 19 | deriveLocalBranchNameFromRemoteRef, | 20 | deriveLocalBranchNameFromRemoteRef, |
| ... | @@ -40,6 +41,7 @@ interface BranchToolbarBranchSelectorProps { | ... | @@ -40,6 +41,7 @@ interface BranchToolbarBranchSelectorProps { |
| 40 | effectiveEnvMode: EnvMode; | 41 | effectiveEnvMode: EnvMode; |
| 41 | envLocked: boolean; | 42 | envLocked: boolean; |
| 42 | onSetThreadBranch: (branch: string | null, worktreePath: string | null) => void; | 43 | onSetThreadBranch: (branch: string | null, worktreePath: string | null) => void; |
| 44 | onCheckoutPullRequestRequest?: (reference: string) => void; | ||
| 43 | onComposerFocusRequest?: () => void; | 45 | onComposerFocusRequest?: () => void; |
| 44 | } | 46 | } |
| 45 | 47 | ||
| ... | @@ -70,6 +72,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -70,6 +72,7 @@ export function BranchToolbarBranchSelector({ |
| 70 | effectiveEnvMode, | 72 | effectiveEnvMode, |
| 71 | envLocked, | 73 | envLocked, |
| 72 | onSetThreadBranch, | 74 | onSetThreadBranch, |
| 75 | onCheckoutPullRequestRequest, | ||
| 73 | onComposerFocusRequest, | 76 | onComposerFocusRequest, |
| 74 | }: BranchToolbarBranchSelectorProps) { | 77 | }: BranchToolbarBranchSelectorProps) { |
| 75 | const queryClient = useQueryClient(); | 78 | const queryClient = useQueryClient(); |
| ... | @@ -95,17 +98,27 @@ export function BranchToolbarBranchSelector({ | ... | @@ -95,17 +98,27 @@ export function BranchToolbarBranchSelector({ |
| 95 | ); | 98 | ); |
| 96 | const trimmedBranchQuery = branchQuery.trim(); | 99 | const trimmedBranchQuery = branchQuery.trim(); |
| 97 | const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); | 100 | const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); |
| 101 | const prReference = parsePullRequestReference(trimmedBranchQuery); | ||
| 102 | const checkoutPullRequestItemValue = prReference && onCheckoutPullRequestRequest | ||
| 103 | ? `__checkout_pull_request__:${prReference}` | ||
| 104 | : null; | ||
| 98 | const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0; | 105 | const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0; |
| 99 | const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); | 106 | const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); |
| 100 | const createBranchItemValue = canCreateBranch | 107 | const createBranchItemValue = canCreateBranch |
| 101 | ? `__create_new_branch__:${trimmedBranchQuery}` | 108 | ? `__create_new_branch__:${trimmedBranchQuery}` |
| 102 | : null; | 109 | : null; |
| 103 | const branchPickerItems = useMemo( | 110 | const branchPickerItems = useMemo( |
| 104 | () => | 111 | () => { |
| 105 | createBranchItemValue && !hasExactBranchMatch | 112 | const items = [...branchNames]; |
| 106 | ? [...branchNames, createBranchItemValue] | 113 | if (createBranchItemValue && !hasExactBranchMatch) { |
| 107 | : branchNames, | 114 | items.push(createBranchItemValue); |
| 108 | [branchNames, createBranchItemValue, hasExactBranchMatch], | 115 | } |
| 116 | if (checkoutPullRequestItemValue) { | ||
| 117 | items.unshift(checkoutPullRequestItemValue); | ||
| 118 | } | ||
| 119 | return items; | ||
| 120 | }, | ||
| 121 | [branchNames, checkoutPullRequestItemValue, createBranchItemValue, hasExactBranchMatch], | ||
| 109 | ); | 122 | ); |
| 110 | const filteredBranchPickerItems = useMemo( | 123 | const filteredBranchPickerItems = useMemo( |
| 111 | () => | 124 | () => |
| ... | @@ -338,6 +351,37 @@ export function BranchToolbarBranchSelector({ | ... | @@ -338,6 +351,37 @@ export function BranchToolbarBranchSelector({ |
| 338 | {virtualBranchRows.map((virtualRow) => { | 351 | {virtualBranchRows.map((virtualRow) => { |
| 339 | const itemValue = filteredBranchPickerItems[virtualRow.index]; | 352 | const itemValue = filteredBranchPickerItems[virtualRow.index]; |
| 340 | if (!itemValue) return null; | 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 | if (createBranchItemValue && itemValue === createBranchItemValue) { | 385 | if (createBranchItemValue && itemValue === createBranchItemValue) { |
| 342 | return ( | 386 | return ( |
| 343 | <ComboboxItem | 387 | <ComboboxItem |
apps/web/src/components/ChatView.tsx+153-20| ... | @@ -119,6 +119,7 @@ import { | ... | @@ -119,6 +119,7 @@ import { |
| 119 | type TurnDiffTreeNode, | 119 | type TurnDiffTreeNode, |
| 120 | } from "../lib/turnDiffTree"; | 120 | } from "../lib/turnDiffTree"; |
| 121 | import BranchToolbar from "./BranchToolbar"; | 121 | import BranchToolbar from "./BranchToolbar"; |
| 122 | import { ComposerPullRequestCommandMenu } from "./ComposerPullRequestCommandMenu"; | ||
| 122 | import GitActionsControl from "./GitActionsControl"; | 123 | import GitActionsControl from "./GitActionsControl"; |
| 123 | import { | 124 | import { |
| 124 | isOpenFavoriteEditorShortcut, | 125 | isOpenFavoriteEditorShortcut, |
| ... | @@ -407,6 +408,11 @@ type ComposerCommandItem = | ... | @@ -407,6 +408,11 @@ type ComposerCommandItem = |
| 407 | 408 | ||
| 408 | type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; | 409 | type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; |
| 409 | 410 | ||
| 411 | interface PullRequestComposerMenuState { | ||
| 412 | initialReference: string | null; | ||
| 413 | key: number; | ||
| 414 | } | ||
| 415 | |||
| 410 | function readFileAsDataUrl(file: File): Promise<string> { | 416 | function readFileAsDataUrl(file: File): Promise<string> { |
| 411 | return new Promise((resolve, reject) => { | 417 | return new Promise((resolve, reject) => { |
| 412 | const reader = new FileReader(); | 418 | const reader = new FileReader(); |
| ... | @@ -609,6 +615,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -609,6 +615,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 609 | const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); | 615 | const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); |
| 610 | const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread); | 616 | const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread); |
| 611 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | 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 | const draftThread = useComposerDraftStore( | 624 | const draftThread = useComposerDraftStore( |
| 613 | (store) => store.draftThreadsByThreadId[threadId] ?? null, | 625 | (store) => store.draftThreadsByThreadId[threadId] ?? null, |
| 614 | ); | 626 | ); |
| ... | @@ -645,6 +657,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -645,6 +657,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 645 | const [nowTick, setNowTick] = useState(() => Date.now()); | 657 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 646 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); | 658 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 647 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); | 659 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); |
| 660 | const [pullRequestComposerMenuState, setPullRequestComposerMenuState] = | ||
| 661 | useState<PullRequestComposerMenuState | null>(null); | ||
| 648 | const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< | 662 | const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< |
| 649 | Record<string, string[]> | 663 | Record<string, string[]> |
| 650 | >({}); | 664 | >({}); |
| ... | @@ -749,6 +763,80 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -749,6 +763,80 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 749 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); | 763 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 750 | const activeProject = projects.find((p) => p.id === activeThread?.projectId); | 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 | useEffect(() => { | 840 | useEffect(() => { |
| 753 | if (!activeThread?.id) return; | 841 | if (!activeThread?.id) return; |
| 754 | if (!latestTurnSettled) return; | 842 | if (!latestTurnSettled) return; |
| ... | @@ -1162,6 +1250,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1162,6 +1250,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1162 | timelineEntries, | 1250 | timelineEntries, |
| 1163 | ]); | 1251 | ]); |
| 1164 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; | 1252 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; |
| 1253 | const pullRequestComposerMenuOpen = pullRequestComposerMenuState !== null; | ||
| 1165 | const composerTriggerKind = composerTrigger?.kind ?? null; | 1254 | const composerTriggerKind = composerTrigger?.kind ?? null; |
| 1166 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; | 1255 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; |
| 1167 | const isPathTrigger = composerTriggerKind === "path"; | 1256 | const isPathTrigger = composerTriggerKind === "path"; |
| ... | @@ -1218,6 +1307,13 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1218,6 +1307,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1218 | label: "/default", | 1307 | label: "/default", |
| 1219 | description: "Switch this thread back to normal chat mode", | 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 | ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>; | 1317 | ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>; |
| 1222 | const query = composerTrigger.query.trim().toLowerCase(); | 1318 | const query = composerTrigger.query.trim().toLowerCase(); |
| 1223 | if (!query) { | 1319 | if (!query) { |
| ... | @@ -1245,13 +1341,15 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1245,13 +1341,15 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1245 | description: `${providerLabel} · ${slug}`, | 1341 | description: `${providerLabel} · ${slug}`, |
| 1246 | })); | 1342 | })); |
| 1247 | }, [composerTrigger, searchableModelOptions, workspaceEntries]); | 1343 | }, [composerTrigger, searchableModelOptions, workspaceEntries]); |
| 1248 | const composerMenuOpen = Boolean(composerTrigger); | 1344 | const composerMenuOpen = Boolean(composerTrigger) || pullRequestComposerMenuOpen; |
| 1249 | const activeComposerMenuItem = useMemo( | 1345 | const activeComposerMenuItem = useMemo( |
| 1250 | () => | 1346 | () => |
| 1251 | composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? | 1347 | pullRequestComposerMenuOpen |
| 1252 | composerMenuItems[0] ?? | 1348 | ? null |
| 1253 | null, | 1349 | : (composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? |
| 1254 | [composerHighlightedItemId, composerMenuItems], | 1350 | composerMenuItems[0] ?? |
| 1351 | null), | ||
| 1352 | [composerHighlightedItemId, composerMenuItems, pullRequestComposerMenuOpen], | ||
| 1255 | ); | 1353 | ); |
| 1256 | composerMenuOpenRef.current = composerMenuOpen; | 1354 | composerMenuOpenRef.current = composerMenuOpen; |
| 1257 | composerMenuItemsRef.current = composerMenuItems; | 1355 | composerMenuItemsRef.current = composerMenuItems; |
| ... | @@ -1950,6 +2048,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1950,6 +2048,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1950 | 2048 | ||
| 1951 | useEffect(() => { | 2049 | useEffect(() => { |
| 1952 | setExpandedWorkGroups({}); | 2050 | setExpandedWorkGroups({}); |
| 2051 | setPullRequestComposerMenuState(null); | ||
| 1953 | if (planSidebarOpenOnNextThreadRef.current) { | 2052 | if (planSidebarOpenOnNextThreadRef.current) { |
| 1954 | planSidebarOpenOnNextThreadRef.current = false; | 2053 | planSidebarOpenOnNextThreadRef.current = false; |
| 1955 | setPlanSidebarOpen(true); | 2054 | setPlanSidebarOpen(true); |
| ... | @@ -1964,12 +2063,16 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1964,12 +2063,16 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1964 | setComposerHighlightedItemId(null); | 2063 | setComposerHighlightedItemId(null); |
| 1965 | return; | 2064 | return; |
| 1966 | } | 2065 | } |
| 2066 | if (pullRequestComposerMenuOpen) { | ||
| 2067 | setComposerHighlightedItemId(null); | ||
| 2068 | return; | ||
| 2069 | } | ||
| 1967 | setComposerHighlightedItemId((existing) => | 2070 | setComposerHighlightedItemId((existing) => |
| 1968 | existing && composerMenuItems.some((item) => item.id === existing) | 2071 | existing && composerMenuItems.some((item) => item.id === existing) |
| 1969 | ? existing | 2072 | ? existing |
| 1970 | : (composerMenuItems[0]?.id ?? null), | 2073 | : (composerMenuItems[0]?.id ?? null), |
| 1971 | ); | 2074 | ); |
| 1972 | }, [composerMenuItems, composerMenuOpen]); | 2075 | }, [composerMenuItems, composerMenuOpen, pullRequestComposerMenuOpen]); |
| 1973 | 2076 | ||
| 1974 | useEffect(() => { | 2077 | useEffect(() => { |
| 1975 | setIsRevertingCheckpoint(false); | 2078 | setIsRevertingCheckpoint(false); |
| ... | @@ -2480,7 +2583,11 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2480,7 +2583,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2480 | const standaloneSlashCommand = | 2583 | const standaloneSlashCommand = |
| 2481 | composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; | 2584 | composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; |
| 2482 | if (standaloneSlashCommand) { | 2585 | if (standaloneSlashCommand) { |
| 2483 | await handleInteractionModeChange(standaloneSlashCommand); | 2586 | if (standaloneSlashCommand === "checkout-pr") { |
| 2587 | openPullRequestComposerMenu(); | ||
| 2588 | } else { | ||
| 2589 | await handleInteractionModeChange(standaloneSlashCommand); | ||
| 2590 | } | ||
| 2484 | promptRef.current = ""; | 2591 | promptRef.current = ""; |
| 2485 | clearComposerDraftContent(activeThread.id); | 2592 | clearComposerDraftContent(activeThread.id); |
| 2486 | setComposerHighlightedItemId(null); | 2593 | setComposerHighlightedItemId(null); |
| ... | @@ -3269,7 +3376,11 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3269,7 +3376,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3269 | } | 3376 | } |
| 3270 | return; | 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 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { | 3384 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { |
| 3274 | expectedText: expectedToken, | 3385 | expectedText: expectedToken, |
| 3275 | }); | 3386 | }); |
| ... | @@ -3289,6 +3400,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3289,6 +3400,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3289 | [ | 3400 | [ |
| 3290 | applyPromptReplacement, | 3401 | applyPromptReplacement, |
| 3291 | handleInteractionModeChange, | 3402 | handleInteractionModeChange, |
| 3403 | openPullRequestComposerMenu, | ||
| 3292 | onProviderModelSelect, | 3404 | onProviderModelSelect, |
| 3293 | resolveActiveComposerTrigger, | 3405 | resolveActiveComposerTrigger, |
| 3294 | ], | 3406 | ], |
| ... | @@ -3363,6 +3475,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3363,6 +3475,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3363 | const { trigger } = resolveActiveComposerTrigger(); | 3475 | const { trigger } = resolveActiveComposerTrigger(); |
| 3364 | const menuIsActive = composerMenuOpenRef.current || trigger !== null; | 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 | if (menuIsActive) { | 3484 | if (menuIsActive) { |
| 3367 | const currentItems = composerMenuItemsRef.current; | 3485 | const currentItems = composerMenuItemsRef.current; |
| 3368 | if (key === "ArrowDown" && currentItems.length > 0) { | 3486 | if (key === "ArrowDown" && currentItems.length > 0) { |
| ... | @@ -3584,15 +3702,28 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3584,15 +3702,28 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3584 | > | 3702 | > |
| 3585 | {composerMenuOpen && !isComposerApprovalState && ( | 3703 | {composerMenuOpen && !isComposerApprovalState && ( |
| 3586 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> | 3704 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> |
| 3587 | <ComposerCommandMenu | 3705 | {pullRequestComposerMenuState ? ( |
| 3588 | items={composerMenuItems} | 3706 | <ComposerPullRequestCommandMenu |
| 3589 | resolvedTheme={resolvedTheme} | 3707 | key={pullRequestComposerMenuState.key} |
| 3590 | isLoading={isComposerMenuLoading} | 3708 | cwd={activeProject?.cwd ?? null} |
| 3591 | triggerKind={composerTriggerKind} | 3709 | initialReference={pullRequestComposerMenuState.initialReference} |
| 3592 | activeItemId={activeComposerMenuItem?.id ?? null} | 3710 | onCancel={closePullRequestComposerMenu} |
| 3593 | onHighlightedItemChange={onComposerMenuItemHighlighted} | 3711 | onPrepared={async (input) => { |
| 3594 | onSelect={onSelectComposerItem} | 3712 | await handlePreparedPullRequestThread(input); |
| 3595 | /> | 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 | </div> | 3727 | </div> |
| 3597 | )} | 3728 | )} |
| 3598 | 3729 | ||
| ... | @@ -3688,7 +3819,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3688,7 +3819,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3688 | ? "Add feedback to refine the plan, or leave this blank to implement it" | 3819 | ? "Add feedback to refine the plan, or leave this blank to implement it" |
| 3689 | : phase === "disconnected" | 3820 | : phase === "disconnected" |
| 3690 | ? "Ask for follow-up changes or attach images" | 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 | disabled={isConnecting || isComposerApprovalState} | 3824 | disabled={isConnecting || isComposerApprovalState} |
| 3694 | /> | 3825 | /> |
| ... | @@ -4018,10 +4149,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -4018,10 +4149,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 4018 | onEnvModeChange={onEnvModeChange} | 4149 | onEnvModeChange={onEnvModeChange} |
| 4019 | envLocked={envLocked} | 4150 | envLocked={envLocked} |
| 4020 | onComposerFocusRequest={scheduleComposerFocus} | 4151 | onComposerFocusRequest={scheduleComposerFocus} |
| 4152 | {...(canCheckoutPullRequestIntoThread | ||
| 4153 | ? { onCheckoutPullRequestRequest: openPullRequestComposerMenu } | ||
| 4154 | : {})} | ||
| 4021 | /> | 4155 | /> |
| 4022 | )} | 4156 | )} |
| 4023 | </div> | 4157 | </div>{/* end chat column */} |
| 4024 | {/* end chat column */} | ||
| 4025 | 4158 | ||
| 4026 | {/* Plan sidebar */} | 4159 | {/* Plan sidebar */} |
| 4027 | {planSidebarOpen ? ( | 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,6 +134,10 @@ describe("parseStandaloneComposerSlashCommand", () => { |
| 134 | expect(parseStandaloneComposerSlashCommand("/default")).toBe("default"); | 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 | it("ignores slash commands with extra message text", () => { | 141 | it("ignores slash commands with extra message text", () => { |
| 138 | expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull(); | 142 | expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull(); |
| 139 | }); | 143 | }); |
apps/web/src/composer-logic.ts+12-4| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; | 1 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; |
| 2 | 2 | ||
| 3 | export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; | 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 | export interface ComposerTrigger { | 6 | export interface ComposerTrigger { |
| 7 | kind: ComposerTriggerKind; | 7 | kind: ComposerTriggerKind; |
| ... | @@ -10,7 +10,12 @@ export interface ComposerTrigger { | ... | @@ -10,7 +10,12 @@ export interface ComposerTrigger { |
| 10 | rangeEnd: number; | 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 | function clampCursor(text: string, cursor: number): number { | 20 | function clampCursor(text: string, cursor: number): number { |
| 16 | if (!Number.isFinite(cursor)) return text.length; | 21 | if (!Number.isFinite(cursor)) return text.length; |
| ... | @@ -164,11 +169,14 @@ export function parseStandaloneComposerSlashCommand(text: string): Exclude< | ... | @@ -164,11 +169,14 @@ export function parseStandaloneComposerSlashCommand(text: string): Exclude< |
| 164 | ComposerSlashCommand, | 169 | ComposerSlashCommand, |
| 165 | "model" | 170 | "model" |
| 166 | > | null { | 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 | if (!match) { | 173 | if (!match) { |
| 169 | return null; | 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 | export function replaceTextRange( | 182 | export function replaceTextRange( |
apps/web/src/lib/gitReactQuery.test.ts+15| ... | @@ -2,6 +2,7 @@ import { QueryClient } from "@tanstack/react-query"; | ... | @@ -2,6 +2,7 @@ import { QueryClient } from "@tanstack/react-query"; |
| 2 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 3 | import { | 3 | import { |
| 4 | gitMutationKeys, | 4 | gitMutationKeys, |
| 5 | gitPreparePullRequestThreadMutationOptions, | ||
| 5 | gitPullMutationOptions, | 6 | gitPullMutationOptions, |
| 6 | gitRunStackedActionMutationOptions, | 7 | gitRunStackedActionMutationOptions, |
| 7 | } from "./gitReactQuery"; | 8 | } from "./gitReactQuery"; |
| ... | @@ -16,6 +17,12 @@ describe("gitMutationKeys", () => { | ... | @@ -16,6 +17,12 @@ describe("gitMutationKeys", () => { |
| 16 | it("scopes pull keys by cwd", () => { | 17 | it("scopes pull keys by cwd", () => { |
| 17 | expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b")); | 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 | describe("git mutation options", () => { | 28 | describe("git mutation options", () => { |
| ... | @@ -30,4 +37,12 @@ describe("git mutation options", () => { | ... | @@ -30,4 +37,12 @@ describe("git mutation options", () => { |
| 30 | const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); | 37 | const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); |
| 31 | expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a")); | 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,6 +18,8 @@ export const gitMutationKeys = { |
| 18 | checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const, | 18 | checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const, |
| 19 | runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const, | 19 | runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const, |
| 20 | pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const, | 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 | export function invalidateGitQueries(queryClient: QueryClient) { | 25 | export function invalidateGitQueries(queryClient: QueryClient) { |
| ... | @@ -56,6 +58,26 @@ export function gitBranchesQueryOptions(cwd: string | null) { | ... | @@ -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 | export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { | 81 | export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { |
| 60 | return mutationOptions({ | 82 | return mutationOptions({ |
| 61 | mutationKey: gitMutationKeys.init(input.cwd), | 83 | mutationKey: gitMutationKeys.init(input.cwd), |
| ... | @@ -168,3 +190,30 @@ export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClie | ... | @@ -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,6 +170,9 @@ export function createWsNativeApi(): NativeApi { |
| 170 | createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input), | 170 | createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input), |
| 171 | checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), | 171 | checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), |
| 172 | init: (input) => transport.request(WS_METHODS.gitInit, input), | 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 | contextMenu: { | 177 | contextMenu: { |
| 175 | show: async <T extends string>( | 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,6 +20,9 @@ const GitPrStepStatus = Schema.Literals([ |
| 20 | "skipped_not_requested", | 20 | "skipped_not_requested", |
| 21 | ]); | 21 | ]); |
| 22 | const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); | 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 | export const GitBranch = Schema.Struct({ | 27 | export const GitBranch = Schema.Struct({ |
| 25 | name: TrimmedNonEmptyStringSchema, | 28 | name: TrimmedNonEmptyStringSchema, |
| ... | @@ -35,6 +38,15 @@ const GitWorktree = Schema.Struct({ | ... | @@ -35,6 +38,15 @@ const GitWorktree = Schema.Struct({ |
| 35 | path: TrimmedNonEmptyStringSchema, | 38 | path: TrimmedNonEmptyStringSchema, |
| 36 | branch: TrimmedNonEmptyStringSchema, | 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 | // RPC Inputs | 51 | // RPC Inputs |
| 40 | 52 | ||
| ... | @@ -64,11 +76,24 @@ export type GitListBranchesInput = typeof GitListBranchesInput.Type; | ... | @@ -64,11 +76,24 @@ export type GitListBranchesInput = typeof GitListBranchesInput.Type; |
| 64 | export const GitCreateWorktreeInput = Schema.Struct({ | 76 | export const GitCreateWorktreeInput = Schema.Struct({ |
| 65 | cwd: TrimmedNonEmptyStringSchema, | 77 | cwd: TrimmedNonEmptyStringSchema, |
| 66 | branch: TrimmedNonEmptyStringSchema, | 78 | branch: TrimmedNonEmptyStringSchema, |
| 67 | newBranch: TrimmedNonEmptyStringSchema, | 79 | newBranch: Schema.optional(TrimmedNonEmptyStringSchema), |
| 68 | path: Schema.NullOr(TrimmedNonEmptyStringSchema), | 80 | path: Schema.NullOr(TrimmedNonEmptyStringSchema), |
| 69 | }); | 81 | }); |
| 70 | export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type; | 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 | export const GitRemoveWorktreeInput = Schema.Struct({ | 97 | export const GitRemoveWorktreeInput = Schema.Struct({ |
| 73 | cwd: TrimmedNonEmptyStringSchema, | 98 | cwd: TrimmedNonEmptyStringSchema, |
| 74 | path: TrimmedNonEmptyStringSchema, | 99 | path: TrimmedNonEmptyStringSchema, |
| ... | @@ -136,6 +161,18 @@ export const GitCreateWorktreeResult = Schema.Struct({ | ... | @@ -136,6 +161,18 @@ export const GitCreateWorktreeResult = Schema.Struct({ |
| 136 | }); | 161 | }); |
| 137 | export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type; | 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 | export const GitRunStackedActionResult = Schema.Struct({ | 176 | export const GitRunStackedActionResult = Schema.Struct({ |
| 140 | action: GitStackedAction, | 177 | action: GitStackedAction, |
| 141 | branch: Schema.Struct({ | 178 | branch: Schema.Struct({ |
packages/contracts/src/ipc.ts+8| ... | @@ -1,6 +1,9 @@ | ... | @@ -1,6 +1,9 @@ |
| 1 | import type { | 1 | import type { |
| 2 | GitCheckoutInput, | 2 | GitCheckoutInput, |
| 3 | GitCreateBranchInput, | 3 | GitCreateBranchInput, |
| 4 | GitPreparePullRequestThreadInput, | ||
| 5 | GitPreparePullRequestThreadResult, | ||
| 6 | GitPullRequestRefInput, | ||
| 4 | GitCreateWorktreeInput, | 7 | GitCreateWorktreeInput, |
| 5 | GitCreateWorktreeResult, | 8 | GitCreateWorktreeResult, |
| 6 | GitInitInput, | 9 | GitInitInput, |
| ... | @@ -9,6 +12,7 @@ import type { | ... | @@ -9,6 +12,7 @@ import type { |
| 9 | GitPullInput, | 12 | GitPullInput, |
| 10 | GitPullResult, | 13 | GitPullResult, |
| 11 | GitRemoveWorktreeInput, | 14 | GitRemoveWorktreeInput, |
| 15 | GitResolvePullRequestResult, | ||
| 12 | GitRunStackedActionInput, | 16 | GitRunStackedActionInput, |
| 13 | GitRunStackedActionResult, | 17 | GitRunStackedActionResult, |
| 14 | GitStatusInput, | 18 | GitStatusInput, |
| ... | @@ -134,6 +138,10 @@ export interface NativeApi { | ... | @@ -134,6 +138,10 @@ export interface NativeApi { |
| 134 | createBranch: (input: GitCreateBranchInput) => Promise<void>; | 138 | createBranch: (input: GitCreateBranchInput) => Promise<void>; |
| 135 | checkout: (input: GitCheckoutInput) => Promise<void>; | 139 | checkout: (input: GitCheckoutInput) => Promise<void>; |
| 136 | init: (input: GitInitInput) => Promise<void>; | 140 | init: (input: GitInitInput) => Promise<void>; |
| 141 | resolvePullRequest: (input: GitPullRequestRefInput) => Promise<GitResolvePullRequestResult>; | ||
| 142 | preparePullRequestThread: ( | ||
| 143 | input: GitPreparePullRequestThreadInput, | ||
| 144 | ) => Promise<GitPreparePullRequestThreadResult>; | ||
| 137 | // Stacked action API | 145 | // Stacked action API |
| 138 | pull: (input: GitPullInput) => Promise<GitPullResult>; | 146 | pull: (input: GitPullInput) => Promise<GitPullResult>; |
| 139 | status: (input: GitStatusInput) => Promise<GitStatusResult>; | 147 | status: (input: GitStatusInput) => Promise<GitStatusResult>; |
packages/contracts/src/ws.test.ts+16| ... | @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; | ... | @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; |
| 2 | import { Effect, Schema } from "effect"; | 2 | import { Effect, Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import { ORCHESTRATION_WS_METHODS } from "./orchestration"; | 4 | import { ORCHESTRATION_WS_METHODS } from "./orchestration"; |
| 5 | import { WS_METHODS } from "./ws"; | ||
| 5 | import { WebSocketRequest } from "./ws"; | 6 | import { WebSocketRequest } from "./ws"; |
| 6 | 7 | ||
| 7 | const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest); | 8 | const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest); |
| ... | @@ -56,3 +57,18 @@ it.effect("trims websocket request id and nested orchestration ids", () => | ... | @@ -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,10 +12,12 @@ import { |
| 12 | import { | 12 | import { |
| 13 | GitCheckoutInput, | 13 | GitCheckoutInput, |
| 14 | GitCreateBranchInput, | 14 | GitCreateBranchInput, |
| 15 | GitPreparePullRequestThreadInput, | ||
| 15 | GitCreateWorktreeInput, | 16 | GitCreateWorktreeInput, |
| 16 | GitInitInput, | 17 | GitInitInput, |
| 17 | GitListBranchesInput, | 18 | GitListBranchesInput, |
| 18 | GitPullInput, | 19 | GitPullInput, |
| 20 | GitPullRequestRefInput, | ||
| 19 | GitRemoveWorktreeInput, | 21 | GitRemoveWorktreeInput, |
| 20 | GitRunStackedActionInput, | 22 | GitRunStackedActionInput, |
| 21 | GitStatusInput, | 23 | GitStatusInput, |
| ... | @@ -55,6 +57,8 @@ export const WS_METHODS = { | ... | @@ -55,6 +57,8 @@ export const WS_METHODS = { |
| 55 | gitCreateBranch: "git.createBranch", | 57 | gitCreateBranch: "git.createBranch", |
| 56 | gitCheckout: "git.checkout", | 58 | gitCheckout: "git.checkout", |
| 57 | gitInit: "git.init", | 59 | gitInit: "git.init", |
| 60 | gitResolvePullRequest: "git.resolvePullRequest", | ||
| 61 | gitPreparePullRequestThread: "git.preparePullRequestThread", | ||
| 58 | 62 | ||
| 59 | // Terminal methods | 63 | // Terminal methods |
| 60 | terminalOpen: "terminal.open", | 64 | terminalOpen: "terminal.open", |
| ... | @@ -117,6 +121,8 @@ const WebSocketRequestBody = Schema.Union([ | ... | @@ -117,6 +121,8 @@ const WebSocketRequestBody = Schema.Union([ |
| 117 | tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput), | 121 | tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput), |
| 118 | tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput), | 122 | tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput), |
| 119 | tagRequestBody(WS_METHODS.gitInit, GitInitInput), | 123 | tagRequestBody(WS_METHODS.gitInit, GitInitInput), |
| 124 | tagRequestBody(WS_METHODS.gitResolvePullRequest, GitPullRequestRefInput), | ||
| 125 | tagRequestBody(WS_METHODS.gitPreparePullRequestThread, GitPreparePullRequestThreadInput), | ||
| 120 | 126 | ||
| 121 | // Terminal methods | 127 | // Terminal methods |
| 122 | tagRequestBody(WS_METHODS.terminalOpen, TerminalOpenInput), | 128 | tagRequestBody(WS_METHODS.terminalOpen, TerminalOpenInput), |