diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index d03ad60615fbd6fedf00d363c28d05aab0bf72fd..9ebdfe8ca95dc934be979f535c4f0b4adce5df6a 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -105,6 +105,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => readConfigValue: (cwd, key) => core.readConfigValue(cwd, key), listBranches: (input) => core.listBranches(input), createWorktree: (input) => core.createWorktree(input), + fetchPullRequestBranch: (input) => core.fetchPullRequestBranch(input), removeWorktree: (input) => core.removeWorktree(input), renameBranch: (input) => core.renameBranch(input), createBranch: (input) => core.createBranch(input), @@ -149,6 +150,15 @@ function createGitWorktree(input: Parameters[0]) }); } +function fetchGitPullRequestBranch( + input: Parameters[0], +) { + return Effect.gen(function* () { + const core = yield* GitCore; + return yield* core.fetchPullRequestBranch(input); + }); +} + function removeGitWorktree(input: Parameters[0]) { return Effect.gen(function* () { const core = yield* GitCore; @@ -1006,6 +1016,28 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("creates a worktree for an existing branch when newBranch is omitted", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* createGitBranch({ cwd: tmp, branch: "feature/existing-worktree" }); + + const wtPath = path.join(tmp, "wt-existing"); + const result = yield* createGitWorktree({ + cwd: tmp, + branch: "feature/existing-worktree", + path: wtPath, + }); + + expect(result.worktree.path).toBe(wtPath); + expect(result.worktree.branch).toBe("feature/existing-worktree"); + const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); + expect(branchOutput).toBe("feature/existing-worktree"); + + yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + }), + ); + it.effect("throws when new branch name already exists", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); @@ -1168,6 +1200,37 @@ it.layer(TestLayer)("git integration", (it) => { ); }); + describe("fetchPullRequestBranch", () => { + it.effect("fetches a GitHub pull request ref into a local branch without checkout", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const remoteDir = yield* makeTmpDir("git-remote-"); + yield* git(remoteDir, ["init", "--bare"]); + yield* git(tmp, ["remote", "add", "origin", remoteDir]); + yield* git(tmp, ["push", "-u", "origin", "main"]); + yield* git(tmp, ["checkout", "-b", "feature/pr-fetch"]); + yield* writeTextFile(path.join(tmp, "pr-fetch.txt"), "fetch me\n"); + yield* git(tmp, ["add", "pr-fetch.txt"]); + yield* git(tmp, ["commit", "-m", "Add PR fetch branch"]); + yield* git(tmp, ["push", "-u", "origin", "feature/pr-fetch"]); + yield* git(tmp, ["push", "origin", "HEAD:refs/pull/55/head"]); + yield* git(tmp, ["checkout", "main"]); + + yield* fetchGitPullRequestBranch({ + cwd: tmp, + prNumber: 55, + branch: "feature/pr-fetch", + }); + + const localBranches = yield* git(tmp, ["branch", "--list", "feature/pr-fetch"]); + expect(localBranches).toContain("feature/pr-fetch"); + const currentBranch = yield* git(tmp, ["branch", "--show-current"]); + expect(currentBranch).toBe("main"); + }), + ); + }); + // ── Full flow: thread switching simulation ── describe("full flow: thread switching (checkout toggling)", () => { diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index a288b2f3799cabd16d86e244c0fa0a2dc1d4855c..6df74aaafcc8889f1e539166ebc60138d0981a75 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -418,6 +418,29 @@ const makeGitCore = Effect.gen(function* () { allowNonZeroExit: true, }).pipe(Effect.map((result) => result.code === 0)); + const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => + runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( + Effect.map((stdout) => parseRemoteNames(stdout).toReversed()), + ); + + const resolvePrimaryRemoteName = (cwd: string): Effect.Effect => + Effect.gen(function* () { + if (yield* originRemoteExists(cwd)) { + return "origin"; + } + const remotes = yield* listRemoteNames(cwd); + const [firstRemote] = remotes; + if (firstRemote) { + return firstRemote; + } + return yield* createGitCommandError( + "GitCore.resolvePrimaryRemoteName", + cwd, + ["remote"], + "No git remote is configured for this repository.", + ); + }); + const resolveBaseBranchForNoUpstream = ( cwd: string, branch: string, @@ -1015,16 +1038,20 @@ const makeGitCore = Effect.gen(function* () { const createWorktree: GitCoreShape["createWorktree"] = (input) => Effect.gen(function* () { - const sanitizedBranch = input.newBranch.replace(/\//g, "-"); + const targetBranch = input.newBranch ?? input.branch; + const sanitizedBranch = targetBranch.replace(/\//g, "-"); const repoName = path.basename(input.cwd); const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp"; const worktreePath = input.path ?? path.join(homeDir, ".t3", "worktrees", repoName, sanitizedBranch); + const args = input.newBranch + ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch] + : ["worktree", "add", worktreePath, input.branch]; yield* executeGit( "GitCore.createWorktree", input.cwd, - ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch], + args, { fallbackErrorMessage: "git worktree add failed", }, @@ -1033,11 +1060,30 @@ const makeGitCore = Effect.gen(function* () { return { worktree: { path: worktreePath, - branch: input.newBranch, + branch: targetBranch, }, }; }); + const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = (input) => + Effect.gen(function* () { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + yield* executeGit( + "GitCore.fetchPullRequestBranch", + input.cwd, + [ + "fetch", + "--quiet", + "--no-tags", + remoteName, + `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, + ], + { + fallbackErrorMessage: "git fetch pull request branch failed", + }, + ); + }).pipe(Effect.asVoid); + const removeWorktree: GitCoreShape["removeWorktree"] = (input) => Effect.gen(function* () { const args = ["worktree", "remove"]; @@ -1197,6 +1243,7 @@ const makeGitCore = Effect.gen(function* () { readConfigValue, listBranches, createWorktree, + fetchPullRequestBranch, removeWorktree, renameBranch, createBranch, diff --git a/apps/server/src/git/Layers/GitHubCli.test.ts b/apps/server/src/git/Layers/GitHubCli.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2242c307cf1836441778ac66ef6879002f0118e7 --- /dev/null +++ b/apps/server/src/git/Layers/GitHubCli.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest"; +import { Effect } from "effect"; + +vi.mock("../../processRunner", () => ({ + runProcess: vi.fn(), +})); + +import { runProcess } from "../../processRunner"; +import { GitHubCli } from "../Services/GitHubCli.ts"; +import { GitHubCliLive } from "./GitHubCli.ts"; + +const mockedRunProcess = vi.mocked(runProcess); + +describe("GitHubCliLive", () => { + it("parses pull request view output", async () => { + mockedRunProcess.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 42, + title: "Add PR thread creation", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/pr-threads", + state: "OPEN", + mergedAt: null, + }), + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const gh = yield* GitHubCli; + return yield* gh.getPullRequest({ + cwd: "/repo", + reference: "#42", + }); + }).pipe(Effect.provide(GitHubCliLive)), + ); + + expect(result).toEqual({ + number: 42, + title: "Add PR thread creation", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/pr-threads", + state: "open", + }); + expect(mockedRunProcess).toHaveBeenCalledWith( + "gh", + ["pr", "view", "#42", "--json", "number,title,url,baseRefName,headRefName,state,mergedAt"], + expect.objectContaining({ cwd: "/repo" }), + ); + }); + + it("surfaces a friendly error when the pull request is not found", async () => { + mockedRunProcess.mockRejectedValueOnce( + new Error( + "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", + ), + ); + + const error = await Effect.runPromise( + Effect.gen(function* () { + const gh = yield* GitHubCli; + return yield* gh.getPullRequest({ + cwd: "/repo", + reference: "4888", + }); + }).pipe(Effect.provide(GitHubCliLive), Effect.flip), + ); + + expect(error.message).toContain("Pull request not found"); + }); +}); diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index d0e6ebef6f6c158d5684fb8b70a71355f3e6dd43..334cd8be3b3ae1dfd441fcf065f080b8ed405db7 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -2,7 +2,11 @@ import { Effect, Layer } from "effect"; import { runProcess } from "../../processRunner"; import { GitHubCliError } from "../Errors.ts"; -import { GitHubCli, type GitHubCliShape } from "../Services/GitHubCli.ts"; +import { + GitHubCli, + type GitHubCliShape, + type GitHubPullRequestSummary, +} from "../Services/GitHubCli.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -30,6 +34,19 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown }); } + if ( + lower.includes("could not resolve to a pullrequest") || + lower.includes("repository.pullrequest") || + lower.includes("no pull requests found for branch") || + lower.includes("pull request not found") + ) { + return new GitHubCliError({ + operation, + detail: "Pull request not found. Check the PR number or URL and try again.", + cause: error, + }); + } + return new GitHubCliError({ operation, detail: `GitHub CLI command failed: ${error.message}`, @@ -44,13 +61,52 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown }); } -function parseOpenPullRequests(raw: string): ReadonlyArray<{ - number: number; - title: string; - url: string; - baseRefName: string; - headRefName: string; -}> { +function normalizePullRequestState(record: Record): "open" | "closed" | "merged" { + const mergedAt = record.mergedAt; + const state = record.state; + if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { + return "merged"; + } + if (state === "CLOSED") { + return "closed"; + } + return "open"; +} + +function parsePullRequestSummary( + raw: unknown, +): GitHubPullRequestSummary | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return null; + } + const record = raw as Record; + const number = record.number; + const title = record.title; + const url = record.url; + const baseRefName = record.baseRefName; + const headRefName = record.headRefName; + if ( + typeof number !== "number" || + !Number.isInteger(number) || + number <= 0 || + typeof title !== "string" || + typeof url !== "string" || + typeof baseRefName !== "string" || + typeof headRefName !== "string" + ) { + return null; + } + return { + number, + title, + url, + baseRefName, + headRefName, + state: normalizePullRequestState(record), + }; +} + +function parseOpenPullRequests(raw: string): ReadonlyArray { const trimmed = raw.trim(); if (trimmed.length === 0) return []; @@ -59,41 +115,12 @@ function parseOpenPullRequests(raw: string): ReadonlyArray<{ throw new Error("GitHub CLI returned non-array JSON."); } - const result: Array<{ - number: number; - title: string; - url: string; - baseRefName: string; - headRefName: string; - }> = []; + const result: Array = []; for (const entry of parsed) { - if (!entry || typeof entry !== "object") { - continue; + const parsedEntry = parsePullRequestSummary(entry); + if (parsedEntry) { + result.push(parsedEntry); } - const record = entry as Record; - const number = record.number; - const title = record.title; - const url = record.url; - const baseRefName = record.baseRefName; - const headRefName = record.headRefName; - if ( - typeof number !== "number" || - !Number.isInteger(number) || - number <= 0 || - typeof title !== "string" || - typeof url !== "string" || - typeof baseRefName !== "string" || - typeof headRefName !== "string" - ) { - continue; - } - result.push({ - number, - title, - url, - baseRefName, - headRefName, - }); } return result; @@ -144,6 +171,40 @@ const makeGitHubCli = Effect.sync(() => { }), ), ), + getPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + input.reference, + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + Effect.try({ + try: () => { + const parsed = raw.length > 0 ? JSON.parse(raw) : null; + const summary = parsePullRequestSummary(parsed); + if (!summary) { + throw new Error("GitHub CLI returned invalid pull request JSON."); + } + return summary; + }, + catch: (error: unknown) => + new GitHubCliError({ + operation: "getPullRequest", + detail: + error instanceof Error + ? `GitHub CLI returned invalid pull request JSON: ${error.message}` + : "GitHub CLI returned invalid pull request JSON.", + ...(error !== undefined ? { cause: error } : {}), + }), + }), + ), + ), createPullRequest: (input) => execute({ cwd: input.cwd, @@ -170,6 +231,16 @@ const makeGitHubCli = Effect.sync(() => { return trimmed.length > 0 ? trimmed : null; }), ), + checkoutPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "checkout", + input.reference, + ...(input.force ? ["--force"] : []), + ], + }).pipe(Effect.asVoid), } satisfies GitHubCliShape; return service; diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index d8a3753bb0653c9e178dd07dbccd6a9c6c6d48ee..ff02b5b776faefc06ea34b365a360de949fa19bf 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -23,6 +23,14 @@ interface FakeGhScenario { prListSequence?: string[]; createdPrUrl?: string; defaultBranch?: string; + pullRequest?: { + number: number; + title: string; + url: string; + baseRefName: string; + headRefName: string; + state?: "open" | "closed" | "merged"; + }; failWith?: GitHubCliError; } @@ -203,6 +211,26 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } if (args[0] === "pr" && args[1] === "view") { + return Effect.succeed({ + stdout: + JSON.stringify( + scenario.pullRequest ?? { + number: 101, + title: "Pull request", + url: "https://github.com/pingdotgg/codething-mvp/pull/101", + baseRefName: "main", + headRefName: "feature/pull-request", + state: "OPEN", + }, + ) + "\n", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + } + + if (args[0] === "pr" && args[1] === "checkout") { return Effect.succeed({ stdout: "", stderr: "", @@ -279,6 +307,29 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return value.length > 0 ? value : null; }), ), + getPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + input.reference, + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt", + ], + }).pipe( + Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary), + ), + checkoutPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "checkout", + input.reference, + ...(input.force ? ["--force"] : []), + ], + }).pipe(Effect.asVoid), }, ghCalls, }; @@ -296,6 +347,17 @@ function runStackedAction( return manager.runStackedAction(input); } +function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) { + return manager.resolvePullRequest(input); +} + +function preparePullRequestThread( + manager: GitManagerShape, + input: { cwd: string; reference: string; mode: "local" | "worktree" }, +) { + return manager.preparePullRequestThread(input); +} + function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; @@ -924,4 +986,189 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(errorMessage).toContain("gh auth login"); }), ); + + it.effect("resolves pull requests from #number references", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 42, + title: "Resolve PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/resolve-pr", + state: "open", + }, + }, + }); + + const result = yield* resolvePullRequest(manager, { + cwd: repoDir, + reference: "#42", + }); + + expect(result.pullRequest).toEqual({ + number: 42, + title: "Resolve PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseBranch: "main", + headBranch: "feature/resolve-pr", + state: "open", + }); + expect(ghCalls.some((call) => call.startsWith("pr view 42 "))).toBe(true); + }), + ); + + it.effect("prepares pull request threads in local mode by checking out the PR branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local"]); + fs.writeFileSync(path.join(repoDir, "local.txt"), "local\n"); + yield* runGit(repoDir, ["add", "local.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local PR branch"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 64, + title: "Local PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/64", + baseRefName: "main", + headRefName: "feature/pr-local", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "#64", + mode: "local", + }); + + expect(result.branch).toBe("feature/pr-local"); + expect(result.worktreePath).toBeNull(); + const branch = (yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim(); + expect(branch).toBe("feature/pr-local"); + expect(ghCalls).toContain("pr checkout 64 --force"); + }), + ); + + it.effect("prepares pull request threads in worktree mode on the PR head branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree"]); + fs.writeFileSync(path.join(repoDir, "worktree.txt"), "worktree\n"); + yield* runGit(repoDir, ["add", "worktree.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR worktree branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree"]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/77/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 77, + title: "Worktree PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/77", + baseRefName: "main", + headRefName: "feature/pr-worktree", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "77", + mode: "worktree", + }); + + expect(result.branch).toBe("feature/pr-worktree"); + expect(result.worktreePath).not.toBeNull(); + expect(fs.existsSync(result.worktreePath as string)).toBe(true); + const worktreeBranch = ( + yield* runGit(result.worktreePath as string, ["branch", "--show-current"]) + ).stdout.trim(); + expect(worktreeBranch).toBe("feature/pr-worktree"); + }), + ); + + it.effect("reuses an existing dedicated worktree for the PR head branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-existing-worktree"]); + fs.writeFileSync(path.join(repoDir, "existing.txt"), "existing\n"); + yield* runGit(repoDir, ["add", "existing.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Existing worktree branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = path.join(repoDir, "..", `pr-existing-${Date.now()}`); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 78, + title: "Existing worktree PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/78", + baseRefName: "main", + headRefName: "feature/pr-existing-worktree", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "78", + mode: "worktree", + }); + + expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( + fs.realpathSync.native(worktreePath), + ); + expect(result.branch).toBe("feature/pr-existing-worktree"); + }), + ); + + it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-root-only"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 79, + title: "Root-only PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/79", + baseRefName: "main", + headRefName: "feature/pr-root-only", + state: "open", + }, + }, + }); + + const errorMessage = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "79", + mode: "worktree", + }).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + + expect(errorMessage).toContain("already checked out in the main repo"); + }), + ); }); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index c4a29e15ddb3637012af6df925cd8892759510cf..f25213540600dc99975e652e8b1306f90389644e 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { realpathSync } from "node:fs"; import { Effect, FileSystem, Layer, Path } from "effect"; import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; @@ -22,6 +23,15 @@ interface PullRequestInfo extends OpenPrInfo { updatedAt: string | null; } +interface ResolvedPullRequest { + number: number; + title: string; + url: string; + baseBranch: string; + headBranch: string; + state: "open" | "closed" | "merged"; +} + function parsePullRequestList(raw: unknown): PullRequestInfo[] { if (!Array.isArray(raw)) return []; @@ -175,6 +185,38 @@ function toStatusPr(pr: PullRequestInfo): { }; } +function normalizePullRequestReference(reference: string): string { + const trimmed = reference.trim(); + const hashNumber = /^#(\d+)$/.exec(trimmed); + return hashNumber?.[1] ?? trimmed; +} + +function canonicalizeExistingPath(value: string): string { + try { + return realpathSync.native(value); + } catch { + return value; + } +} + +function toResolvedPullRequest(pr: { + number: number; + title: string; + url: string; + baseRefName: string; + headRefName: string; + state?: "open" | "closed" | "merged"; +}): ResolvedPullRequest { + return { + number: pr.number, + title: pr.title, + url: pr.url, + baseBranch: pr.baseRefName, + headBranch: pr.headRefName, + state: pr.state ?? "open", + }; +} + export const makeGitManager = Effect.gen(function* () { const gitCore = yield* GitCore; const gitHubCli = yield* GitHubCli; @@ -445,6 +487,115 @@ export const makeGitManager = Effect.gen(function* () { }; }); + const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fnUntraced(function* ( + input, + ) { + const pullRequest = yield* gitHubCli + .getPullRequest({ + cwd: input.cwd, + reference: normalizePullRequestReference(input.reference), + }) + .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); + + return { pullRequest }; + }); + + const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fnUntraced( + function* (input) { + const normalizedReference = normalizePullRequestReference(input.reference); + const rootWorktreePath = canonicalizeExistingPath(input.cwd); + const pullRequest = yield* gitHubCli + .getPullRequest({ + cwd: input.cwd, + reference: normalizedReference, + }) + .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); + + if (input.mode === "local") { + yield* gitHubCli.checkoutPullRequest({ + cwd: input.cwd, + reference: normalizedReference, + force: true, + }); + const details = yield* gitCore.statusDetails(input.cwd); + return { + pullRequest, + branch: details.branch ?? pullRequest.headBranch, + worktreePath: null, + }; + } + + const findLocalHeadBranch = (cwd: string) => + gitCore.listBranches({ cwd }).pipe( + Effect.map((result) => + result.branches.find( + (branch) => !branch.isRemote && branch.name === pullRequest.headBranch, + ), + ), + ); + + const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); + const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath + ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) + : null; + if ( + existingBranchBeforeFetch?.worktreePath && + existingBranchBeforeFetchPath !== rootWorktreePath + ) { + return { + pullRequest, + branch: pullRequest.headBranch, + worktreePath: existingBranchBeforeFetch.worktreePath, + }; + } + if (existingBranchBeforeFetchPath === rootWorktreePath) { + return yield* gitManagerError( + "preparePullRequestThread", + "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.", + ); + } + + yield* gitCore.fetchPullRequestBranch({ + cwd: input.cwd, + prNumber: pullRequest.number, + branch: pullRequest.headBranch, + }); + + const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); + const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath + ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) + : null; + if ( + existingBranchAfterFetch?.worktreePath && + existingBranchAfterFetchPath !== rootWorktreePath + ) { + return { + pullRequest, + branch: pullRequest.headBranch, + worktreePath: existingBranchAfterFetch.worktreePath, + }; + } + if (existingBranchAfterFetchPath === rootWorktreePath) { + return yield* gitManagerError( + "preparePullRequestThread", + "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.", + ); + } + + const worktree = yield* gitCore.createWorktree({ + cwd: input.cwd, + branch: pullRequest.headBranch, + path: null, + }); + + return { + pullRequest, + branch: worktree.worktree.branch, + worktreePath: worktree.worktree.path, + }; + }, + ); + const runFeatureBranchStep = (cwd: string, branch: string | null, commitMessage?: string) => Effect.gen(function* () { const suggestion = yield* resolveCommitAndBranchSuggestion({ @@ -536,6 +687,8 @@ export const makeGitManager = Effect.gen(function* () { return { status, + resolvePullRequest, + preparePullRequestThread, runStackedAction, } satisfies GitManagerShape; }); diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index c60b9bd3e903213fbfcc92ea61c253efae3b646f..7fbfc33506908aa00a421649a5d6fb0c9e9c4322 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -56,6 +56,12 @@ export interface GitRenameBranchResult { branch: string; } +export interface GitFetchPullRequestBranchInput { + cwd: string; + prNumber: number; + branch: string; +} + /** * GitCoreShape - Service API for low-level Git repository interactions. */ @@ -129,6 +135,13 @@ export interface GitCoreShape { input: GitCreateWorktreeInput, ) => Effect.Effect; + /** + * Materialize a GitHub pull request head as a local branch without switching checkout. + */ + readonly fetchPullRequestBranch: ( + input: GitFetchPullRequestBranchInput, + ) => Effect.Effect; + /** * Remove an existing worktree. */ diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts index db0ed95aa94cf2f6eefa25b85f02021360357c7e..aa0402d8f27a916e4517584ff4083bc6a65d3ee3 100644 --- a/apps/server/src/git/Services/GitHubCli.ts +++ b/apps/server/src/git/Services/GitHubCli.ts @@ -17,6 +17,7 @@ export interface GitHubPullRequestSummary { readonly url: string; readonly baseRefName: string; readonly headRefName: string; + readonly state?: "open" | "closed" | "merged"; } /** @@ -41,6 +42,14 @@ export interface GitHubCliShape { readonly limit?: number; }) => Effect.Effect, GitHubCliError>; + /** + * Resolve a pull request by URL, number, or branch-ish identifier. + */ + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + /** * Create a pull request from branch context and body file. */ @@ -58,6 +67,15 @@ export interface GitHubCliShape { readonly getDefaultBranch: (input: { readonly cwd: string; }) => Effect.Effect; + + /** + * Checkout a pull request into the current repository worktree. + */ + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; } /** diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index e3f90202dd92a047e9b466fb0a32c7b48900e8e8..650581c71669421a3a0759bb4e6d01aa264e8583 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -7,6 +7,10 @@ * @module GitManager */ import { + GitPreparePullRequestThreadInput, + GitPreparePullRequestThreadResult, + GitPullRequestRefInput, + GitResolvePullRequestResult, GitRunStackedActionInput, GitRunStackedActionResult, GitStatusInput, @@ -27,6 +31,20 @@ export interface GitManagerShape { input: GitStatusInput, ) => Effect.Effect; + /** + * Resolve a pull request by URL/number against the current repository. + */ + readonly resolvePullRequest: ( + input: GitPullRequestRefInput, + ) => Effect.Effect; + + /** + * Prepare a new thread workspace from a pull request in local or worktree mode. + */ + readonly preparePullRequestThread: ( + input: GitPreparePullRequestThreadInput, + ) => Effect.Effect; + /** * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`). * When `featureBranch` is set, creates and checks out a feature branch first. diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 80d57d7c6f9c730d4f518bed5ac301be496786f9..cb9092cc7e2b729d5fd0a7304acbf5d2dd038950 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1638,7 +1638,14 @@ describe("WebSocket Server", () => { const status = vi.fn(() => Effect.succeed(statusResult)); const runStackedAction = vi.fn(() => Effect.void as any); - const gitManager: GitManagerShape = { status, runStackedAction }; + const resolvePullRequest = vi.fn(() => Effect.void as any); + const preparePullRequestThread = vi.fn(() => Effect.void as any); + const gitManager: GitManagerShape = { + status, + resolvePullRequest, + preparePullRequestThread, + runStackedAction, + }; server = await createTestServer({ cwd: "/test", gitManager }); const addr = server.address(); @@ -1656,6 +1663,63 @@ describe("WebSocket Server", () => { expect(status).toHaveBeenCalledWith({ cwd: "/test" }); }); + it("supports git pull request routing over websocket", async () => { + const resolvePullRequestResult = { + pullRequest: { + number: 42, + title: "PR thread flow", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseBranch: "main", + headBranch: "feature/pr-threads", + state: "open" as const, + }, + }; + const preparePullRequestThreadResult = { + ...resolvePullRequestResult, + branch: "feature/pr-threads", + worktreePath: "/tmp/pr-threads", + }; + + const gitManager: GitManagerShape = { + status: vi.fn(() => Effect.void as any), + resolvePullRequest: vi.fn(() => Effect.succeed(resolvePullRequestResult)), + preparePullRequestThread: vi.fn(() => Effect.succeed(preparePullRequestThreadResult)), + runStackedAction: vi.fn(() => Effect.void as any), + }; + + server = await createTestServer({ cwd: "/test", gitManager }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const resolveResponse = await sendRequest(ws, WS_METHODS.gitResolvePullRequest, { + cwd: "/test", + reference: "#42", + }); + expect(resolveResponse.error).toBeUndefined(); + expect(resolveResponse.result).toEqual(resolvePullRequestResult); + + const prepareResponse = await sendRequest(ws, WS_METHODS.gitPreparePullRequestThread, { + cwd: "/test", + reference: "42", + mode: "worktree", + }); + expect(prepareResponse.error).toBeUndefined(); + expect(prepareResponse.result).toEqual(preparePullRequestThreadResult); + expect(gitManager.resolvePullRequest).toHaveBeenCalledWith({ + cwd: "/test", + reference: "#42", + }); + expect(gitManager.preparePullRequestThread).toHaveBeenCalledWith({ + cwd: "/test", + reference: "42", + mode: "worktree", + }); + }); + it("returns errors from git.runStackedAction", async () => { const runStackedAction = vi.fn(() => Effect.fail( @@ -1667,6 +1731,8 @@ describe("WebSocket Server", () => { ); const gitManager: GitManagerShape = { status: vi.fn(() => Effect.void as any), + resolvePullRequest: vi.fn(() => Effect.void as any), + preparePullRequestThread: vi.fn(() => Effect.void as any), runStackedAction, }; diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index aee34cc8882d85a26a077ecdf7274a8e3db5959e..cc118eb752bb4fc2727d6b715b1dda5330f2d9ea 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -832,6 +832,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* gitManager.runStackedAction(body); } + case WS_METHODS.gitResolvePullRequest: { + const body = stripRequestTag(request.body); + return yield* gitManager.resolvePullRequest(body); + } + + case WS_METHODS.gitPreparePullRequestThread: { + const body = stripRequestTag(request.body); + return yield* gitManager.preparePullRequestThread(body); + } + case WS_METHODS.gitListBranches: { const body = stripRequestTag(request.body); return yield* git.listBranches(body); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index d1c68d321dffc002ffcdfac0c603c5c93586ea2d..c6311ccac8f57ed5a809fa953a406115fe529632 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -17,6 +17,7 @@ interface BranchToolbarProps { threadId: ThreadId; onEnvModeChange: (mode: EnvMode) => void; envLocked: boolean; + onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -24,6 +25,7 @@ export default function BranchToolbar({ threadId, onEnvModeChange, envLocked, + onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarProps) { const threads = useStore((store) => store.threads); @@ -128,6 +130,9 @@ export default function BranchToolbar({ effectiveEnvMode={effectiveEnvMode} envLocked={envLocked} onSetThreadBranch={setThreadBranch} + {...(onCheckoutPullRequestRequest + ? { onCheckoutPullRequestRequest } + : {})} {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} /> diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index ba27e383d8c834912cd2e2ec813b76908aef0ef7..249d9b45a29901d2cda4b1245eb7d815d4237c38 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -14,6 +14,7 @@ import { import { gitBranchesQueryOptions, gitQueryKeys, invalidateGitQueries } from "../lib/gitReactQuery"; import { readNativeApi } from "../nativeApi"; +import { parsePullRequestReference } from "../pullRequestReference"; import { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, @@ -40,6 +41,7 @@ interface BranchToolbarBranchSelectorProps { effectiveEnvMode: EnvMode; envLocked: boolean; onSetThreadBranch: (branch: string | null, worktreePath: string | null) => void; + onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -70,6 +72,7 @@ export function BranchToolbarBranchSelector({ effectiveEnvMode, envLocked, onSetThreadBranch, + onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { const queryClient = useQueryClient(); @@ -95,17 +98,27 @@ export function BranchToolbarBranchSelector({ ); const trimmedBranchQuery = branchQuery.trim(); const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); + const prReference = parsePullRequestReference(trimmedBranchQuery); + const checkoutPullRequestItemValue = prReference && onCheckoutPullRequestRequest + ? `__checkout_pull_request__:${prReference}` + : null; const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0; const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); const createBranchItemValue = canCreateBranch ? `__create_new_branch__:${trimmedBranchQuery}` : null; const branchPickerItems = useMemo( - () => - createBranchItemValue && !hasExactBranchMatch - ? [...branchNames, createBranchItemValue] - : branchNames, - [branchNames, createBranchItemValue, hasExactBranchMatch], + () => { + const items = [...branchNames]; + if (createBranchItemValue && !hasExactBranchMatch) { + items.push(createBranchItemValue); + } + if (checkoutPullRequestItemValue) { + items.unshift(checkoutPullRequestItemValue); + } + return items; + }, + [branchNames, checkoutPullRequestItemValue, createBranchItemValue, hasExactBranchMatch], ); const filteredBranchPickerItems = useMemo( () => @@ -338,6 +351,37 @@ export function BranchToolbarBranchSelector({ {virtualBranchRows.map((virtualRow) => { const itemValue = filteredBranchPickerItems[virtualRow.index]; if (!itemValue) return null; + if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { + return ( + { + if (!prReference || !onCheckoutPullRequestRequest) { + return; + } + setIsBranchMenuOpen(false); + setBranchQuery(""); + onComposerFocusRequest?.(); + onCheckoutPullRequestRequest(prReference); + }} + > +
+ Checkout Pull Request + {prReference} +
+
+ ); + } if (createBranchItemValue && itemValue === createBranchItemValue) { return ( { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -609,6 +615,12 @@ export default function ChatView({ threadId }: ChatViewProps) { const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + const getDraftThreadByProjectId = useComposerDraftStore((store) => store.getDraftThreadByProjectId); + const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); + const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); + const clearProjectDraftThreadId = useComposerDraftStore( + (store) => store.clearProjectDraftThreadId, + ); const draftThread = useComposerDraftStore( (store) => store.draftThreadsByThreadId[threadId] ?? null, ); @@ -645,6 +657,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); + const [pullRequestComposerMenuState, setPullRequestComposerMenuState] = + useState(null); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); @@ -749,6 +763,80 @@ export default function ChatView({ threadId }: ChatViewProps) { const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProject = projects.find((p) => p.id === activeThread?.projectId); + const openPullRequestComposerMenu = useCallback((reference?: string) => { + setPullRequestComposerMenuState({ + initialReference: reference ?? null, + key: Date.now(), + }); + setComposerHighlightedItemId(null); + }, []); + + const closePullRequestComposerMenu = useCallback(() => { + setPullRequestComposerMenuState(null); + }, []); + + const openOrReuseProjectDraftThread = useCallback( + async (input: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => { + if (!activeProject) { + throw new Error("No active project is available for this pull request."); + } + const storedDraftThread = getDraftThreadByProjectId(activeProject.id); + if (storedDraftThread) { + setDraftThreadContext(storedDraftThread.threadId, input); + setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input); + if (storedDraftThread.threadId !== threadId) { + await navigate({ + to: "/$threadId", + params: { threadId: storedDraftThread.threadId }, + }); + } + return; + } + + const activeDraftThread = getDraftThread(threadId); + if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { + setDraftThreadContext(threadId, input); + setProjectDraftThreadId(activeProject.id, threadId, input); + return; + } + + clearProjectDraftThreadId(activeProject.id); + const nextThreadId = newThreadId(); + setProjectDraftThreadId(activeProject.id, nextThreadId, { + createdAt: new Date().toISOString(), + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + ...input, + }); + await navigate({ + to: "/$threadId", + params: { threadId: nextThreadId }, + }); + }, + [ + activeProject, + clearProjectDraftThreadId, + getDraftThread, + getDraftThreadByProjectId, + isServerThread, + navigate, + setDraftThreadContext, + setProjectDraftThreadId, + threadId, + ], + ); + + const handlePreparedPullRequestThread = useCallback( + async (input: { branch: string; worktreePath: string | null }) => { + await openOrReuseProjectDraftThread({ + branch: input.branch, + worktreePath: input.worktreePath, + envMode: input.worktreePath ? "worktree" : "local", + }); + }, + [openOrReuseProjectDraftThread], + ); + useEffect(() => { if (!activeThread?.id) return; if (!latestTurnSettled) return; @@ -1162,6 +1250,7 @@ export default function ChatView({ threadId }: ChatViewProps) { timelineEntries, ]); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; + const pullRequestComposerMenuOpen = pullRequestComposerMenuState !== null; const composerTriggerKind = composerTrigger?.kind ?? null; const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; @@ -1218,6 +1307,13 @@ export default function ChatView({ threadId }: ChatViewProps) { label: "/default", description: "Switch this thread back to normal chat mode", }, + { + id: "slash:checkout-pr", + type: "slash-command", + command: "checkout-pr", + label: "/checkout-pr", + description: "Create a new thread from a GitHub pull request", + }, ] satisfies ReadonlyArray>; const query = composerTrigger.query.trim().toLowerCase(); if (!query) { @@ -1245,13 +1341,15 @@ export default function ChatView({ threadId }: ChatViewProps) { description: `${providerLabel} · ${slug}`, })); }, [composerTrigger, searchableModelOptions, workspaceEntries]); - const composerMenuOpen = Boolean(composerTrigger); + const composerMenuOpen = Boolean(composerTrigger) || pullRequestComposerMenuOpen; const activeComposerMenuItem = useMemo( () => - composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? - composerMenuItems[0] ?? - null, - [composerHighlightedItemId, composerMenuItems], + pullRequestComposerMenuOpen + ? null + : (composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? + composerMenuItems[0] ?? + null), + [composerHighlightedItemId, composerMenuItems, pullRequestComposerMenuOpen], ); composerMenuOpenRef.current = composerMenuOpen; composerMenuItemsRef.current = composerMenuItems; @@ -1950,6 +2048,7 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { setExpandedWorkGroups({}); + setPullRequestComposerMenuState(null); if (planSidebarOpenOnNextThreadRef.current) { planSidebarOpenOnNextThreadRef.current = false; setPlanSidebarOpen(true); @@ -1964,12 +2063,16 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerHighlightedItemId(null); return; } + if (pullRequestComposerMenuOpen) { + setComposerHighlightedItemId(null); + return; + } setComposerHighlightedItemId((existing) => existing && composerMenuItems.some((item) => item.id === existing) ? existing : (composerMenuItems[0]?.id ?? null), ); - }, [composerMenuItems, composerMenuOpen]); + }, [composerMenuItems, composerMenuOpen, pullRequestComposerMenuOpen]); useEffect(() => { setIsRevertingCheckpoint(false); @@ -2480,7 +2583,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const standaloneSlashCommand = composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { - await handleInteractionModeChange(standaloneSlashCommand); + if (standaloneSlashCommand === "checkout-pr") { + openPullRequestComposerMenu(); + } else { + await handleInteractionModeChange(standaloneSlashCommand); + } promptRef.current = ""; clearComposerDraftContent(activeThread.id); setComposerHighlightedItemId(null); @@ -3269,7 +3376,11 @@ export default function ChatView({ threadId }: ChatViewProps) { } return; } - void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); + if (item.command === "checkout-pr") { + openPullRequestComposerMenu(); + } else { + void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); + } const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: expectedToken, }); @@ -3289,6 +3400,7 @@ export default function ChatView({ threadId }: ChatViewProps) { [ applyPromptReplacement, handleInteractionModeChange, + openPullRequestComposerMenu, onProviderModelSelect, resolveActiveComposerTrigger, ], @@ -3363,6 +3475,12 @@ export default function ChatView({ threadId }: ChatViewProps) { const { trigger } = resolveActiveComposerTrigger(); const menuIsActive = composerMenuOpenRef.current || trigger !== null; + if (pullRequestComposerMenuOpen) { + if (key === "ArrowDown" || key === "ArrowUp" || key === "Tab" || key === "Enter") { + return true; + } + } + if (menuIsActive) { const currentItems = composerMenuItemsRef.current; if (key === "ArrowDown" && currentItems.length > 0) { @@ -3584,15 +3702,28 @@ export default function ChatView({ threadId }: ChatViewProps) { > {composerMenuOpen && !isComposerApprovalState && (
- + {pullRequestComposerMenuState ? ( + { + await handlePreparedPullRequestThread(input); + closePullRequestComposerMenu(); + }} + /> + ) : ( + + )}
)} @@ -3688,7 +3819,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ? "Add feedback to refine the plan, or leave this blank to implement it" : phase === "disconnected" ? "Ask for follow-up changes or attach images" - : "Ask anything, @tag files/folders, or use /model" + : "Ask anything, @tag files/folders, or use / to show available commands" } disabled={isConnecting || isComposerApprovalState} /> @@ -4018,10 +4149,12 @@ export default function ChatView({ threadId }: ChatViewProps) { onEnvModeChange={onEnvModeChange} envLocked={envLocked} onComposerFocusRequest={scheduleComposerFocus} + {...(canCheckoutPullRequestIntoThread + ? { onCheckoutPullRequestRequest: openPullRequestComposerMenu } + : {})} /> )} - - {/* end chat column */} + {/* end chat column */} {/* Plan sidebar */} {planSidebarOpen ? ( diff --git a/apps/web/src/components/ComposerPullRequestCommandMenu.tsx b/apps/web/src/components/ComposerPullRequestCommandMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..53dd7ab2ea363c5d8e3d72ce150c1ba7a0252e19 --- /dev/null +++ b/apps/web/src/components/ComposerPullRequestCommandMenu.tsx @@ -0,0 +1,259 @@ +import type { GitResolvePullRequestResult } from "@t3tools/contracts"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useDebouncedValue } from "@tanstack/react-pacer"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { + gitPreparePullRequestThreadMutationOptions, + gitResolvePullRequestQueryOptions, +} from "~/lib/gitReactQuery"; +import { cn } from "~/lib/utils"; +import { parsePullRequestReference } from "~/pullRequestReference"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Spinner } from "./ui/spinner"; + +interface ComposerPullRequestCommandMenuProps { + cwd: string | null; + initialReference: string | null; + onCancel: () => void; + onPrepared: (input: { branch: string; worktreePath: string | null }) => Promise | void; +} + +export function ComposerPullRequestCommandMenu({ + cwd, + initialReference, + onCancel, + onPrepared, +}: ComposerPullRequestCommandMenuProps) { + const queryClient = useQueryClient(); + const referenceInputRef = useRef(null); + const [reference, setReference] = useState(initialReference ?? ""); + const [referenceDirty, setReferenceDirty] = useState(false); + const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null); + const [debouncedReference, referenceDebouncer] = useDebouncedValue( + reference, + { wait: 450 }, + (debouncerState) => ({ isPending: debouncerState.isPending }), + ); + + useEffect(() => { + setReference(initialReference ?? ""); + setReferenceDirty(false); + setPreparingMode(null); + }, [initialReference]); + + useEffect(() => { + const frame = window.requestAnimationFrame(() => { + referenceInputRef.current?.focus(); + referenceInputRef.current?.select(); + }); + return () => { + window.cancelAnimationFrame(frame); + }; + }, []); + + const parsedReference = parsePullRequestReference(reference); + const parsedDebouncedReference = parsePullRequestReference(debouncedReference); + const resolvePullRequestQuery = useQuery( + gitResolvePullRequestQueryOptions({ + cwd, + reference: parsedDebouncedReference, + }), + ); + const cachedPullRequest = useMemo(() => { + if (!cwd || !parsedReference) { + return null; + } + const cached = queryClient.getQueryData([ + "git", + "pull-request", + cwd, + parsedReference, + ]); + return cached?.pullRequest ?? null; + }, [cwd, parsedReference, queryClient]); + const preparePullRequestThreadMutation = useMutation( + gitPreparePullRequestThreadMutationOptions({ cwd, queryClient }), + ); + + const liveResolvedPullRequest = + parsedReference !== null && parsedReference === parsedDebouncedReference + ? (resolvePullRequestQuery.data?.pullRequest ?? null) + : null; + const resolvedPullRequest = liveResolvedPullRequest ?? cachedPullRequest; + const isResolving = + parsedReference !== null && + resolvedPullRequest === null && + (referenceDebouncer.state.isPending || + parsedReference !== parsedDebouncedReference || + resolvePullRequestQuery.isPending || + resolvePullRequestQuery.isFetching); + const statusTone = useMemo(() => { + switch (resolvedPullRequest?.state) { + case "merged": + return "text-violet-600 dark:text-violet-300/90"; + case "closed": + return "text-zinc-500 dark:text-zinc-400/80"; + case "open": + return "text-emerald-600 dark:text-emerald-300/90"; + default: + return "text-muted-foreground"; + } + }, [resolvedPullRequest?.state]); + + const handleConfirm = useCallback(async (mode: "local" | "worktree") => { + if (!parsedReference) { + setReferenceDirty(true); + return; + } + if (!parsedReference || !resolvedPullRequest || !cwd) { + return; + } + setPreparingMode(mode); + try { + const result = await preparePullRequestThreadMutation.mutateAsync({ + reference: parsedReference, + mode, + }); + await onPrepared({ + branch: result.branch, + worktreePath: result.worktreePath, + }); + } finally { + setPreparingMode(null); + } + }, [ + cwd, + onPrepared, + parsedReference, + preparePullRequestThreadMutation, + resolvedPullRequest, + ]); + + const validationMessage = + !referenceDirty + ? null + : reference.trim().length === 0 + ? "Paste a GitHub pull request URL or enter 123 / #123." + : parsedReference === null + ? "Use a GitHub pull request URL, 123, or #123." + : null; + const errorMessage = + validationMessage ?? + (resolvedPullRequest === null && resolvePullRequestQuery.isError + ? (resolvePullRequestQuery.error instanceof Error + ? resolvePullRequestQuery.error.message + : "Failed to resolve pull request.") + : (preparePullRequestThreadMutation.error instanceof Error + ? preparePullRequestThreadMutation.error.message + : preparePullRequestThreadMutation.error + ? "Failed to prepare pull request thread." + : null)); + + return ( +
+
+
+
+
+

Checkout Pull Request

+

+ Resolve a PR, then choose Local or Worktree for the new draft thread. +

+
+
+ { + setReferenceDirty(true); + setReference(event.target.value); + }} + onKeyDown={(event) => { + if (event.key !== "Enter") { + return; + } + event.preventDefault(); + if (!isResolving && !preparePullRequestThreadMutation.isPending) { + void handleConfirm("local"); + } + }} + /> +
+ + {resolvedPullRequest ? ( +
+
+
+

{resolvedPullRequest.title}

+

+ #{resolvedPullRequest.number} · {resolvedPullRequest.headBranch} to{" "} + {resolvedPullRequest.baseBranch} +

+
+ + {resolvedPullRequest.state} + +
+
+ ) : null} + + {isResolving ? ( +
+ + Resolving pull request... +
+ ) : null} + + {errorMessage ?

{errorMessage}

: null} +
+ +
+

+ {resolvedPullRequest + ? "Create the draft thread in the main repo or in a dedicated worktree." + : "Resolve the pull request first."} +

+
+ + + +
+
+
+ ); +} diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 7e6805c96d5483d987a4862023bf12a76b9c2835..ef490f6e954109956def53812378420695e0d4a0 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -134,6 +134,10 @@ describe("parseStandaloneComposerSlashCommand", () => { expect(parseStandaloneComposerSlashCommand("/default")).toBe("default"); }); + it("parses standalone /checkout-pr command", () => { + expect(parseStandaloneComposerSlashCommand(" /checkout-pr ")).toBe("checkout-pr"); + }); + it("ignores slash commands with extra message text", () => { expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull(); }); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index f2e367bcf037e48db6f248b14401980ed57481ea..cde009708b33e8c996308e6a0e54f3d97080dff4 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,7 +1,7 @@ import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; -export type ComposerSlashCommand = "model" | "plan" | "default"; +export type ComposerSlashCommand = "model" | "plan" | "default" | "checkout-pr"; export interface ComposerTrigger { kind: ComposerTriggerKind; @@ -10,7 +10,12 @@ export interface ComposerTrigger { rangeEnd: number; } -const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; +const SLASH_COMMANDS: readonly ComposerSlashCommand[] = [ + "model", + "plan", + "default", + "checkout-pr", +]; function clampCursor(text: string, cursor: number): number { if (!Number.isFinite(cursor)) return text.length; @@ -164,11 +169,14 @@ export function parseStandaloneComposerSlashCommand(text: string): Exclude< ComposerSlashCommand, "model" > | null { - const match = /^\/(plan|default)\s*$/i.exec(text.trim()); + const match = /^\/(plan|default|checkout-pr)\s*$/i.exec(text.trim()); if (!match) { return null; } - return match[1]?.toLowerCase() === "plan" ? "plan" : "default"; + const command = match[1]?.toLowerCase(); + if (command === "plan") return "plan"; + if (command === "checkout-pr") return "checkout-pr"; + return "default"; } export function replaceTextRange( diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index c760d345494809cfcdbf27c06f058c8aea3a15c5..964d14fb8be002430203881321871ca82990925e 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -2,6 +2,7 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import { gitMutationKeys, + gitPreparePullRequestThreadMutationOptions, gitPullMutationOptions, gitRunStackedActionMutationOptions, } from "./gitReactQuery"; @@ -16,6 +17,12 @@ describe("gitMutationKeys", () => { it("scopes pull keys by cwd", () => { expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b")); }); + + it("scopes pull request thread preparation keys by cwd", () => { + expect(gitMutationKeys.preparePullRequestThread("/repo/a")).not.toEqual( + gitMutationKeys.preparePullRequestThread("/repo/b"), + ); + }); }); describe("git mutation options", () => { @@ -30,4 +37,12 @@ describe("git mutation options", () => { const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a")); }); + + it("attaches cwd-scoped mutation key for preparePullRequestThread", () => { + const options = gitPreparePullRequestThreadMutationOptions({ + cwd: "/repo/a", + queryClient, + }); + expect(options.mutationKey).toEqual(gitMutationKeys.preparePullRequestThread("/repo/a")); + }); }); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index dbd7ad1c3ae278c1371a9fc43d7d9a6a9c63effd..b574c9364d6d052770901dc5328786e72f1b08e2 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -18,6 +18,8 @@ export const gitMutationKeys = { checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const, runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const, pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const, + preparePullRequestThread: (cwd: string | null) => + ["git", "mutation", "prepare-pull-request-thread", cwd] as const, }; export function invalidateGitQueries(queryClient: QueryClient) { @@ -56,6 +58,26 @@ export function gitBranchesQueryOptions(cwd: string | null) { }); } +export function gitResolvePullRequestQueryOptions(input: { + cwd: string | null; + reference: string | null; +}) { + return queryOptions({ + queryKey: ["git", "pull-request", input.cwd, input.reference] as const, + queryFn: async () => { + const api = ensureNativeApi(); + if (!input.cwd || !input.reference) { + throw new Error("Pull request lookup is unavailable."); + } + return api.git.resolvePullRequest({ cwd: input.cwd, reference: input.reference }); + }, + enabled: input.cwd !== null && input.reference !== null, + staleTime: 30_000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); +} + export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { return mutationOptions({ mutationKey: gitMutationKeys.init(input.cwd), @@ -168,3 +190,30 @@ export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClie }, }); } + +export function gitPreparePullRequestThreadMutationOptions(input: { + cwd: string | null; + queryClient: QueryClient; +}) { + return mutationOptions({ + mutationFn: async ({ + reference, + mode, + }: { + reference: string; + mode: "local" | "worktree"; + }) => { + const api = ensureNativeApi(); + if (!input.cwd) throw new Error("Pull request thread preparation is unavailable."); + return api.git.preparePullRequestThread({ + cwd: input.cwd, + reference, + mode, + }); + }, + mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), + onSettled: async () => { + await invalidateGitQueries(input.queryClient); + }, + }); +} diff --git a/apps/web/src/pullRequestReference.test.ts b/apps/web/src/pullRequestReference.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b1c27701d75054e16d4351ab364340b5eb5d2a2 --- /dev/null +++ b/apps/web/src/pullRequestReference.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { parsePullRequestReference } from "./pullRequestReference"; + +describe("parsePullRequestReference", () => { + it("accepts GitHub pull request URLs", () => { + expect( + parsePullRequestReference("https://github.com/pingdotgg/t3code/pull/42"), + ).toBe("https://github.com/pingdotgg/t3code/pull/42"); + }); + + it("accepts raw numbers", () => { + expect(parsePullRequestReference("42")).toBe("42"); + }); + + it("accepts #number references", () => { + expect(parsePullRequestReference("#42")).toBe("#42"); + }); + + it("rejects non-pull-request input", () => { + expect(parsePullRequestReference("feature/my-branch")).toBeNull(); + }); +}); diff --git a/apps/web/src/pullRequestReference.ts b/apps/web/src/pullRequestReference.ts new file mode 100644 index 0000000000000000000000000000000000000000..ecaf916b71e3c160944a4533c0b86d9dedcd40b2 --- /dev/null +++ b/apps/web/src/pullRequestReference.ts @@ -0,0 +1,22 @@ +const GITHUB_PULL_REQUEST_URL_PATTERN = + /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; +const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; + +export function parsePullRequestReference(input: string): string | null { + const trimmed = input.trim(); + if (trimmed.length === 0) { + return null; + } + + const urlMatch = GITHUB_PULL_REQUEST_URL_PATTERN.exec(trimmed); + if (urlMatch?.[1]) { + return trimmed; + } + + const numberMatch = PULL_REQUEST_NUMBER_PATTERN.exec(trimmed); + if (numberMatch?.[1]) { + return trimmed.startsWith("#") ? trimmed : numberMatch[1]; + } + + return null; +} diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 91e6a61107c3e42f0670200d3b0e96acda196660..5f2bfcc8fb827b9f5bc7cf13e72f708cbb36e99c 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -170,6 +170,9 @@ export function createWsNativeApi(): NativeApi { createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input), checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), init: (input) => transport.request(WS_METHODS.gitInit, input), + resolvePullRequest: (input) => transport.request(WS_METHODS.gitResolvePullRequest, input), + preparePullRequestThread: (input) => + transport.request(WS_METHODS.gitPreparePullRequestThread, input), }, contextMenu: { show: async ( diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..398dc2523bef8c1e125897170502642804653172 --- /dev/null +++ b/packages/contracts/src/git.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { Schema } from "effect"; + +import { + GitCreateWorktreeInput, + GitPreparePullRequestThreadInput, + GitResolvePullRequestResult, +} from "./git"; + +const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInput); +const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync(GitPreparePullRequestThreadInput); +const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); + +describe("GitCreateWorktreeInput", () => { + it("accepts omitted newBranch for existing-branch worktrees", () => { + const parsed = decodeCreateWorktreeInput({ + cwd: "/repo", + branch: "feature/existing", + path: "/tmp/worktree", + }); + + expect(parsed.newBranch).toBeUndefined(); + expect(parsed.branch).toBe("feature/existing"); + }); +}); + +describe("GitPreparePullRequestThreadInput", () => { + it("accepts pull request references and mode", () => { + const parsed = decodePreparePullRequestThreadInput({ + cwd: "/repo", + reference: "#42", + mode: "worktree", + }); + + expect(parsed.reference).toBe("#42"); + expect(parsed.mode).toBe("worktree"); + }); +}); + +describe("GitResolvePullRequestResult", () => { + it("decodes resolved pull request metadata", () => { + const parsed = decodeResolvePullRequestResult({ + pullRequest: { + number: 42, + title: "PR threads", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseBranch: "main", + headBranch: "feature/pr-threads", + state: "open", + }, + }); + + expect(parsed.pullRequest.number).toBe(42); + expect(parsed.pullRequest.headBranch).toBe("feature/pr-threads"); + }); +}); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 80ede248e6859f81c2afed686299e4c71aa8fc75..f22bbca79e869ff1971a27b7e29449a4e2a24151 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -20,6 +20,9 @@ const GitPrStepStatus = Schema.Literals([ "skipped_not_requested", ]); const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); +const GitPullRequestReference = TrimmedNonEmptyStringSchema; +const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); +const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]); export const GitBranch = Schema.Struct({ name: TrimmedNonEmptyStringSchema, @@ -35,6 +38,15 @@ const GitWorktree = Schema.Struct({ path: TrimmedNonEmptyStringSchema, branch: TrimmedNonEmptyStringSchema, }); +const GitResolvedPullRequest = Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyStringSchema, + url: Schema.String, + baseBranch: TrimmedNonEmptyStringSchema, + headBranch: TrimmedNonEmptyStringSchema, + state: GitPullRequestState, +}); +export type GitResolvedPullRequest = typeof GitResolvedPullRequest.Type; // RPC Inputs @@ -64,11 +76,24 @@ export type GitListBranchesInput = typeof GitListBranchesInput.Type; export const GitCreateWorktreeInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, branch: TrimmedNonEmptyStringSchema, - newBranch: TrimmedNonEmptyStringSchema, + newBranch: Schema.optional(TrimmedNonEmptyStringSchema), path: Schema.NullOr(TrimmedNonEmptyStringSchema), }); export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type; +export const GitPullRequestRefInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + reference: GitPullRequestReference, +}); +export type GitPullRequestRefInput = typeof GitPullRequestRefInput.Type; + +export const GitPreparePullRequestThreadInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + reference: GitPullRequestReference, + mode: GitPreparePullRequestThreadMode, +}); +export type GitPreparePullRequestThreadInput = typeof GitPreparePullRequestThreadInput.Type; + export const GitRemoveWorktreeInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, path: TrimmedNonEmptyStringSchema, @@ -136,6 +161,18 @@ export const GitCreateWorktreeResult = Schema.Struct({ }); export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type; +export const GitResolvePullRequestResult = Schema.Struct({ + pullRequest: GitResolvedPullRequest, +}); +export type GitResolvePullRequestResult = typeof GitResolvePullRequestResult.Type; + +export const GitPreparePullRequestThreadResult = Schema.Struct({ + pullRequest: GitResolvedPullRequest, + branch: TrimmedNonEmptyStringSchema, + worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), +}); +export type GitPreparePullRequestThreadResult = typeof GitPreparePullRequestThreadResult.Type; + export const GitRunStackedActionResult = Schema.Struct({ action: GitStackedAction, branch: Schema.Struct({ diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index db9bab415ea0d71b46f6ffaa754f9edf384e77d9..4c93970fcc34a5d9c1197d3cecd146e782bf6bc8 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,6 +1,9 @@ import type { GitCheckoutInput, GitCreateBranchInput, + GitPreparePullRequestThreadInput, + GitPreparePullRequestThreadResult, + GitPullRequestRefInput, GitCreateWorktreeInput, GitCreateWorktreeResult, GitInitInput, @@ -9,6 +12,7 @@ import type { GitPullInput, GitPullResult, GitRemoveWorktreeInput, + GitResolvePullRequestResult, GitRunStackedActionInput, GitRunStackedActionResult, GitStatusInput, @@ -134,6 +138,10 @@ export interface NativeApi { createBranch: (input: GitCreateBranchInput) => Promise; checkout: (input: GitCheckoutInput) => Promise; init: (input: GitInitInput) => Promise; + resolvePullRequest: (input: GitPullRequestRefInput) => Promise; + preparePullRequestThread: ( + input: GitPreparePullRequestThreadInput, + ) => Promise; // Stacked action API pull: (input: GitPullInput) => Promise; status: (input: GitStatusInput) => Promise; diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts index be103f3046ffd36cb0c44d34ed7f2c3f8a3c3b5a..9566225eb8bc314ca32ba00b6946d5c54eb78511 100644 --- a/packages/contracts/src/ws.test.ts +++ b/packages/contracts/src/ws.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import { Effect, Schema } from "effect"; import { ORCHESTRATION_WS_METHODS } from "./orchestration"; +import { WS_METHODS } from "./ws"; import { WebSocketRequest } from "./ws"; const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest); @@ -56,3 +57,18 @@ it.effect("trims websocket request id and nested orchestration ids", () => } }), ); + +it.effect("accepts git.preparePullRequestThread requests", () => + Effect.gen(function* () { + const parsed = yield* decodeWebSocketRequest({ + id: "req-pr-1", + body: { + _tag: WS_METHODS.gitPreparePullRequestThread, + cwd: "/repo", + reference: "#42", + mode: "worktree", + }, + }); + assert.strictEqual(parsed.body._tag, WS_METHODS.gitPreparePullRequestThread); + }), +); diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 1100b4f9df5aea7cde5435f451d60a7b7d2eb66e..9814cae856f1cb6990ea79355914259cad9441f1 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -12,10 +12,12 @@ import { import { GitCheckoutInput, GitCreateBranchInput, + GitPreparePullRequestThreadInput, GitCreateWorktreeInput, GitInitInput, GitListBranchesInput, GitPullInput, + GitPullRequestRefInput, GitRemoveWorktreeInput, GitRunStackedActionInput, GitStatusInput, @@ -55,6 +57,8 @@ export const WS_METHODS = { gitCreateBranch: "git.createBranch", gitCheckout: "git.checkout", gitInit: "git.init", + gitResolvePullRequest: "git.resolvePullRequest", + gitPreparePullRequestThread: "git.preparePullRequestThread", // Terminal methods terminalOpen: "terminal.open", @@ -117,6 +121,8 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput), tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput), tagRequestBody(WS_METHODS.gitInit, GitInitInput), + tagRequestBody(WS_METHODS.gitResolvePullRequest, GitPullRequestRefInput), + tagRequestBody(WS_METHODS.gitPreparePullRequestThread, GitPreparePullRequestThreadInput), // Terminal methods tagRequestBody(WS_METHODS.terminalOpen, TerminalOpenInput),