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) =>
105105 readConfigValue: (cwd, key) => core.readConfigValue(cwd, key),
106106 listBranches: (input) => core.listBranches(input),
107107 createWorktree: (input) => core.createWorktree(input),
108 fetchPullRequestBranch: (input) => core.fetchPullRequestBranch(input),
108109 removeWorktree: (input) => core.removeWorktree(input),
109110 renameBranch: (input) => core.renameBranch(input),
110111 createBranch: (input) => core.createBranch(input),
......@@ -149,6 +150,15 @@ function createGitWorktree(input: Parameters<GitCoreShape["createWorktree"]>[0])
149150 });
150151}
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
152162function removeGitWorktree(input: Parameters<GitCoreShape["removeWorktree"]>[0]) {
153163 return Effect.gen(function* () {
154164 const core = yield* GitCore;
......@@ -1006,6 +1016,28 @@ it.layer(TestLayer)("git integration", (it) => {
10061016 }),
10071017 );
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
10091041 it.effect("throws when new branch name already exists", () =>
10101042 Effect.gen(function* () {
10111043 const tmp = yield* makeTmpDir();
......@@ -1168,6 +1200,37 @@ it.layer(TestLayer)("git integration", (it) => {
11681200 );
11691201 });
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
11711234 // ── Full flow: thread switching simulation ──
11721235
11731236 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* () {
418418 allowNonZeroExit: true,
419419 }).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
421444 const resolveBaseBranchForNoUpstream = (
422445 cwd: string,
423446 branch: string,
......@@ -1015,16 +1038,20 @@ const makeGitCore = Effect.gen(function* () {
10151038
10161039 const createWorktree: GitCoreShape["createWorktree"] = (input) =>
10171040 Effect.gen(function* () {
1018 const sanitizedBranch = input.newBranch.replace(/\//g, "-");
1041 const targetBranch = input.newBranch ?? input.branch;
1042 const sanitizedBranch = targetBranch.replace(/\//g, "-");
10191043 const repoName = path.basename(input.cwd);
10201044 const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
10211045 const worktreePath =
10221046 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
10241051 yield* executeGit(
10251052 "GitCore.createWorktree",
10261053 input.cwd,
1027 ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch],
1054 args,
10281055 {
10291056 fallbackErrorMessage: "git worktree add failed",
10301057 },
......@@ -1033,11 +1060,30 @@ const makeGitCore = Effect.gen(function* () {
10331060 return {
10341061 worktree: {
10351062 path: worktreePath,
1036 branch: input.newBranch,
1063 branch: targetBranch,
10371064 },
10381065 };
10391066 });
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
10411087 const removeWorktree: GitCoreShape["removeWorktree"] = (input) =>
10421088 Effect.gen(function* () {
10431089 const args = ["worktree", "remove"];
......@@ -1197,6 +1243,7 @@ const makeGitCore = Effect.gen(function* () {
11971243 readConfigValue,
11981244 listBranches,
11991245 createWorktree,
1246 fetchPullRequestBranch,
12001247 removeWorktree,
12011248 renameBranch,
12021249 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";
22
33import { runProcess } from "../../processRunner";
44import { 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
711const DEFAULT_TIMEOUT_MS = 30_000;
812
......@@ -30,6 +34,19 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown
3034 });
3135 }
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
3350 return new GitHubCliError({
3451 operation,
3552 detail: `GitHub CLI command failed: ${error.message}`,
......@@ -44,13 +61,52 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown
4461 });
4562}
4663
47function parseOpenPullRequests(raw: string): ReadonlyArray<{
48 number: number;
49 title: string;
50 url: string;
51 baseRefName: string;
52 headRefName: string;
53}> {
64function normalizePullRequestState(record: Record<string, unknown>): "open" | "closed" | "merged" {
65 const mergedAt = record.mergedAt;
66 const state = record.state;
67 if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") {
68 return "merged";
69 }
70 if (state === "CLOSED") {
71 return "closed";
72 }
73 return "open";
74}
75
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> {
54110 const trimmed = raw.trim();
55111 if (trimmed.length === 0) return [];
56112
......@@ -59,41 +115,12 @@ function parseOpenPullRequests(raw: string): ReadonlyArray<{
59115 throw new Error("GitHub CLI returned non-array JSON.");
60116 }
61117
62 const result: Array<{
63 number: number;
64 title: string;
65 url: string;
66 baseRefName: string;
67 headRefName: string;
68 }> = [];
118 const result: Array<GitHubPullRequestSummary> = [];
69119 for (const entry of parsed) {
70 if (!entry || typeof entry !== "object") {
71 continue;
120 const parsedEntry = parsePullRequestSummary(entry);
121 if (parsedEntry) {
122 result.push(parsedEntry);
72123 }
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 });
97124 }
98125
99126 return result;
......@@ -144,6 +171,40 @@ const makeGitHubCli = Effect.sync(() => {
144171 }),
145172 ),
146173 ),
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 ),
147208 createPullRequest: (input) =>
148209 execute({
149210 cwd: input.cwd,
......@@ -170,6 +231,16 @@ const makeGitHubCli = Effect.sync(() => {
170231 return trimmed.length > 0 ? trimmed : null;
171232 }),
172233 ),
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),
173244 } satisfies GitHubCliShape;
174245
175246 return service;
apps/server/src/git/Layers/GitManager.test.ts+247
......@@ -23,6 +23,14 @@ interface FakeGhScenario {
2323 prListSequence?: string[];
2424 createdPrUrl?: string;
2525 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 };
2634 failWith?: GitHubCliError;
2735}
2836
......@@ -203,6 +211,26 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
203211 }
204212
205213 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") {
206234 return Effect.succeed({
207235 stdout: "",
208236 stderr: "",
......@@ -279,6 +307,29 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
279307 return value.length > 0 ? value : null;
280308 }),
281309 ),
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),
282333 },
283334 ghCalls,
284335 };
......@@ -296,6 +347,17 @@ function runStackedAction(
296347 return manager.runStackedAction(input);
297348}
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
299361function makeManager(input?: {
300362 ghScenario?: FakeGhScenario;
301363 textGeneration?: Partial<FakeGitTextGeneration>;
......@@ -924,4 +986,189 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
924986 expect(errorMessage).toContain("gh auth login");
925987 }),
926988 );
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 );
9271174});
apps/server/src/git/Layers/GitManager.ts+153
......@@ -1,4 +1,5 @@
11import { randomUUID } from "node:crypto";
2import { realpathSync } from "node:fs";
23
34import { Effect, FileSystem, Layer, Path } from "effect";
45import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git";
......@@ -22,6 +23,15 @@ interface PullRequestInfo extends OpenPrInfo {
2223 updatedAt: string | null;
2324}
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
2535function parsePullRequestList(raw: unknown): PullRequestInfo[] {
2636 if (!Array.isArray(raw)) return [];
2737
......@@ -175,6 +185,38 @@ function toStatusPr(pr: PullRequestInfo): {
175185 };
176186}
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
178220export const makeGitManager = Effect.gen(function* () {
179221 const gitCore = yield* GitCore;
180222 const gitHubCli = yield* GitHubCli;
......@@ -445,6 +487,115 @@ export const makeGitManager = Effect.gen(function* () {
445487 };
446488 });
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
448599 const runFeatureBranchStep = (cwd: string, branch: string | null, commitMessage?: string) =>
449600 Effect.gen(function* () {
450601 const suggestion = yield* resolveCommitAndBranchSuggestion({
......@@ -536,6 +687,8 @@ export const makeGitManager = Effect.gen(function* () {
536687
537688 return {
538689 status,
690 resolvePullRequest,
691 preparePullRequestThread,
539692 runStackedAction,
540693 } satisfies GitManagerShape;
541694});
apps/server/src/git/Services/GitCore.ts+13
......@@ -56,6 +56,12 @@ export interface GitRenameBranchResult {
5656 branch: string;
5757}
5858
59export interface GitFetchPullRequestBranchInput {
60 cwd: string;
61 prNumber: number;
62 branch: string;
63}
64
5965/**
6066 * GitCoreShape - Service API for low-level Git repository interactions.
6167 */
......@@ -129,6 +135,13 @@ export interface GitCoreShape {
129135 input: GitCreateWorktreeInput,
130136 ) => 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
132145 /**
133146 * Remove an existing worktree.
134147 */
apps/server/src/git/Services/GitHubCli.ts+18
......@@ -17,6 +17,7 @@ export interface GitHubPullRequestSummary {
1717 readonly url: string;
1818 readonly baseRefName: string;
1919 readonly headRefName: string;
20 readonly state?: "open" | "closed" | "merged";
2021}
2122
2223/**
......@@ -41,6 +42,14 @@ export interface GitHubCliShape {
4142 readonly limit?: number;
4243 }) => 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
4453 /**
4554 * Create a pull request from branch context and body file.
4655 */
......@@ -58,6 +67,15 @@ export interface GitHubCliShape {
5867 readonly getDefaultBranch: (input: {
5968 readonly cwd: string;
6069 }) => 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>;
6179}
6280
6381/**
apps/server/src/git/Services/GitManager.ts+18
......@@ -7,6 +7,10 @@
77 * @module GitManager
88 */
99import {
10 GitPreparePullRequestThreadInput,
11 GitPreparePullRequestThreadResult,
12 GitPullRequestRefInput,
13 GitResolvePullRequestResult,
1014 GitRunStackedActionInput,
1115 GitRunStackedActionResult,
1216 GitStatusInput,
......@@ -27,6 +31,20 @@ export interface GitManagerShape {
2731 input: GitStatusInput,
2832 ) => 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
3048 /**
3149 * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`).
3250 * 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", () => {
16381638
16391639 const status = vi.fn(() => Effect.succeed(statusResult));
16401640 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
16431650 server = await createTestServer({ cwd: "/test", gitManager });
16441651 const addr = server.address();
......@@ -1656,6 +1663,63 @@ describe("WebSocket Server", () => {
16561663 expect(status).toHaveBeenCalledWith({ cwd: "/test" });
16571664 });
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
16591723 it("returns errors from git.runStackedAction", async () => {
16601724 const runStackedAction = vi.fn(() =>
16611725 Effect.fail(
......@@ -1667,6 +1731,8 @@ describe("WebSocket Server", () => {
16671731 );
16681732 const gitManager: GitManagerShape = {
16691733 status: vi.fn(() => Effect.void as any),
1734 resolvePullRequest: vi.fn(() => Effect.void as any),
1735 preparePullRequestThread: vi.fn(() => Effect.void as any),
16701736 runStackedAction,
16711737 };
16721738
apps/server/src/wsServer.ts+10
......@@ -832,6 +832,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
832832 return yield* gitManager.runStackedAction(body);
833833 }
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
835845 case WS_METHODS.gitListBranches: {
836846 const body = stripRequestTag(request.body);
837847 return yield* git.listBranches(body);
apps/web/src/components/BranchToolbar.tsx+5
......@@ -17,6 +17,7 @@ interface BranchToolbarProps {
1717 threadId: ThreadId;
1818 onEnvModeChange: (mode: EnvMode) => void;
1919 envLocked: boolean;
20 onCheckoutPullRequestRequest?: (reference: string) => void;
2021 onComposerFocusRequest?: () => void;
2122}
2223
......@@ -24,6 +25,7 @@ export default function BranchToolbar({
2425 threadId,
2526 onEnvModeChange,
2627 envLocked,
28 onCheckoutPullRequestRequest,
2729 onComposerFocusRequest,
2830}: BranchToolbarProps) {
2931 const threads = useStore((store) => store.threads);
......@@ -128,6 +130,9 @@ export default function BranchToolbar({
128130 effectiveEnvMode={effectiveEnvMode}
129131 envLocked={envLocked}
130132 onSetThreadBranch={setThreadBranch}
133 {...(onCheckoutPullRequestRequest
134 ? { onCheckoutPullRequestRequest }
135 : {})}
131136 {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
132137 />
133138 </div>
apps/web/src/components/BranchToolbarBranchSelector.tsx+49-5
......@@ -14,6 +14,7 @@ import {
1414
1515import { gitBranchesQueryOptions, gitQueryKeys, invalidateGitQueries } from "../lib/gitReactQuery";
1616import { readNativeApi } from "../nativeApi";
17import { parsePullRequestReference } from "../pullRequestReference";
1718import {
1819 dedupeRemoteBranchesWithLocalMatches,
1920 deriveLocalBranchNameFromRemoteRef,
......@@ -40,6 +41,7 @@ interface BranchToolbarBranchSelectorProps {
4041 effectiveEnvMode: EnvMode;
4142 envLocked: boolean;
4243 onSetThreadBranch: (branch: string | null, worktreePath: string | null) => void;
44 onCheckoutPullRequestRequest?: (reference: string) => void;
4345 onComposerFocusRequest?: () => void;
4446}
4547
......@@ -70,6 +72,7 @@ export function BranchToolbarBranchSelector({
7072 effectiveEnvMode,
7173 envLocked,
7274 onSetThreadBranch,
75 onCheckoutPullRequestRequest,
7376 onComposerFocusRequest,
7477}: BranchToolbarBranchSelectorProps) {
7578 const queryClient = useQueryClient();
......@@ -95,17 +98,27 @@ export function BranchToolbarBranchSelector({
9598 );
9699 const trimmedBranchQuery = branchQuery.trim();
97100 const normalizedBranchQuery = trimmedBranchQuery.toLowerCase();
101 const prReference = parsePullRequestReference(trimmedBranchQuery);
102 const checkoutPullRequestItemValue = prReference && onCheckoutPullRequestRequest
103 ? `__checkout_pull_request__:${prReference}`
104 : null;
98105 const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0;
99106 const hasExactBranchMatch = branchByName.has(trimmedBranchQuery);
100107 const createBranchItemValue = canCreateBranch
101108 ? `__create_new_branch__:${trimmedBranchQuery}`
102109 : null;
103110 const branchPickerItems = useMemo(
104 () =>
105 createBranchItemValue && !hasExactBranchMatch
106 ? [...branchNames, createBranchItemValue]
107 : branchNames,
108 [branchNames, createBranchItemValue, hasExactBranchMatch],
111 () => {
112 const items = [...branchNames];
113 if (createBranchItemValue && !hasExactBranchMatch) {
114 items.push(createBranchItemValue);
115 }
116 if (checkoutPullRequestItemValue) {
117 items.unshift(checkoutPullRequestItemValue);
118 }
119 return items;
120 },
121 [branchNames, checkoutPullRequestItemValue, createBranchItemValue, hasExactBranchMatch],
109122 );
110123 const filteredBranchPickerItems = useMemo(
111124 () =>
......@@ -338,6 +351,37 @@ export function BranchToolbarBranchSelector({
338351 {virtualBranchRows.map((virtualRow) => {
339352 const itemValue = filteredBranchPickerItems[virtualRow.index];
340353 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 }
341385 if (createBranchItemValue && itemValue === createBranchItemValue) {
342386 return (
343387 <ComboboxItem
apps/web/src/components/ChatView.tsx+153-20
......@@ -119,6 +119,7 @@ import {
119119 type TurnDiffTreeNode,
120120} from "../lib/turnDiffTree";
121121import BranchToolbar from "./BranchToolbar";
122import { ComposerPullRequestCommandMenu } from "./ComposerPullRequestCommandMenu";
122123import GitActionsControl from "./GitActionsControl";
123124import {
124125 isOpenFavoriteEditorShortcut,
......@@ -407,6 +408,11 @@ type ComposerCommandItem =
407408
408409type SendPhase = "idle" | "preparing-worktree" | "sending-turn";
409410
411interface PullRequestComposerMenuState {
412 initialReference: string | null;
413 key: number;
414}
415
410416function readFileAsDataUrl(file: File): Promise<string> {
411417 return new Promise((resolve, reject) => {
412418 const reader = new FileReader();
......@@ -609,6 +615,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
609615 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
610616 const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);
611617 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 );
612624 const draftThread = useComposerDraftStore(
613625 (store) => store.draftThreadsByThreadId[threadId] ?? null,
614626 );
......@@ -645,6 +657,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
645657 const [nowTick, setNowTick] = useState(() => Date.now());
646658 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
647659 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
660 const [pullRequestComposerMenuState, setPullRequestComposerMenuState] =
661 useState<PullRequestComposerMenuState | null>(null);
648662 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<
649663 Record<string, string[]>
650664 >({});
......@@ -749,6 +763,80 @@ export default function ChatView({ threadId }: ChatViewProps) {
749763 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
750764 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
752840 useEffect(() => {
753841 if (!activeThread?.id) return;
754842 if (!latestTurnSettled) return;
......@@ -1162,6 +1250,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
11621250 timelineEntries,
11631251 ]);
11641252 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
1253 const pullRequestComposerMenuOpen = pullRequestComposerMenuState !== null;
11651254 const composerTriggerKind = composerTrigger?.kind ?? null;
11661255 const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";
11671256 const isPathTrigger = composerTriggerKind === "path";
......@@ -1218,6 +1307,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
12181307 label: "/default",
12191308 description: "Switch this thread back to normal chat mode",
12201309 },
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 },
12211317 ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
12221318 const query = composerTrigger.query.trim().toLowerCase();
12231319 if (!query) {
......@@ -1245,13 +1341,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
12451341 description: `${providerLabel} · ${slug}`,
12461342 }));
12471343 }, [composerTrigger, searchableModelOptions, workspaceEntries]);
1248 const composerMenuOpen = Boolean(composerTrigger);
1344 const composerMenuOpen = Boolean(composerTrigger) || pullRequestComposerMenuOpen;
12491345 const activeComposerMenuItem = useMemo(
12501346 () =>
1251 composerMenuItems.find((item) => item.id === composerHighlightedItemId) ??
1252 composerMenuItems[0] ??
1253 null,
1254 [composerHighlightedItemId, composerMenuItems],
1347 pullRequestComposerMenuOpen
1348 ? null
1349 : (composerMenuItems.find((item) => item.id === composerHighlightedItemId) ??
1350 composerMenuItems[0] ??
1351 null),
1352 [composerHighlightedItemId, composerMenuItems, pullRequestComposerMenuOpen],
12551353 );
12561354 composerMenuOpenRef.current = composerMenuOpen;
12571355 composerMenuItemsRef.current = composerMenuItems;
......@@ -1950,6 +2048,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
19502048
19512049 useEffect(() => {
19522050 setExpandedWorkGroups({});
2051 setPullRequestComposerMenuState(null);
19532052 if (planSidebarOpenOnNextThreadRef.current) {
19542053 planSidebarOpenOnNextThreadRef.current = false;
19552054 setPlanSidebarOpen(true);
......@@ -1964,12 +2063,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
19642063 setComposerHighlightedItemId(null);
19652064 return;
19662065 }
2066 if (pullRequestComposerMenuOpen) {
2067 setComposerHighlightedItemId(null);
2068 return;
2069 }
19672070 setComposerHighlightedItemId((existing) =>
19682071 existing && composerMenuItems.some((item) => item.id === existing)
19692072 ? existing
19702073 : (composerMenuItems[0]?.id ?? null),
19712074 );
1972 }, [composerMenuItems, composerMenuOpen]);
2075 }, [composerMenuItems, composerMenuOpen, pullRequestComposerMenuOpen]);
19732076
19742077 useEffect(() => {
19752078 setIsRevertingCheckpoint(false);
......@@ -2480,7 +2583,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
24802583 const standaloneSlashCommand =
24812584 composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null;
24822585 if (standaloneSlashCommand) {
2483 await handleInteractionModeChange(standaloneSlashCommand);
2586 if (standaloneSlashCommand === "checkout-pr") {
2587 openPullRequestComposerMenu();
2588 } else {
2589 await handleInteractionModeChange(standaloneSlashCommand);
2590 }
24842591 promptRef.current = "";
24852592 clearComposerDraftContent(activeThread.id);
24862593 setComposerHighlightedItemId(null);
......@@ -3269,7 +3376,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
32693376 }
32703377 return;
32713378 }
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 }
32733384 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
32743385 expectedText: expectedToken,
32753386 });
......@@ -3289,6 +3400,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
32893400 [
32903401 applyPromptReplacement,
32913402 handleInteractionModeChange,
3403 openPullRequestComposerMenu,
32923404 onProviderModelSelect,
32933405 resolveActiveComposerTrigger,
32943406 ],
......@@ -3363,6 +3475,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
33633475 const { trigger } = resolveActiveComposerTrigger();
33643476 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
33663484 if (menuIsActive) {
33673485 const currentItems = composerMenuItemsRef.current;
33683486 if (key === "ArrowDown" && currentItems.length > 0) {
......@@ -3584,15 +3702,28 @@ export default function ChatView({ threadId }: ChatViewProps) {
35843702 >
35853703 {composerMenuOpen && !isComposerApprovalState && (
35863704 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
3587 <ComposerCommandMenu
3588 items={composerMenuItems}
3589 resolvedTheme={resolvedTheme}
3590 isLoading={isComposerMenuLoading}
3591 triggerKind={composerTriggerKind}
3592 activeItemId={activeComposerMenuItem?.id ?? null}
3593 onHighlightedItemChange={onComposerMenuItemHighlighted}
3594 onSelect={onSelectComposerItem}
3595 />
3705 {pullRequestComposerMenuState ? (
3706 <ComposerPullRequestCommandMenu
3707 key={pullRequestComposerMenuState.key}
3708 cwd={activeProject?.cwd ?? null}
3709 initialReference={pullRequestComposerMenuState.initialReference}
3710 onCancel={closePullRequestComposerMenu}
3711 onPrepared={async (input) => {
3712 await handlePreparedPullRequestThread(input);
3713 closePullRequestComposerMenu();
3714 }}
3715 />
3716 ) : (
3717 <ComposerCommandMenu
3718 items={composerMenuItems}
3719 resolvedTheme={resolvedTheme}
3720 isLoading={isComposerMenuLoading}
3721 triggerKind={composerTriggerKind}
3722 activeItemId={activeComposerMenuItem?.id ?? null}
3723 onHighlightedItemChange={onComposerMenuItemHighlighted}
3724 onSelect={onSelectComposerItem}
3725 />
3726 )}
35963727 </div>
35973728 )}
35983729
......@@ -3688,7 +3819,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
36883819 ? "Add feedback to refine the plan, or leave this blank to implement it"
36893820 : phase === "disconnected"
36903821 ? "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"
36923823 }
36933824 disabled={isConnecting || isComposerApprovalState}
36943825 />
......@@ -4018,10 +4149,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
40184149 onEnvModeChange={onEnvModeChange}
40194150 envLocked={envLocked}
40204151 onComposerFocusRequest={scheduleComposerFocus}
4152 {...(canCheckoutPullRequestIntoThread
4153 ? { onCheckoutPullRequestRequest: openPullRequestComposerMenu }
4154 : {})}
40214155 />
40224156 )}
4023 </div>
4024 {/* end chat column */}
4157 </div>{/* end chat column */}
40254158
40264159 {/* Plan sidebar */}
40274160 {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", () => {
134134 expect(parseStandaloneComposerSlashCommand("/default")).toBe("default");
135135 });
136136
137 it("parses standalone /checkout-pr command", () => {
138 expect(parseStandaloneComposerSlashCommand(" /checkout-pr ")).toBe("checkout-pr");
139 });
140
137141 it("ignores slash commands with extra message text", () => {
138142 expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull();
139143 });
apps/web/src/composer-logic.ts+12-4
......@@ -1,7 +1,7 @@
11import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
22
33export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";
4export type ComposerSlashCommand = "model" | "plan" | "default";
4export type ComposerSlashCommand = "model" | "plan" | "default" | "checkout-pr";
55
66export interface ComposerTrigger {
77 kind: ComposerTriggerKind;
......@@ -10,7 +10,12 @@ export interface ComposerTrigger {
1010 rangeEnd: number;
1111}
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
1520function clampCursor(text: string, cursor: number): number {
1621 if (!Number.isFinite(cursor)) return text.length;
......@@ -164,11 +169,14 @@ export function parseStandaloneComposerSlashCommand(text: string): Exclude<
164169 ComposerSlashCommand,
165170 "model"
166171> | null {
167 const match = /^\/(plan|default)\s*$/i.exec(text.trim());
172 const match = /^\/(plan|default|checkout-pr)\s*$/i.exec(text.trim());
168173 if (!match) {
169174 return null;
170175 }
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";
172180}
173181
174182export function replaceTextRange(
apps/web/src/lib/gitReactQuery.test.ts+15
......@@ -2,6 +2,7 @@ import { QueryClient } from "@tanstack/react-query";
22import { describe, expect, it } from "vitest";
33import {
44 gitMutationKeys,
5 gitPreparePullRequestThreadMutationOptions,
56 gitPullMutationOptions,
67 gitRunStackedActionMutationOptions,
78} from "./gitReactQuery";
......@@ -16,6 +17,12 @@ describe("gitMutationKeys", () => {
1617 it("scopes pull keys by cwd", () => {
1718 expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b"));
1819 });
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 });
1926});
2027
2128describe("git mutation options", () => {
......@@ -30,4 +37,12 @@ describe("git mutation options", () => {
3037 const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient });
3138 expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a"));
3239 });
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 });
3348});
apps/web/src/lib/gitReactQuery.ts+49
......@@ -18,6 +18,8 @@ export const gitMutationKeys = {
1818 checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const,
1919 runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const,
2020 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,
2123};
2224
2325export function invalidateGitQueries(queryClient: QueryClient) {
......@@ -56,6 +58,26 @@ export function gitBranchesQueryOptions(cwd: string | null) {
5658 });
5759}
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
5981export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {
6082 return mutationOptions({
6183 mutationKey: gitMutationKeys.init(input.cwd),
......@@ -168,3 +190,30 @@ export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClie
168190 },
169191 });
170192}
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 {
170170 createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input),
171171 checkout: (input) => transport.request(WS_METHODS.gitCheckout, input),
172172 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),
173176 },
174177 contextMenu: {
175178 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([
2020 "skipped_not_requested",
2121]);
2222const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]);
23const GitPullRequestReference = TrimmedNonEmptyStringSchema;
24const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]);
25const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]);
2326
2427export const GitBranch = Schema.Struct({
2528 name: TrimmedNonEmptyStringSchema,
......@@ -35,6 +38,15 @@ const GitWorktree = Schema.Struct({
3538 path: TrimmedNonEmptyStringSchema,
3639 branch: TrimmedNonEmptyStringSchema,
3740});
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
3951// RPC Inputs
4052
......@@ -64,11 +76,24 @@ export type GitListBranchesInput = typeof GitListBranchesInput.Type;
6476export const GitCreateWorktreeInput = Schema.Struct({
6577 cwd: TrimmedNonEmptyStringSchema,
6678 branch: TrimmedNonEmptyStringSchema,
67 newBranch: TrimmedNonEmptyStringSchema,
79 newBranch: Schema.optional(TrimmedNonEmptyStringSchema),
6880 path: Schema.NullOr(TrimmedNonEmptyStringSchema),
6981});
7082export 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
7297export const GitRemoveWorktreeInput = Schema.Struct({
7398 cwd: TrimmedNonEmptyStringSchema,
7499 path: TrimmedNonEmptyStringSchema,
......@@ -136,6 +161,18 @@ export const GitCreateWorktreeResult = Schema.Struct({
136161});
137162export 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
139176export const GitRunStackedActionResult = Schema.Struct({
140177 action: GitStackedAction,
141178 branch: Schema.Struct({
packages/contracts/src/ipc.ts+8
......@@ -1,6 +1,9 @@
11import type {
22 GitCheckoutInput,
33 GitCreateBranchInput,
4 GitPreparePullRequestThreadInput,
5 GitPreparePullRequestThreadResult,
6 GitPullRequestRefInput,
47 GitCreateWorktreeInput,
58 GitCreateWorktreeResult,
69 GitInitInput,
......@@ -9,6 +12,7 @@ import type {
912 GitPullInput,
1013 GitPullResult,
1114 GitRemoveWorktreeInput,
15 GitResolvePullRequestResult,
1216 GitRunStackedActionInput,
1317 GitRunStackedActionResult,
1418 GitStatusInput,
......@@ -134,6 +138,10 @@ export interface NativeApi {
134138 createBranch: (input: GitCreateBranchInput) => Promise<void>;
135139 checkout: (input: GitCheckoutInput) => Promise<void>;
136140 init: (input: GitInitInput) => Promise<void>;
141 resolvePullRequest: (input: GitPullRequestRefInput) => Promise<GitResolvePullRequestResult>;
142 preparePullRequestThread: (
143 input: GitPreparePullRequestThreadInput,
144 ) => Promise<GitPreparePullRequestThreadResult>;
137145 // Stacked action API
138146 pull: (input: GitPullInput) => Promise<GitPullResult>;
139147 status: (input: GitStatusInput) => Promise<GitStatusResult>;
packages/contracts/src/ws.test.ts+16
......@@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest";
22import { Effect, Schema } from "effect";
33
44import { ORCHESTRATION_WS_METHODS } from "./orchestration";
5import { WS_METHODS } from "./ws";
56import { WebSocketRequest } from "./ws";
67
78const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest);
......@@ -56,3 +57,18 @@ it.effect("trims websocket request id and nested orchestration ids", () =>
5657 }
5758 }),
5859);
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 {
1212import {
1313 GitCheckoutInput,
1414 GitCreateBranchInput,
15 GitPreparePullRequestThreadInput,
1516 GitCreateWorktreeInput,
1617 GitInitInput,
1718 GitListBranchesInput,
1819 GitPullInput,
20 GitPullRequestRefInput,
1921 GitRemoveWorktreeInput,
2022 GitRunStackedActionInput,
2123 GitStatusInput,
......@@ -55,6 +57,8 @@ export const WS_METHODS = {
5557 gitCreateBranch: "git.createBranch",
5658 gitCheckout: "git.checkout",
5759 gitInit: "git.init",
60 gitResolvePullRequest: "git.resolvePullRequest",
61 gitPreparePullRequestThread: "git.preparePullRequestThread",
5862
5963 // Terminal methods
6064 terminalOpen: "terminal.open",
......@@ -117,6 +121,8 @@ const WebSocketRequestBody = Schema.Union([
117121 tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput),
118122 tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput),
119123 tagRequestBody(WS_METHODS.gitInit, GitInitInput),
124 tagRequestBody(WS_METHODS.gitResolvePullRequest, GitPullRequestRefInput),
125 tagRequestBody(WS_METHODS.gitPreparePullRequestThread, GitPreparePullRequestThreadInput),
120126
121127 // Terminal methods
122128 tagRequestBody(WS_METHODS.terminalOpen, TerminalOpenInput),