authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-09 11:00:23-07:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-09 22:50:45-07:00
log3d3586800ebfbefb97a4a782dae3439e90ba9065
treebe2f3d4c0a39131ec3da574118f3d203471d6626
parent767de271e972b38de0633c04efcd62a929790b3e

Add pull-request thread setup for local and worktree modes

- 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 behavior

27 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}
151152
153function 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
152function removeGitWorktree(input: Parameters<GitCoreShape["removeWorktree"]>[0]) {162function 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 );
10081018
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 });
11701202
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 ──
11721235
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));
420420
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* () {
10151038
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];
10231050
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 });
10401067
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 @@
1import { describe, expect, it, vi } from "vitest";
2import { Effect } from "effect";
3
4vi.mock("../../processRunner", () => ({
5 runProcess: vi.fn(),
6}));
7
8import { runProcess } from "../../processRunner";
9import { GitHubCli } from "../Services/GitHubCli.ts";
10import { GitHubCliLive } from "./GitHubCli.ts";
11
12const mockedRunProcess = vi.mocked(runProcess);
13
14describe("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";
22
3import { runProcess } from "../../processRunner";3import { runProcess } from "../../processRunner";
4import { GitHubCliError } from "../Errors.ts";4import { GitHubCliError } from "../Errors.ts";
5import { GitHubCli, type GitHubCliShape } from "../Services/GitHubCli.ts";5import {
6 GitHubCli,
7 type GitHubCliShape,
8 type GitHubPullRequestSummary,
9} from "../Services/GitHubCli.ts";
610
7const DEFAULT_TIMEOUT_MS = 30_000;11const DEFAULT_TIMEOUT_MS = 30_000;
812
...@@ -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 }
3236
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}
4663
47function parseOpenPullRequests(raw: string): ReadonlyArray<{64function 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
76function 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
109function 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 [];
56112
...@@ -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 }
61117
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 }
98125
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;
174245
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}
2836
...@@ -203,6 +211,26 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -203,6 +211,26 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
203 }211 }
204212
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}
298349
350function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) {
351 return manager.resolvePullRequest(input);
352}
353
354function preparePullRequestThread(
355 manager: GitManagerShape,
356 input: { cwd: string; reference: string; mode: "local" | "worktree" },
357) {
358 return manager.preparePullRequestThread(input);
359}
360
299function makeManager(input?: {361function 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 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2import { realpathSync } from "node:fs";
23
3import { Effect, FileSystem, Layer, Path } from "effect";4import { Effect, FileSystem, Layer, Path } from "effect";
4import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git";5import { 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}
2425
26interface ResolvedPullRequest {
27 number: number;
28 title: string;
29 url: string;
30 baseBranch: string;
31 headBranch: string;
32 state: "open" | "closed" | "merged";
33}
34
25function parsePullRequestList(raw: unknown): PullRequestInfo[] {35function parsePullRequestList(raw: unknown): PullRequestInfo[] {
26 if (!Array.isArray(raw)) return [];36 if (!Array.isArray(raw)) return [];
2737
...@@ -175,6 +185,38 @@ function toStatusPr(pr: PullRequestInfo): {...@@ -175,6 +185,38 @@ function toStatusPr(pr: PullRequestInfo): {
175 };185 };
176}186}
177187
188function normalizePullRequestReference(reference: string): string {
189 const trimmed = reference.trim();
190 const hashNumber = /^#(\d+)$/.exec(trimmed);
191 return hashNumber?.[1] ?? trimmed;
192}
193
194function canonicalizeExistingPath(value: string): string {
195 try {
196 return realpathSync.native(value);
197 } catch {
198 return value;
199 }
200}
201
202function 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
178export const makeGitManager = Effect.gen(function* () {220export 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 });
447489
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* () {
536687
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}
5858
59export 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>;
131137
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}
2122
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>;
4344
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}
6280
63/**81/**
apps/server/src/git/Services/GitManager.ts+18
...@@ -7,6 +7,10 @@...@@ -7,6 +7,10 @@
7 * @module GitManager7 * @module GitManager
8 */8 */
9import {9import {
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>;
2933
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", () => {
16381638
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 };
16421649
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 });
16581665
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 };
16721738
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 }
834834
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}
2223
...@@ -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 {
1414
15import { gitBranchesQueryOptions, gitQueryKeys, invalidateGitQueries } from "../lib/gitReactQuery";15import { gitBranchesQueryOptions, gitQueryKeys, invalidateGitQueries } from "../lib/gitReactQuery";
16import { readNativeApi } from "../nativeApi";16import { readNativeApi } from "../nativeApi";
17import { parsePullRequestReference } from "../pullRequestReference";
17import {18import {
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}
4547
...@@ -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 = canCreateBranch107 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 && !hasExactBranchMatch112 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 <ComboboxItem387 <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";
121import BranchToolbar from "./BranchToolbar";121import BranchToolbar from "./BranchToolbar";
122import { ComposerPullRequestCommandMenu } from "./ComposerPullRequestCommandMenu";
122import GitActionsControl from "./GitActionsControl";123import GitActionsControl from "./GitActionsControl";
123import {124import {
124 isOpenFavoriteEditorShortcut,125 isOpenFavoriteEditorShortcut,
...@@ -407,6 +408,11 @@ type ComposerCommandItem =...@@ -407,6 +408,11 @@ type ComposerCommandItem =
407408
408type SendPhase = "idle" | "preparing-worktree" | "sending-turn";409type SendPhase = "idle" | "preparing-worktree" | "sending-turn";
409410
411interface PullRequestComposerMenuState {
412 initialReference: string | null;
413 key: number;
414}
415
410function readFileAsDataUrl(file: File): Promise<string> {416function 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);
751765
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) {
19502048
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 ? existing2072 ? existing
1970 : (composerMenuItems[0]?.id ?? null),2073 : (composerMenuItems[0]?.id ?? null),
1971 );2074 );
1972 }, [composerMenuItems, composerMenuOpen]);2075 }, [composerMenuItems, composerMenuOpen, pullRequestComposerMenuOpen]);
19732076
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;
33653477
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 <ComposerCommandMenu3705 {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 )}
35983729
...@@ -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 */}
40254158
4026 {/* Plan sidebar */}4159 {/* Plan sidebar */}
4027 {planSidebarOpen ? (4160 {planSidebarOpen ? (
apps/web/src/components/ComposerPullRequestCommandMenu.tsx created+259
...@@ -0,0 +1,259 @@
1import type { GitResolvePullRequestResult } from "@t3tools/contracts";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useDebouncedValue } from "@tanstack/react-pacer";
4import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
6import {
7 gitPreparePullRequestThreadMutationOptions,
8 gitResolvePullRequestQueryOptions,
9} from "~/lib/gitReactQuery";
10import { cn } from "~/lib/utils";
11import { parsePullRequestReference } from "~/pullRequestReference";
12import { Button } from "./ui/button";
13import { Input } from "./ui/input";
14import { Spinner } from "./ui/spinner";
15
16interface 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
23export 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 });
136136
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 @@
1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
22
3export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";3export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";
4export type ComposerSlashCommand = "model" | "plan" | "default";4export type ComposerSlashCommand = "model" | "plan" | "default" | "checkout-pr";
55
6export interface ComposerTrigger {6export 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}
1212
13const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"];13const SLASH_COMMANDS: readonly ComposerSlashCommand[] = [
14 "model",
15 "plan",
16 "default",
17 "checkout-pr",
18];
1419
15function clampCursor(text: string, cursor: number): number {20function 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}
173181
174export function replaceTextRange(182export 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";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
3import {3import {
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});
2027
21describe("git mutation options", () => {28describe("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};
2224
23export function invalidateGitQueries(queryClient: QueryClient) {25export 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}
5860
61export 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
59export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {81export 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
194export 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 @@
1import { describe, expect, it } from "vitest";
2
3import { parsePullRequestReference } from "./pullRequestReference";
4
5describe("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 @@
1const GITHUB_PULL_REQUEST_URL_PATTERN =
2 /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i;
3const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/;
4
5export 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 @@
1import { describe, expect, it } from "vitest";
2import { Schema } from "effect";
3
4import {
5 GitCreateWorktreeInput,
6 GitPreparePullRequestThreadInput,
7 GitResolvePullRequestResult,
8} from "./git";
9
10const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInput);
11const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync(GitPreparePullRequestThreadInput);
12const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult);
13
14describe("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
27describe("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
40describe("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]);
22const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]);22const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]);
23const GitPullRequestReference = TrimmedNonEmptyStringSchema;
24const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]);
25const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]);
2326
24export const GitBranch = Schema.Struct({27export 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});
41const GitResolvedPullRequest = Schema.Struct({
42 number: PositiveInt,
43 title: TrimmedNonEmptyStringSchema,
44 url: Schema.String,
45 baseBranch: TrimmedNonEmptyStringSchema,
46 headBranch: TrimmedNonEmptyStringSchema,
47 state: GitPullRequestState,
48});
49export type GitResolvedPullRequest = typeof GitResolvedPullRequest.Type;
3850
39// RPC Inputs51// RPC Inputs
4052
...@@ -64,11 +76,24 @@ export type GitListBranchesInput = typeof GitListBranchesInput.Type;...@@ -64,11 +76,24 @@ export type GitListBranchesInput = typeof GitListBranchesInput.Type;
64export const GitCreateWorktreeInput = Schema.Struct({76export 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});
70export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type;82export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type;
7183
84export const GitPullRequestRefInput = Schema.Struct({
85 cwd: TrimmedNonEmptyStringSchema,
86 reference: GitPullRequestReference,
87});
88export type GitPullRequestRefInput = typeof GitPullRequestRefInput.Type;
89
90export const GitPreparePullRequestThreadInput = Schema.Struct({
91 cwd: TrimmedNonEmptyStringSchema,
92 reference: GitPullRequestReference,
93 mode: GitPreparePullRequestThreadMode,
94});
95export type GitPreparePullRequestThreadInput = typeof GitPreparePullRequestThreadInput.Type;
96
72export const GitRemoveWorktreeInput = Schema.Struct({97export 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});
137export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type;162export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type;
138163
164export const GitResolvePullRequestResult = Schema.Struct({
165 pullRequest: GitResolvedPullRequest,
166});
167export type GitResolvePullRequestResult = typeof GitResolvePullRequestResult.Type;
168
169export const GitPreparePullRequestThreadResult = Schema.Struct({
170 pullRequest: GitResolvedPullRequest,
171 branch: TrimmedNonEmptyStringSchema,
172 worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
173});
174export type GitPreparePullRequestThreadResult = typeof GitPreparePullRequestThreadResult.Type;
175
139export const GitRunStackedActionResult = Schema.Struct({176export 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 @@
1import type {1import 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 API145 // 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";
2import { Effect, Schema } from "effect";2import { Effect, Schema } from "effect";
33
4import { ORCHESTRATION_WS_METHODS } from "./orchestration";4import { ORCHESTRATION_WS_METHODS } from "./orchestration";
5import { WS_METHODS } from "./ws";
5import { WebSocketRequest } from "./ws";6import { WebSocketRequest } from "./ws";
67
7const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest);8const 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
61it.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 {
12import {12import {
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",
5862
59 // Terminal methods63 // 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),
120126
121 // Terminal methods127 // Terminal methods
122 tagRequestBody(WS_METHODS.terminalOpen, TerminalOpenInput),128 tagRequestBody(WS_METHODS.terminalOpen, TerminalOpenInput),