| author | |
| committer | |
| log | 39390c0b9a8751a9a7213daafd9f1797cbca04ae |
| tree | 46c46fb9d56b2bde7502831596efe973e9145bcd |
| parent | 169d06f05b543433278a123b3512fddad05f9f2a |
14 files changed, 1857 insertions(+), 0 deletions(-)
apps/server/src/codexTextGenerator.ts created+232| ... | ... | @@ -0,0 +1,232 @@ |
| 1 | import { randomUUID } from "node:crypto"; | |
| 2 | import fs from "node:fs/promises"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | ||
| 6 | import type { | |
| 7 | CommitMessageGenerationInput, | |
| 8 | CommitMessageGenerationResult, | |
| 9 | PrContentGenerationInput, | |
| 10 | PrContentGenerationResult, | |
| 11 | TextGenerationService, | |
| 12 | } from "./coreServices"; | |
| 13 | import { runProcess } from "./processRunner"; | |
| 14 | ||
| 15 | const COMMIT_OUTPUT_SCHEMA_JSON = { | |
| 16 | type: "object", | |
| 17 | properties: { | |
| 18 | subject: { type: "string" }, | |
| 19 | body: { type: "string" }, | |
| 20 | }, | |
| 21 | required: ["subject", "body"], | |
| 22 | additionalProperties: false, | |
| 23 | } as const; | |
| 24 | ||
| 25 | const PR_OUTPUT_SCHEMA_JSON = { | |
| 26 | type: "object", | |
| 27 | properties: { | |
| 28 | title: { type: "string" }, | |
| 29 | body: { type: "string" }, | |
| 30 | }, | |
| 31 | required: ["title", "body"], | |
| 32 | additionalProperties: false, | |
| 33 | } as const; | |
| 34 | ||
| 35 | function parseCommitOutput(raw: unknown): { subject: string; body: string } { | |
| 36 | if (!raw || typeof raw !== "object") { | |
| 37 | throw new Error("Codex returned a non-object commit message payload."); | |
| 38 | } | |
| 39 | const record = raw as Record<string, unknown>; | |
| 40 | const subject = typeof record.subject === "string" ? record.subject.trim() : ""; | |
| 41 | const body = typeof record.body === "string" ? record.body : ""; | |
| 42 | if (subject.length === 0) { | |
| 43 | throw new Error("Codex returned an empty commit subject."); | |
| 44 | } | |
| 45 | return { subject, body }; | |
| 46 | } | |
| 47 | ||
| 48 | function parsePrOutput(raw: unknown): { title: string; body: string } { | |
| 49 | if (!raw || typeof raw !== "object") { | |
| 50 | throw new Error("Codex returned a non-object PR payload."); | |
| 51 | } | |
| 52 | const record = raw as Record<string, unknown>; | |
| 53 | const title = typeof record.title === "string" ? record.title.trim() : ""; | |
| 54 | const body = typeof record.body === "string" ? record.body.trim() : ""; | |
| 55 | if (title.length === 0 || body.length === 0) { | |
| 56 | throw new Error("Codex returned an invalid PR title/body payload."); | |
| 57 | } | |
| 58 | return { title, body }; | |
| 59 | } | |
| 60 | ||
| 61 | function limitSection(value: string, maxChars: number): string { | |
| 62 | if (value.length <= maxChars) return value; | |
| 63 | const truncated = value.slice(0, maxChars); | |
| 64 | return `${truncated}\n\n[truncated]`; | |
| 65 | } | |
| 66 | ||
| 67 | async function writeTempFile(prefix: string, content: string): Promise<string> { | |
| 68 | const filePath = path.join( | |
| 69 | os.tmpdir(), | |
| 70 | `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`, | |
| 71 | ); | |
| 72 | await fs.writeFile(filePath, content, "utf8"); | |
| 73 | return filePath; | |
| 74 | } | |
| 75 | ||
| 76 | async function safeUnlink(filePath: string): Promise<void> { | |
| 77 | try { | |
| 78 | await fs.unlink(filePath); | |
| 79 | } catch { | |
| 80 | // Best-effort cleanup. | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | function sanitizeCommitSubject(raw: string): string { | |
| 85 | const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | |
| 86 | const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); | |
| 87 | if (withoutTrailingPeriod.length === 0) { | |
| 88 | return "Update project files"; | |
| 89 | } | |
| 90 | ||
| 91 | if (withoutTrailingPeriod.length <= 72) { | |
| 92 | return withoutTrailingPeriod; | |
| 93 | } | |
| 94 | return withoutTrailingPeriod.slice(0, 72).trimEnd(); | |
| 95 | } | |
| 96 | ||
| 97 | function sanitizePrTitle(raw: string): string { | |
| 98 | const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | |
| 99 | if (singleLine.length > 0) { | |
| 100 | return singleLine; | |
| 101 | } | |
| 102 | return "Update project changes"; | |
| 103 | } | |
| 104 | ||
| 105 | async function runCodexJson<T>({ | |
| 106 | cwd, | |
| 107 | prompt, | |
| 108 | outputSchemaJson, | |
| 109 | parse, | |
| 110 | }: { | |
| 111 | cwd: string; | |
| 112 | prompt: string; | |
| 113 | outputSchemaJson: object; | |
| 114 | parse: (raw: unknown) => T; | |
| 115 | }): Promise<T> { | |
| 116 | const schemaPath = await writeTempFile( | |
| 117 | "codex-schema", | |
| 118 | JSON.stringify(outputSchemaJson), | |
| 119 | ); | |
| 120 | const outputPath = await writeTempFile("codex-output", ""); | |
| 121 | ||
| 122 | try { | |
| 123 | await runProcess( | |
| 124 | "codex", | |
| 125 | [ | |
| 126 | "exec", | |
| 127 | "--ephemeral", | |
| 128 | "-s", | |
| 129 | "read-only", | |
| 130 | "--output-schema", | |
| 131 | schemaPath, | |
| 132 | "--output-last-message", | |
| 133 | outputPath, | |
| 134 | "-", | |
| 135 | ], | |
| 136 | { | |
| 137 | cwd, | |
| 138 | timeoutMs: 180_000, | |
| 139 | stdin: prompt, | |
| 140 | }, | |
| 141 | ); | |
| 142 | ||
| 143 | const rawOutput = (await fs.readFile(outputPath, "utf8")).trim(); | |
| 144 | if (rawOutput.length === 0) { | |
| 145 | throw new Error("Codex returned an empty response."); | |
| 146 | } | |
| 147 | ||
| 148 | let parsedJson: unknown; | |
| 149 | try { | |
| 150 | parsedJson = JSON.parse(rawOutput); | |
| 151 | } catch { | |
| 152 | throw new Error("Codex returned invalid JSON output."); | |
| 153 | } | |
| 154 | ||
| 155 | return parse(parsedJson); | |
| 156 | } finally { | |
| 157 | await Promise.all([safeUnlink(schemaPath), safeUnlink(outputPath)]); | |
| 158 | } | |
| 159 | } | |
| 160 | ||
| 161 | export class CodexTextGenerator implements TextGenerationService { | |
| 162 | async generateCommitMessage( | |
| 163 | input: CommitMessageGenerationInput, | |
| 164 | ): Promise<CommitMessageGenerationResult> { | |
| 165 | const prompt = [ | |
| 166 | "You write concise git commit messages.", | |
| 167 | "Return a JSON object with keys: subject, body.", | |
| 168 | "Rules:", | |
| 169 | "- subject must be imperative, <= 72 chars, and no trailing period", | |
| 170 | "- body can be empty string or short bullet points", | |
| 171 | "- capture the primary user-visible or developer-visible change", | |
| 172 | "", | |
| 173 | `Branch: ${input.branch ?? "(detached)"}`, | |
| 174 | "", | |
| 175 | "Staged files:", | |
| 176 | limitSection(input.stagedSummary, 6_000), | |
| 177 | "", | |
| 178 | "Staged patch:", | |
| 179 | limitSection(input.stagedPatch, 40_000), | |
| 180 | ].join("\n"); | |
| 181 | ||
| 182 | const generated = await runCodexJson({ | |
| 183 | cwd: input.cwd, | |
| 184 | prompt, | |
| 185 | outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON, | |
| 186 | parse: (raw) => parseCommitOutput(raw), | |
| 187 | }); | |
| 188 | ||
| 189 | return { | |
| 190 | subject: sanitizeCommitSubject(generated.subject), | |
| 191 | body: generated.body.trim(), | |
| 192 | }; | |
| 193 | } | |
| 194 | ||
| 195 | async generatePrContent( | |
| 196 | input: PrContentGenerationInput, | |
| 197 | ): Promise<PrContentGenerationResult> { | |
| 198 | const prompt = [ | |
| 199 | "You write GitHub pull request content.", | |
| 200 | "Return a JSON object with keys: title, body.", | |
| 201 | "Rules:", | |
| 202 | "- title should be concise and specific", | |
| 203 | "- body must be markdown and include headings '## Summary' and '## Testing'", | |
| 204 | "- under Summary, provide short bullet points", | |
| 205 | "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", | |
| 206 | "", | |
| 207 | `Base branch: ${input.baseBranch}`, | |
| 208 | `Head branch: ${input.headBranch}`, | |
| 209 | "", | |
| 210 | "Commits:", | |
| 211 | limitSection(input.commitSummary, 12_000), | |
| 212 | "", | |
| 213 | "Diff stat:", | |
| 214 | limitSection(input.diffSummary, 12_000), | |
| 215 | "", | |
| 216 | "Diff patch:", | |
| 217 | limitSection(input.diffPatch, 40_000), | |
| 218 | ].join("\n"); | |
| 219 | ||
| 220 | const generated = await runCodexJson({ | |
| 221 | cwd: input.cwd, | |
| 222 | prompt, | |
| 223 | outputSchemaJson: PR_OUTPUT_SCHEMA_JSON, | |
| 224 | parse: (raw) => parsePrOutput(raw), | |
| 225 | }); | |
| 226 | ||
| 227 | return { | |
| 228 | title: sanitizePrTitle(generated.title), | |
| 229 | body: generated.body.trim(), | |
| 230 | }; | |
| 231 | } | |
| 232 | } |
apps/server/src/coreServices.ts created+35| ... | ... | @@ -0,0 +1,35 @@ |
| 1 | export interface CommitMessageGenerationInput { | |
| 2 | cwd: string; | |
| 3 | branch: string | null; | |
| 4 | stagedSummary: string; | |
| 5 | stagedPatch: string; | |
| 6 | } | |
| 7 | ||
| 8 | export interface CommitMessageGenerationResult { | |
| 9 | subject: string; | |
| 10 | body: string; | |
| 11 | } | |
| 12 | ||
| 13 | export interface PrContentGenerationInput { | |
| 14 | cwd: string; | |
| 15 | baseBranch: string; | |
| 16 | headBranch: string; | |
| 17 | commitSummary: string; | |
| 18 | diffSummary: string; | |
| 19 | diffPatch: string; | |
| 20 | } | |
| 21 | ||
| 22 | export interface PrContentGenerationResult { | |
| 23 | title: string; | |
| 24 | body: string; | |
| 25 | } | |
| 26 | ||
| 27 | export interface TextGenerationService { | |
| 28 | generateCommitMessage( | |
| 29 | input: CommitMessageGenerationInput, | |
| 30 | ): Promise<CommitMessageGenerationResult>; | |
| 31 | generatePrContent( | |
| 32 | input: PrContentGenerationInput, | |
| 33 | ): Promise<PrContentGenerationResult>; | |
| 34 | } | |
| 35 |
apps/server/src/gitManager.test.ts created+397| ... | ... | @@ -0,0 +1,397 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { afterEach, describe, expect, it } from "vitest"; | |
| 6 | ||
| 7 | import { GitManager } from "./gitManager"; | |
| 8 | import { | |
| 9 | type ProcessRunOptions, | |
| 10 | type ProcessRunResult, | |
| 11 | runProcess, | |
| 12 | } from "./processRunner"; | |
| 13 | ||
| 14 | interface FakeGhScenario { | |
| 15 | prListSequence?: string[]; | |
| 16 | createdPrUrl?: string; | |
| 17 | defaultBranch?: string; | |
| 18 | failWith?: Error; | |
| 19 | } | |
| 20 | ||
| 21 | interface FakeGitTextGenerator { | |
| 22 | generateCommitMessage: (input: { | |
| 23 | cwd: string; | |
| 24 | branch: string | null; | |
| 25 | stagedSummary: string; | |
| 26 | stagedPatch: string; | |
| 27 | }) => Promise<{ subject: string; body: string }>; | |
| 28 | generatePrContent: (input: { | |
| 29 | cwd: string; | |
| 30 | baseBranch: string; | |
| 31 | headBranch: string; | |
| 32 | commitSummary: string; | |
| 33 | diffSummary: string; | |
| 34 | diffPatch: string; | |
| 35 | }) => Promise<{ title: string; body: string }>; | |
| 36 | } | |
| 37 | ||
| 38 | const tempDirs: string[] = []; | |
| 39 | ||
| 40 | function makeTempDir(prefix: string): string { | |
| 41 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 42 | tempDirs.push(dir); | |
| 43 | return dir; | |
| 44 | } | |
| 45 | ||
| 46 | async function runGit( | |
| 47 | cwd: string, | |
| 48 | args: readonly string[], | |
| 49 | allowNonZeroExit = false, | |
| 50 | ): Promise<ProcessRunResult> { | |
| 51 | return runProcess("git", args, { cwd, allowNonZeroExit }); | |
| 52 | } | |
| 53 | ||
| 54 | async function runGitStdout(cwd: string, args: readonly string[]): Promise<string> { | |
| 55 | const result = await runGit(cwd, args); | |
| 56 | return result.stdout.trim(); | |
| 57 | } | |
| 58 | ||
| 59 | async function initRepo(cwd: string): Promise<void> { | |
| 60 | await runGit(cwd, ["init", "--initial-branch=main"]); | |
| 61 | await runGit(cwd, ["config", "user.email", "test@example.com"]); | |
| 62 | await runGit(cwd, ["config", "user.name", "Test User"]); | |
| 63 | fs.writeFileSync(path.join(cwd, "README.md"), "hello\n"); | |
| 64 | await runGit(cwd, ["add", "README.md"]); | |
| 65 | await runGit(cwd, ["commit", "-m", "Initial commit"]); | |
| 66 | } | |
| 67 | ||
| 68 | async function createBareRemote(): Promise<string> { | |
| 69 | const remoteDir = makeTempDir("t3code-git-remote-"); | |
| 70 | await runProcess("git", ["init", "--bare"], { cwd: remoteDir }); | |
| 71 | return remoteDir; | |
| 72 | } | |
| 73 | ||
| 74 | function createTextGenerator( | |
| 75 | overrides: Partial<FakeGitTextGenerator> = {}, | |
| 76 | ): FakeGitTextGenerator { | |
| 77 | return { | |
| 78 | generateCommitMessage: async () => ({ | |
| 79 | subject: "Implement stacked git actions", | |
| 80 | body: "", | |
| 81 | }), | |
| 82 | generatePrContent: async () => ({ | |
| 83 | title: "Add stacked git actions", | |
| 84 | body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", | |
| 85 | }), | |
| 86 | ...overrides, | |
| 87 | }; | |
| 88 | } | |
| 89 | ||
| 90 | function createRunnerWithFakeGh(scenario: FakeGhScenario = {}): { | |
| 91 | runner: ( | |
| 92 | command: string, | |
| 93 | args: readonly string[], | |
| 94 | options?: ProcessRunOptions, | |
| 95 | ) => Promise<ProcessRunResult>; | |
| 96 | ghCalls: string[]; | |
| 97 | } { | |
| 98 | const prListQueue = [...(scenario.prListSequence ?? [])]; | |
| 99 | const ghCalls: string[] = []; | |
| 100 | ||
| 101 | const runner = async ( | |
| 102 | command: string, | |
| 103 | args: readonly string[], | |
| 104 | options?: ProcessRunOptions, | |
| 105 | ): Promise<ProcessRunResult> => { | |
| 106 | if (command !== "gh") { | |
| 107 | return runProcess(command, args, options); | |
| 108 | } | |
| 109 | ||
| 110 | ghCalls.push(args.join(" ")); | |
| 111 | if (scenario.failWith) { | |
| 112 | throw scenario.failWith; | |
| 113 | } | |
| 114 | ||
| 115 | if (args[0] === "pr" && args[1] === "list") { | |
| 116 | const stdout = (prListQueue.shift() ?? "[]") + "\n"; | |
| 117 | return { | |
| 118 | stdout, | |
| 119 | stderr: "", | |
| 120 | code: 0, | |
| 121 | signal: null, | |
| 122 | timedOut: false, | |
| 123 | }; | |
| 124 | } | |
| 125 | ||
| 126 | if (args[0] === "pr" && args[1] === "create") { | |
| 127 | return { | |
| 128 | stdout: | |
| 129 | (scenario.createdPrUrl ?? | |
| 130 | "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n", | |
| 131 | stderr: "", | |
| 132 | code: 0, | |
| 133 | signal: null, | |
| 134 | timedOut: false, | |
| 135 | }; | |
| 136 | } | |
| 137 | ||
| 138 | if (args[0] === "pr" && args[1] === "view") { | |
| 139 | return { | |
| 140 | stdout: "", | |
| 141 | stderr: "", | |
| 142 | code: 0, | |
| 143 | signal: null, | |
| 144 | timedOut: false, | |
| 145 | }; | |
| 146 | } | |
| 147 | ||
| 148 | if (args[0] === "repo" && args[1] === "view") { | |
| 149 | return { | |
| 150 | stdout: `${scenario.defaultBranch ?? "main"}\n`, | |
| 151 | stderr: "", | |
| 152 | code: 0, | |
| 153 | signal: null, | |
| 154 | timedOut: false, | |
| 155 | }; | |
| 156 | } | |
| 157 | ||
| 158 | throw new Error(`Unexpected gh command: ${args.join(" ")}`); | |
| 159 | }; | |
| 160 | ||
| 161 | return { runner, ghCalls }; | |
| 162 | } | |
| 163 | ||
| 164 | afterEach(() => { | |
| 165 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 166 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 167 | } | |
| 168 | }); | |
| 169 | ||
| 170 | describe("GitManager", () => { | |
| 171 | it("creates a commit when working tree is dirty", async () => { | |
| 172 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 173 | await initRepo(repoDir); | |
| 174 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n"); | |
| 175 | ||
| 176 | const manager = new GitManager({ | |
| 177 | textGenerator: createTextGenerator(), | |
| 178 | }); | |
| 179 | const result = await manager.runStackedAction({ | |
| 180 | cwd: repoDir, | |
| 181 | action: "commit", | |
| 182 | }); | |
| 183 | ||
| 184 | expect(result.commit.status).toBe("created"); | |
| 185 | expect(result.push.status).toBe("skipped_not_requested"); | |
| 186 | expect(result.pr.status).toBe("skipped_not_requested"); | |
| 187 | expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%s"])).toBe( | |
| 188 | "Implement stacked git actions", | |
| 189 | ); | |
| 190 | }); | |
| 191 | ||
| 192 | it("skips commit when there are no uncommitted changes", async () => { | |
| 193 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 194 | await initRepo(repoDir); | |
| 195 | ||
| 196 | const manager = new GitManager({ | |
| 197 | textGenerator: createTextGenerator(), | |
| 198 | }); | |
| 199 | const result = await manager.runStackedAction({ | |
| 200 | cwd: repoDir, | |
| 201 | action: "commit", | |
| 202 | }); | |
| 203 | ||
| 204 | expect(result.commit.status).toBe("skipped_no_changes"); | |
| 205 | expect(result.push.status).toBe("skipped_not_requested"); | |
| 206 | expect(result.pr.status).toBe("skipped_not_requested"); | |
| 207 | }); | |
| 208 | ||
| 209 | it("commits and pushes with upstream auto-setup when needed", async () => { | |
| 210 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 211 | await initRepo(repoDir); | |
| 212 | await runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]); | |
| 213 | const remoteDir = await createBareRemote(); | |
| 214 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 215 | fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); | |
| 216 | ||
| 217 | const manager = new GitManager({ | |
| 218 | textGenerator: createTextGenerator(), | |
| 219 | }); | |
| 220 | const result = await manager.runStackedAction({ | |
| 221 | cwd: repoDir, | |
| 222 | action: "commit_push", | |
| 223 | }); | |
| 224 | ||
| 225 | expect(result.commit.status).toBe("created"); | |
| 226 | expect(result.push.status).toBe("pushed"); | |
| 227 | expect(result.push.setUpstream).toBe(true); | |
| 228 | expect(await runGitStdout(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( | |
| 229 | "origin/feature/stacked-flow", | |
| 230 | ); | |
| 231 | }); | |
| 232 | ||
| 233 | it("skips push when branch is already up to date", async () => { | |
| 234 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 235 | await initRepo(repoDir); | |
| 236 | await runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]); | |
| 237 | const remoteDir = await createBareRemote(); | |
| 238 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 239 | await runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]); | |
| 240 | ||
| 241 | const manager = new GitManager({ | |
| 242 | textGenerator: createTextGenerator(), | |
| 243 | }); | |
| 244 | const result = await manager.runStackedAction({ | |
| 245 | cwd: repoDir, | |
| 246 | action: "commit_push", | |
| 247 | }); | |
| 248 | ||
| 249 | expect(result.commit.status).toBe("skipped_no_changes"); | |
| 250 | expect(result.push.status).toBe("skipped_up_to_date"); | |
| 251 | }); | |
| 252 | ||
| 253 | it("opens existing PR for commit/push/pr action", async () => { | |
| 254 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 255 | await initRepo(repoDir); | |
| 256 | await runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]); | |
| 257 | const remoteDir = await createBareRemote(); | |
| 258 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 259 | await runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]); | |
| 260 | ||
| 261 | const { runner, ghCalls } = createRunnerWithFakeGh({ | |
| 262 | prListSequence: [ | |
| 263 | JSON.stringify([ | |
| 264 | { | |
| 265 | number: 42, | |
| 266 | title: "Existing PR", | |
| 267 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | |
| 268 | baseRefName: "main", | |
| 269 | headRefName: "feature/existing-pr", | |
| 270 | }, | |
| 271 | ]), | |
| 272 | ], | |
| 273 | }); | |
| 274 | ||
| 275 | const manager = new GitManager({ | |
| 276 | runProcess: runner, | |
| 277 | textGenerator: createTextGenerator(), | |
| 278 | }); | |
| 279 | const result = await manager.runStackedAction({ | |
| 280 | cwd: repoDir, | |
| 281 | action: "commit_push_pr", | |
| 282 | }); | |
| 283 | ||
| 284 | expect(result.pr.status).toBe("opened_existing"); | |
| 285 | expect(result.pr.number).toBe(42); | |
| 286 | expect( | |
| 287 | ghCalls.some((call) => call.startsWith("pr view feature/existing-pr --web")), | |
| 288 | ).toBe(true); | |
| 289 | }); | |
| 290 | ||
| 291 | it("creates PR when one does not already exist", async () => { | |
| 292 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 293 | await initRepo(repoDir); | |
| 294 | await runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); | |
| 295 | const remoteDir = await createBareRemote(); | |
| 296 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 297 | fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); | |
| 298 | await runGit(repoDir, ["add", "changes.txt"]); | |
| 299 | await runGit(repoDir, ["commit", "-m", "Feature commit"]); | |
| 300 | await runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); | |
| 301 | await runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]); | |
| 302 | ||
| 303 | const { runner, ghCalls } = createRunnerWithFakeGh({ | |
| 304 | prListSequence: [ | |
| 305 | "[]", | |
| 306 | JSON.stringify([ | |
| 307 | { | |
| 308 | number: 88, | |
| 309 | title: "Add stacked git actions", | |
| 310 | url: "https://github.com/pingdotgg/codething-mvp/pull/88", | |
| 311 | baseRefName: "main", | |
| 312 | headRefName: "feature-create-pr", | |
| 313 | }, | |
| 314 | ]), | |
| 315 | ], | |
| 316 | }); | |
| 317 | ||
| 318 | const manager = new GitManager({ | |
| 319 | runProcess: runner, | |
| 320 | textGenerator: createTextGenerator(), | |
| 321 | }); | |
| 322 | const result = await manager.runStackedAction({ | |
| 323 | cwd: repoDir, | |
| 324 | action: "commit_push_pr", | |
| 325 | }); | |
| 326 | ||
| 327 | expect(result.pr.status).toBe("created"); | |
| 328 | expect(result.pr.number).toBe(88); | |
| 329 | expect( | |
| 330 | ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), | |
| 331 | ).toBe(true); | |
| 332 | }); | |
| 333 | ||
| 334 | it("rejects push/pr actions from detached HEAD", async () => { | |
| 335 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 336 | await initRepo(repoDir); | |
| 337 | await runGit(repoDir, ["checkout", "--detach", "HEAD"]); | |
| 338 | ||
| 339 | const manager = new GitManager({ | |
| 340 | textGenerator: createTextGenerator(), | |
| 341 | }); | |
| 342 | await expect( | |
| 343 | manager.runStackedAction({ | |
| 344 | cwd: repoDir, | |
| 345 | action: "commit_push", | |
| 346 | }), | |
| 347 | ).rejects.toThrow("detached HEAD"); | |
| 348 | }); | |
| 349 | ||
| 350 | it("surfaces missing gh binary errors", async () => { | |
| 351 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 352 | await initRepo(repoDir); | |
| 353 | await runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]); | |
| 354 | const remoteDir = await createBareRemote(); | |
| 355 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 356 | await runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]); | |
| 357 | ||
| 358 | const { runner } = createRunnerWithFakeGh({ | |
| 359 | failWith: new Error("Command not found: gh"), | |
| 360 | }); | |
| 361 | const manager = new GitManager({ | |
| 362 | runProcess: runner, | |
| 363 | textGenerator: createTextGenerator(), | |
| 364 | }); | |
| 365 | ||
| 366 | await expect( | |
| 367 | manager.runStackedAction({ | |
| 368 | cwd: repoDir, | |
| 369 | action: "commit_push_pr", | |
| 370 | }), | |
| 371 | ).rejects.toThrow("GitHub CLI (`gh`) is required"); | |
| 372 | }); | |
| 373 | ||
| 374 | it("surfaces gh auth errors with guidance", async () => { | |
| 375 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 376 | await initRepo(repoDir); | |
| 377 | await runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]); | |
| 378 | const remoteDir = await createBareRemote(); | |
| 379 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 380 | await runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]); | |
| 381 | ||
| 382 | const { runner } = createRunnerWithFakeGh({ | |
| 383 | failWith: new Error("authentication failed"), | |
| 384 | }); | |
| 385 | const manager = new GitManager({ | |
| 386 | runProcess: runner, | |
| 387 | textGenerator: createTextGenerator(), | |
| 388 | }); | |
| 389 | ||
| 390 | await expect( | |
| 391 | manager.runStackedAction({ | |
| 392 | cwd: repoDir, | |
| 393 | action: "commit_push_pr", | |
| 394 | }), | |
| 395 | ).rejects.toThrow("gh auth login"); | |
| 396 | }); | |
| 397 | }); |
apps/server/src/gitManager.ts created+623| ... | ... | @@ -0,0 +1,623 @@ |
| 1 | import { randomUUID } from "node:crypto"; | |
| 2 | import fs from "node:fs/promises"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | ||
| 6 | import { | |
| 7 | gitRunStackedActionInputSchema, | |
| 8 | gitRunStackedActionResultSchema, | |
| 9 | gitStatusInputSchema, | |
| 10 | gitStatusResultSchema, | |
| 11 | type GitRunStackedActionInput, | |
| 12 | type GitRunStackedActionResult, | |
| 13 | type GitStatusInput, | |
| 14 | type GitStatusResult, | |
| 15 | } from "@t3tools/contracts"; | |
| 16 | ||
| 17 | import type { | |
| 18 | CommitMessageGenerationResult, | |
| 19 | PrContentGenerationResult, | |
| 20 | TextGenerationService, | |
| 21 | } from "./coreServices"; | |
| 22 | import { CodexTextGenerator } from "./codexTextGenerator"; | |
| 23 | import { type ProcessRunOptions, runProcess } from "./processRunner"; | |
| 24 | ||
| 25 | interface GitStatusDetails extends GitStatusResult { | |
| 26 | upstreamRef: string | null; | |
| 27 | } | |
| 28 | ||
| 29 | interface GitManagerDeps { | |
| 30 | runProcess?: ( | |
| 31 | command: string, | |
| 32 | args: readonly string[], | |
| 33 | options?: ProcessRunOptions, | |
| 34 | ) => Promise<{ | |
| 35 | stdout: string; | |
| 36 | stderr: string; | |
| 37 | code: number | null; | |
| 38 | signal: NodeJS.Signals | null; | |
| 39 | timedOut: boolean; | |
| 40 | }>; | |
| 41 | textGenerator?: TextGenerationService; | |
| 42 | } | |
| 43 | ||
| 44 | interface OpenPrInfo { | |
| 45 | number: number; | |
| 46 | title: string; | |
| 47 | url: string; | |
| 48 | baseRefName: string; | |
| 49 | headRefName: string; | |
| 50 | } | |
| 51 | ||
| 52 | function parseOpenPrList(raw: unknown): OpenPrInfo[] { | |
| 53 | if (!Array.isArray(raw)) return []; | |
| 54 | ||
| 55 | const parsed: OpenPrInfo[] = []; | |
| 56 | for (const entry of raw) { | |
| 57 | if (!entry || typeof entry !== "object") continue; | |
| 58 | const record = entry as Record<string, unknown>; | |
| 59 | const number = record.number; | |
| 60 | const title = record.title; | |
| 61 | const url = record.url; | |
| 62 | const baseRefName = record.baseRefName; | |
| 63 | const headRefName = record.headRefName; | |
| 64 | if ( | |
| 65 | typeof number !== "number" || | |
| 66 | !Number.isInteger(number) || | |
| 67 | number <= 0 | |
| 68 | ) { | |
| 69 | continue; | |
| 70 | } | |
| 71 | if ( | |
| 72 | typeof title !== "string" || | |
| 73 | typeof url !== "string" || | |
| 74 | typeof baseRefName !== "string" || | |
| 75 | typeof headRefName !== "string" | |
| 76 | ) { | |
| 77 | continue; | |
| 78 | } | |
| 79 | parsed.push({ | |
| 80 | number, | |
| 81 | title, | |
| 82 | url, | |
| 83 | baseRefName, | |
| 84 | headRefName, | |
| 85 | }); | |
| 86 | } | |
| 87 | return parsed; | |
| 88 | } | |
| 89 | ||
| 90 | function trimStdout(value: string): string { | |
| 91 | return value.trim(); | |
| 92 | } | |
| 93 | ||
| 94 | function limitContext(value: string, maxChars: number): string { | |
| 95 | if (value.length <= maxChars) return value; | |
| 96 | return `${value.slice(0, maxChars)}\n\n[truncated]`; | |
| 97 | } | |
| 98 | ||
| 99 | function sanitizeCommitMessage( | |
| 100 | generated: CommitMessageGenerationResult, | |
| 101 | ): CommitMessageGenerationResult { | |
| 102 | const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | |
| 103 | const subject = rawSubject.replace(/[.]+$/g, "").trim(); | |
| 104 | const safeSubject = | |
| 105 | subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files"; | |
| 106 | return { | |
| 107 | subject: safeSubject, | |
| 108 | body: generated.body.trim(), | |
| 109 | }; | |
| 110 | } | |
| 111 | ||
| 112 | function extractBranchFromRef(ref: string): string { | |
| 113 | const normalized = ref.trim(); | |
| 114 | const afterSlash = normalized.includes("/") | |
| 115 | ? normalized.slice(normalized.lastIndexOf("/") + 1) | |
| 116 | : normalized; | |
| 117 | return afterSlash.trim(); | |
| 118 | } | |
| 119 | ||
| 120 | function parseBranchAb(value: string): { ahead: number; behind: number } { | |
| 121 | const match = value.match(/^\+(\d+)\s+-(\d+)$/); | |
| 122 | if (!match) return { ahead: 0, behind: 0 }; | |
| 123 | return { | |
| 124 | ahead: Number(match[1] ?? "0"), | |
| 125 | behind: Number(match[2] ?? "0"), | |
| 126 | }; | |
| 127 | } | |
| 128 | ||
| 129 | function asCommandNotFound( | |
| 130 | command: string, | |
| 131 | error: unknown, | |
| 132 | ): Error | undefined { | |
| 133 | if (!(error instanceof Error)) return undefined; | |
| 134 | if (!error.message.includes(`Command not found: ${command}`)) return undefined; | |
| 135 | if (command === "gh") { | |
| 136 | return new Error("GitHub CLI (`gh`) is required but not available on PATH."); | |
| 137 | } | |
| 138 | if (command === "codex") { | |
| 139 | return new Error("Codex CLI (`codex`) is required but not available on PATH."); | |
| 140 | } | |
| 141 | if (command === "git") { | |
| 142 | return new Error("Git is required but not available on PATH."); | |
| 143 | } | |
| 144 | return new Error(`${command} is required but not available on PATH.`); | |
| 145 | } | |
| 146 | ||
| 147 | function normalizeGitHubAuthError(error: unknown): Error | undefined { | |
| 148 | if (!(error instanceof Error)) return undefined; | |
| 149 | const lower = error.message.toLowerCase(); | |
| 150 | if ( | |
| 151 | lower.includes("authentication failed") || | |
| 152 | lower.includes("not logged in") || | |
| 153 | lower.includes("gh auth login") || | |
| 154 | lower.includes("no oauth token") | |
| 155 | ) { | |
| 156 | return new Error("GitHub CLI is not authenticated. Run `gh auth login` and retry."); | |
| 157 | } | |
| 158 | return undefined; | |
| 159 | } | |
| 160 | ||
| 161 | function normalizeNotGitRepoError(error: unknown): Error | undefined { | |
| 162 | if (!(error instanceof Error)) return undefined; | |
| 163 | if (error.message.toLowerCase().includes("not a git repository")) { | |
| 164 | return new Error("Current folder is not a git repository."); | |
| 165 | } | |
| 166 | return undefined; | |
| 167 | } | |
| 168 | ||
| 169 | export class GitManager { | |
| 170 | private readonly run: NonNullable<GitManagerDeps["runProcess"]>; | |
| 171 | private readonly textGenerator: TextGenerationService; | |
| 172 | ||
| 173 | constructor(deps: GitManagerDeps = {}) { | |
| 174 | this.run = deps.runProcess ?? runProcess; | |
| 175 | this.textGenerator = deps.textGenerator ?? new CodexTextGenerator(); | |
| 176 | } | |
| 177 | ||
| 178 | async status(raw: GitStatusInput): Promise<GitStatusResult> { | |
| 179 | const input = gitStatusInputSchema.parse(raw); | |
| 180 | const details = await this.readStatusDetails(input.cwd); | |
| 181 | return gitStatusResultSchema.parse({ | |
| 182 | branch: details.branch, | |
| 183 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | |
| 184 | hasUpstream: details.hasUpstream, | |
| 185 | aheadCount: details.aheadCount, | |
| 186 | behindCount: details.behindCount, | |
| 187 | }); | |
| 188 | } | |
| 189 | ||
| 190 | async runStackedAction( | |
| 191 | raw: GitRunStackedActionInput, | |
| 192 | ): Promise<GitRunStackedActionResult> { | |
| 193 | const input = gitRunStackedActionInputSchema.parse(raw); | |
| 194 | const wantsPush = input.action !== "commit"; | |
| 195 | const wantsPr = input.action === "commit_push_pr"; | |
| 196 | ||
| 197 | const initialStatus = await this.readStatusDetails(input.cwd); | |
| 198 | if (wantsPush && !initialStatus.branch) { | |
| 199 | throw new Error("Cannot push from detached HEAD."); | |
| 200 | } | |
| 201 | if (wantsPr && !initialStatus.branch) { | |
| 202 | throw new Error("Cannot create a pull request from detached HEAD."); | |
| 203 | } | |
| 204 | ||
| 205 | const commit = await this.runCommitStep(input.cwd, initialStatus.branch); | |
| 206 | ||
| 207 | const push = wantsPush | |
| 208 | ? await this.runPushStep(input.cwd, initialStatus.branch) | |
| 209 | : { status: "skipped_not_requested" as const }; | |
| 210 | ||
| 211 | const pr = wantsPr | |
| 212 | ? await this.runPrStep(input.cwd, initialStatus.branch) | |
| 213 | : { status: "skipped_not_requested" as const }; | |
| 214 | ||
| 215 | return gitRunStackedActionResultSchema.parse({ | |
| 216 | action: input.action, | |
| 217 | commit, | |
| 218 | push, | |
| 219 | pr, | |
| 220 | }); | |
| 221 | } | |
| 222 | ||
| 223 | private async readStatusDetails(cwd: string): Promise<GitStatusDetails> { | |
| 224 | let stdout = ""; | |
| 225 | try { | |
| 226 | stdout = await this.runGitStdout(cwd, ["status", "--porcelain=2", "--branch"]); | |
| 227 | } catch (error) { | |
| 228 | throw ( | |
| 229 | asCommandNotFound("git", error) ?? | |
| 230 | normalizeNotGitRepoError(error) ?? | |
| 231 | (error instanceof Error ? error : new Error("Failed to read git status.")) | |
| 232 | ); | |
| 233 | } | |
| 234 | ||
| 235 | let branch: string | null = null; | |
| 236 | let upstreamRef: string | null = null; | |
| 237 | let aheadCount = 0; | |
| 238 | let behindCount = 0; | |
| 239 | let hasWorkingTreeChanges = false; | |
| 240 | ||
| 241 | for (const line of stdout.split(/\r?\n/g)) { | |
| 242 | if (line.startsWith("# branch.head ")) { | |
| 243 | const value = line.slice("# branch.head ".length).trim(); | |
| 244 | branch = value.startsWith("(") ? null : value; | |
| 245 | continue; | |
| 246 | } | |
| 247 | if (line.startsWith("# branch.upstream ")) { | |
| 248 | const value = line.slice("# branch.upstream ".length).trim(); | |
| 249 | upstreamRef = value.length > 0 ? value : null; | |
| 250 | continue; | |
| 251 | } | |
| 252 | if (line.startsWith("# branch.ab ")) { | |
| 253 | const value = line.slice("# branch.ab ".length).trim(); | |
| 254 | const parsed = parseBranchAb(value); | |
| 255 | aheadCount = parsed.ahead; | |
| 256 | behindCount = parsed.behind; | |
| 257 | continue; | |
| 258 | } | |
| 259 | if (line.trim().length > 0 && !line.startsWith("#")) { | |
| 260 | hasWorkingTreeChanges = true; | |
| 261 | } | |
| 262 | } | |
| 263 | ||
| 264 | return { | |
| 265 | branch, | |
| 266 | upstreamRef, | |
| 267 | hasWorkingTreeChanges, | |
| 268 | hasUpstream: upstreamRef !== null, | |
| 269 | aheadCount, | |
| 270 | behindCount, | |
| 271 | }; | |
| 272 | } | |
| 273 | ||
| 274 | private async runCommitStep( | |
| 275 | cwd: string, | |
| 276 | branch: string | null, | |
| 277 | ): Promise<{ | |
| 278 | status: "created" | "skipped_no_changes"; | |
| 279 | commitSha?: string | undefined; | |
| 280 | subject?: string | undefined; | |
| 281 | }> { | |
| 282 | await this.runGit(cwd, ["add", "-A"]); | |
| 283 | const stagedSummary = await this.runGitStdout(cwd, [ | |
| 284 | "diff", | |
| 285 | "--cached", | |
| 286 | "--name-status", | |
| 287 | ]); | |
| 288 | if (trimStdout(stagedSummary).length === 0) { | |
| 289 | return { status: "skipped_no_changes" }; | |
| 290 | } | |
| 291 | ||
| 292 | const stagedPatch = await this.runGitStdout(cwd, [ | |
| 293 | "diff", | |
| 294 | "--cached", | |
| 295 | "--patch", | |
| 296 | "--minimal", | |
| 297 | ]); | |
| 298 | ||
| 299 | let generated: CommitMessageGenerationResult; | |
| 300 | try { | |
| 301 | generated = sanitizeCommitMessage( | |
| 302 | await this.textGenerator.generateCommitMessage({ | |
| 303 | cwd, | |
| 304 | branch, | |
| 305 | stagedSummary: limitContext(stagedSummary, 8_000), | |
| 306 | stagedPatch: limitContext(stagedPatch, 50_000), | |
| 307 | }), | |
| 308 | ); | |
| 309 | } catch (error) { | |
| 310 | throw asCommandNotFound("codex", error) ?? error; | |
| 311 | } | |
| 312 | ||
| 313 | const commitArgs = ["commit", "-m", generated.subject]; | |
| 314 | if (generated.body.length > 0) { | |
| 315 | commitArgs.push("-m", generated.body); | |
| 316 | } | |
| 317 | await this.runGit(cwd, commitArgs); | |
| 318 | ||
| 319 | const commitSha = trimStdout(await this.runGitStdout(cwd, ["rev-parse", "HEAD"])); | |
| 320 | return { | |
| 321 | status: "created", | |
| 322 | commitSha, | |
| 323 | subject: generated.subject, | |
| 324 | }; | |
| 325 | } | |
| 326 | ||
| 327 | private async runPushStep( | |
| 328 | cwd: string, | |
| 329 | fallbackBranch: string | null, | |
| 330 | ): Promise<{ | |
| 331 | status: "pushed" | "skipped_not_requested" | "skipped_up_to_date"; | |
| 332 | branch?: string | undefined; | |
| 333 | upstreamBranch?: string | undefined; | |
| 334 | setUpstream?: boolean | undefined; | |
| 335 | }> { | |
| 336 | const details = await this.readStatusDetails(cwd); | |
| 337 | const branch = details.branch ?? fallbackBranch; | |
| 338 | if (!branch) { | |
| 339 | throw new Error("Cannot push from detached HEAD."); | |
| 340 | } | |
| 341 | ||
| 342 | if (details.hasUpstream && details.aheadCount === 0) { | |
| 343 | return { | |
| 344 | status: "skipped_up_to_date", | |
| 345 | branch, | |
| 346 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | |
| 347 | }; | |
| 348 | } | |
| 349 | ||
| 350 | if (!details.hasUpstream) { | |
| 351 | await this.runGit(cwd, ["push", "-u", "origin", branch]); | |
| 352 | return { | |
| 353 | status: "pushed", | |
| 354 | branch, | |
| 355 | upstreamBranch: `origin/${branch}`, | |
| 356 | setUpstream: true, | |
| 357 | }; | |
| 358 | } | |
| 359 | ||
| 360 | await this.runGit(cwd, ["push"]); | |
| 361 | return { | |
| 362 | status: "pushed", | |
| 363 | branch, | |
| 364 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | |
| 365 | setUpstream: false, | |
| 366 | }; | |
| 367 | } | |
| 368 | ||
| 369 | private async runPrStep( | |
| 370 | cwd: string, | |
| 371 | fallbackBranch: string | null, | |
| 372 | ): Promise<{ | |
| 373 | status: "created" | "opened_existing" | "skipped_not_requested"; | |
| 374 | url?: string | undefined; | |
| 375 | number?: number | undefined; | |
| 376 | baseBranch?: string | undefined; | |
| 377 | headBranch?: string | undefined; | |
| 378 | title?: string | undefined; | |
| 379 | }> { | |
| 380 | const details = await this.readStatusDetails(cwd); | |
| 381 | const branch = details.branch ?? fallbackBranch; | |
| 382 | if (!branch) { | |
| 383 | throw new Error("Cannot create a pull request from detached HEAD."); | |
| 384 | } | |
| 385 | if (!details.hasUpstream) { | |
| 386 | throw new Error("Current branch has not been pushed. Push before creating a PR."); | |
| 387 | } | |
| 388 | ||
| 389 | const existing = await this.findOpenPr(cwd, branch); | |
| 390 | if (existing) { | |
| 391 | await this.openPrInBrowser(cwd, branch); | |
| 392 | return { | |
| 393 | status: "opened_existing", | |
| 394 | url: existing.url, | |
| 395 | number: existing.number, | |
| 396 | baseBranch: existing.baseRefName, | |
| 397 | headBranch: existing.headRefName, | |
| 398 | title: existing.title, | |
| 399 | }; | |
| 400 | } | |
| 401 | ||
| 402 | const baseBranch = await this.resolveBaseBranch(cwd, branch, details.upstreamRef); | |
| 403 | const commitSummary = await this.runGitStdout(cwd, [ | |
| 404 | "log", | |
| 405 | "--oneline", | |
| 406 | `${baseBranch}..HEAD`, | |
| 407 | ]); | |
| 408 | const diffSummary = await this.runGitStdout(cwd, [ | |
| 409 | "diff", | |
| 410 | "--stat", | |
| 411 | `${baseBranch}..HEAD`, | |
| 412 | ]); | |
| 413 | const diffPatch = await this.runGitStdout(cwd, [ | |
| 414 | "diff", | |
| 415 | "--patch", | |
| 416 | "--minimal", | |
| 417 | `${baseBranch}..HEAD`, | |
| 418 | ]); | |
| 419 | ||
| 420 | let generated: PrContentGenerationResult; | |
| 421 | try { | |
| 422 | generated = await this.textGenerator.generatePrContent({ | |
| 423 | cwd, | |
| 424 | baseBranch, | |
| 425 | headBranch: branch, | |
| 426 | commitSummary: limitContext(commitSummary, 20_000), | |
| 427 | diffSummary: limitContext(diffSummary, 20_000), | |
| 428 | diffPatch: limitContext(diffPatch, 60_000), | |
| 429 | }); | |
| 430 | } catch (error) { | |
| 431 | throw asCommandNotFound("codex", error) ?? error; | |
| 432 | } | |
| 433 | ||
| 434 | const bodyFile = path.join( | |
| 435 | os.tmpdir(), | |
| 436 | `t3code-pr-body-${process.pid}-${randomUUID()}.md`, | |
| 437 | ); | |
| 438 | await fs.writeFile(bodyFile, generated.body, "utf8"); | |
| 439 | ||
| 440 | try { | |
| 441 | await this.runGh(cwd, [ | |
| 442 | "pr", | |
| 443 | "create", | |
| 444 | "--base", | |
| 445 | baseBranch, | |
| 446 | "--head", | |
| 447 | branch, | |
| 448 | "--title", | |
| 449 | generated.title, | |
| 450 | "--body-file", | |
| 451 | bodyFile, | |
| 452 | ]); | |
| 453 | } finally { | |
| 454 | try { | |
| 455 | await fs.unlink(bodyFile); | |
| 456 | } catch { | |
| 457 | // Best-effort cleanup. | |
| 458 | } | |
| 459 | } | |
| 460 | ||
| 461 | const created = await this.findOpenPr(cwd, branch); | |
| 462 | await this.openPrInBrowser(cwd, branch); | |
| 463 | ||
| 464 | if (!created) { | |
| 465 | return { | |
| 466 | status: "created", | |
| 467 | baseBranch, | |
| 468 | headBranch: branch, | |
| 469 | title: generated.title, | |
| 470 | }; | |
| 471 | } | |
| 472 | ||
| 473 | return { | |
| 474 | status: "created", | |
| 475 | url: created.url, | |
| 476 | number: created.number, | |
| 477 | baseBranch: created.baseRefName, | |
| 478 | headBranch: created.headRefName, | |
| 479 | title: created.title, | |
| 480 | }; | |
| 481 | } | |
| 482 | ||
| 483 | private async openPrInBrowser(cwd: string, branch: string): Promise<void> { | |
| 484 | try { | |
| 485 | await this.runGh(cwd, ["pr", "view", branch, "--web"]); | |
| 486 | } catch { | |
| 487 | // Opening the browser is best-effort. | |
| 488 | } | |
| 489 | } | |
| 490 | ||
| 491 | private async findOpenPr( | |
| 492 | cwd: string, | |
| 493 | branch: string, | |
| 494 | ): Promise<OpenPrInfo | null> { | |
| 495 | const stdout = await this.runGhStdout(cwd, [ | |
| 496 | "pr", | |
| 497 | "list", | |
| 498 | "--head", | |
| 499 | branch, | |
| 500 | "--state", | |
| 501 | "open", | |
| 502 | "--limit", | |
| 503 | "1", | |
| 504 | "--json", | |
| 505 | "number,title,url,baseRefName,headRefName", | |
| 506 | ]); | |
| 507 | ||
| 508 | const raw = trimStdout(stdout); | |
| 509 | if (raw.length === 0) return null; | |
| 510 | ||
| 511 | let parsedJson: unknown; | |
| 512 | try { | |
| 513 | parsedJson = JSON.parse(raw); | |
| 514 | } catch { | |
| 515 | throw new Error("GitHub CLI returned invalid PR list JSON."); | |
| 516 | } | |
| 517 | ||
| 518 | const parsed = parseOpenPrList(parsedJson); | |
| 519 | return parsed[0] ?? null; | |
| 520 | } | |
| 521 | ||
| 522 | private async resolveBaseBranch( | |
| 523 | cwd: string, | |
| 524 | branch: string, | |
| 525 | upstreamRef: string | null, | |
| 526 | ): Promise<string> { | |
| 527 | const mergeBaseConfig = await this.runGitStdout( | |
| 528 | cwd, | |
| 529 | ["config", "--get", `branch.${branch}.gh-merge-base`], | |
| 530 | true, | |
| 531 | ); | |
| 532 | const configured = trimStdout(mergeBaseConfig); | |
| 533 | if (configured.length > 0) return configured; | |
| 534 | ||
| 535 | if (upstreamRef) { | |
| 536 | const upstreamBranch = extractBranchFromRef(upstreamRef); | |
| 537 | if (upstreamBranch.length > 0 && upstreamBranch !== branch) { | |
| 538 | return upstreamBranch; | |
| 539 | } | |
| 540 | } | |
| 541 | ||
| 542 | try { | |
| 543 | const ghDefault = await this.runGhStdout(cwd, [ | |
| 544 | "repo", | |
| 545 | "view", | |
| 546 | "--json", | |
| 547 | "defaultBranchRef", | |
| 548 | "--jq", | |
| 549 | ".defaultBranchRef.name", | |
| 550 | ]); | |
| 551 | const defaultBranch = trimStdout(ghDefault); | |
| 552 | if (defaultBranch.length > 0) return defaultBranch; | |
| 553 | } catch { | |
| 554 | // Fall through to deterministic fallback. | |
| 555 | } | |
| 556 | ||
| 557 | return "main"; | |
| 558 | } | |
| 559 | ||
| 560 | private async runGit( | |
| 561 | cwd: string, | |
| 562 | args: readonly string[], | |
| 563 | allowNonZeroExit = false, | |
| 564 | ): Promise<void> { | |
| 565 | try { | |
| 566 | await this.run("git", args, { | |
| 567 | cwd, | |
| 568 | allowNonZeroExit, | |
| 569 | }); | |
| 570 | } catch (error) { | |
| 571 | throw ( | |
| 572 | asCommandNotFound("git", error) ?? | |
| 573 | normalizeNotGitRepoError(error) ?? | |
| 574 | (error instanceof Error ? error : new Error("Git command failed.")) | |
| 575 | ); | |
| 576 | } | |
| 577 | } | |
| 578 | ||
| 579 | private async runGitStdout( | |
| 580 | cwd: string, | |
| 581 | args: readonly string[], | |
| 582 | allowNonZeroExit = false, | |
| 583 | ): Promise<string> { | |
| 584 | try { | |
| 585 | const result = await this.run("git", args, { | |
| 586 | cwd, | |
| 587 | allowNonZeroExit, | |
| 588 | }); | |
| 589 | return result.stdout; | |
| 590 | } catch (error) { | |
| 591 | throw ( | |
| 592 | asCommandNotFound("git", error) ?? | |
| 593 | normalizeNotGitRepoError(error) ?? | |
| 594 | (error instanceof Error ? error : new Error("Git command failed.")) | |
| 595 | ); | |
| 596 | } | |
| 597 | } | |
| 598 | ||
| 599 | private async runGh(cwd: string, args: readonly string[]): Promise<void> { | |
| 600 | try { | |
| 601 | await this.run("gh", args, { cwd }); | |
| 602 | } catch (error) { | |
| 603 | throw ( | |
| 604 | asCommandNotFound("gh", error) ?? | |
| 605 | normalizeGitHubAuthError(error) ?? | |
| 606 | (error instanceof Error ? error : new Error("GitHub CLI command failed.")) | |
| 607 | ); | |
| 608 | } | |
| 609 | } | |
| 610 | ||
| 611 | private async runGhStdout(cwd: string, args: readonly string[]): Promise<string> { | |
| 612 | try { | |
| 613 | const result = await this.run("gh", args, { cwd }); | |
| 614 | return result.stdout; | |
| 615 | } catch (error) { | |
| 616 | throw ( | |
| 617 | asCommandNotFound("gh", error) ?? | |
| 618 | normalizeGitHubAuthError(error) ?? | |
| 619 | (error instanceof Error ? error : new Error("GitHub CLI command failed.")) | |
| 620 | ); | |
| 621 | } | |
| 622 | } | |
| 623 | } |
apps/server/src/processRunner.ts created+134| ... | ... | @@ -0,0 +1,134 @@ |
| 1 | import { spawn } from "node:child_process"; | |
| 2 | ||
| 3 | export interface ProcessRunOptions { | |
| 4 | cwd?: string | undefined; | |
| 5 | timeoutMs?: number | undefined; | |
| 6 | env?: NodeJS.ProcessEnv | undefined; | |
| 7 | stdin?: string | undefined; | |
| 8 | allowNonZeroExit?: boolean | undefined; | |
| 9 | } | |
| 10 | ||
| 11 | export interface ProcessRunResult { | |
| 12 | stdout: string; | |
| 13 | stderr: string; | |
| 14 | code: number | null; | |
| 15 | signal: NodeJS.Signals | null; | |
| 16 | timedOut: boolean; | |
| 17 | } | |
| 18 | ||
| 19 | function commandLabel(command: string, args: readonly string[]): string { | |
| 20 | return [command, ...args].join(" "); | |
| 21 | } | |
| 22 | ||
| 23 | function normalizeSpawnError( | |
| 24 | command: string, | |
| 25 | args: readonly string[], | |
| 26 | error: unknown, | |
| 27 | ): Error { | |
| 28 | if (!(error instanceof Error)) { | |
| 29 | return new Error(`Failed to run ${commandLabel(command, args)}.`); | |
| 30 | } | |
| 31 | ||
| 32 | const maybeCode = (error as NodeJS.ErrnoException).code; | |
| 33 | if (maybeCode === "ENOENT") { | |
| 34 | return new Error(`Command not found: ${command}`); | |
| 35 | } | |
| 36 | ||
| 37 | return new Error( | |
| 38 | `Failed to run ${commandLabel(command, args)}: ${error.message}`, | |
| 39 | ); | |
| 40 | } | |
| 41 | ||
| 42 | function normalizeExitError( | |
| 43 | command: string, | |
| 44 | args: readonly string[], | |
| 45 | result: ProcessRunResult, | |
| 46 | ): Error { | |
| 47 | const reason = result.timedOut | |
| 48 | ? "timed out" | |
| 49 | : `failed (code=${result.code ?? "null"}, signal=${result.signal ?? "null"})`; | |
| 50 | const stderr = result.stderr.trim(); | |
| 51 | const detail = stderr.length > 0 ? ` ${stderr}` : ""; | |
| 52 | return new Error(`${commandLabel(command, args)} ${reason}.${detail}`); | |
| 53 | } | |
| 54 | ||
| 55 | export async function runProcess( | |
| 56 | command: string, | |
| 57 | args: readonly string[], | |
| 58 | options: ProcessRunOptions = {}, | |
| 59 | ): Promise<ProcessRunResult> { | |
| 60 | const timeoutMs = options.timeoutMs ?? 60_000; | |
| 61 | ||
| 62 | return new Promise<ProcessRunResult>((resolve, reject) => { | |
| 63 | const child = spawn(command, args, { | |
| 64 | cwd: options.cwd, | |
| 65 | env: options.env, | |
| 66 | stdio: "pipe", | |
| 67 | }); | |
| 68 | ||
| 69 | let stdout = ""; | |
| 70 | let stderr = ""; | |
| 71 | let timedOut = false; | |
| 72 | let settled = false; | |
| 73 | let forceKillTimer: ReturnType<typeof setTimeout> | null = null; | |
| 74 | ||
| 75 | const timeoutTimer = setTimeout(() => { | |
| 76 | timedOut = true; | |
| 77 | child.kill("SIGTERM"); | |
| 78 | forceKillTimer = setTimeout(() => { | |
| 79 | child.kill("SIGKILL"); | |
| 80 | }, 1_000); | |
| 81 | }, timeoutMs); | |
| 82 | ||
| 83 | const finalize = (callback: () => void): void => { | |
| 84 | if (settled) return; | |
| 85 | settled = true; | |
| 86 | clearTimeout(timeoutTimer); | |
| 87 | if (forceKillTimer) { | |
| 88 | clearTimeout(forceKillTimer); | |
| 89 | } | |
| 90 | callback(); | |
| 91 | }; | |
| 92 | ||
| 93 | child.stdout.on("data", (chunk: Buffer | string) => { | |
| 94 | stdout += chunk.toString(); | |
| 95 | }); | |
| 96 | ||
| 97 | child.stderr.on("data", (chunk: Buffer | string) => { | |
| 98 | stderr += chunk.toString(); | |
| 99 | }); | |
| 100 | ||
| 101 | child.once("error", (error) => { | |
| 102 | finalize(() => { | |
| 103 | reject(normalizeSpawnError(command, args, error)); | |
| 104 | }); | |
| 105 | }); | |
| 106 | ||
| 107 | child.once("close", (code, signal) => { | |
| 108 | const result: ProcessRunResult = { | |
| 109 | stdout, | |
| 110 | stderr, | |
| 111 | code, | |
| 112 | signal, | |
| 113 | timedOut, | |
| 114 | }; | |
| 115 | ||
| 116 | finalize(() => { | |
| 117 | if ( | |
| 118 | !options.allowNonZeroExit && | |
| 119 | (timedOut || (code !== null && code !== 0)) | |
| 120 | ) { | |
| 121 | reject(normalizeExitError(command, args, result)); | |
| 122 | return; | |
| 123 | } | |
| 124 | resolve(result); | |
| 125 | }); | |
| 126 | }); | |
| 127 | ||
| 128 | if (options.stdin !== undefined) { | |
| 129 | child.stdin.write(options.stdin); | |
| 130 | } | |
| 131 | child.stdin.end(); | |
| 132 | }); | |
| 133 | } | |
| 134 |
apps/server/src/wsServer.test.ts+69| ... | ... | @@ -85,6 +85,10 @@ describe("WebSocket Server", () => { |
| 85 | 85 | devUrl?: string; |
| 86 | 86 | authToken?: string; |
| 87 | 87 | stateDir?: string; |
| 88 | gitManager?: { | |
| 89 | status: (input: { cwd: string }) => Promise<unknown>; | |
| 90 | runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>; | |
| 91 | }; | |
| 88 | 92 | } = {}, |
| 89 | 93 | ): ReturnType<typeof createServer> { |
| 90 | 94 | const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); |
| ... | ... | @@ -94,6 +98,7 @@ describe("WebSocket Server", () => { |
| 94 | 98 | ...(options.devUrl ? { devUrl: options.devUrl } : {}), |
| 95 | 99 | ...(options.authToken ? { authToken: options.authToken } : {}), |
| 96 | 100 | projectRegistry: new ProjectRegistry(stateDir), |
| 101 | ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), | |
| 97 | 102 | }); |
| 98 | 103 | } |
| 99 | 104 | |
| ... | ... | @@ -281,6 +286,70 @@ describe("WebSocket Server", () => { |
| 281 | 286 | expect(afterRemove.result).toEqual([]); |
| 282 | 287 | }); |
| 283 | 288 | |
| 289 | it("responds to git.status via the git manager", async () => { | |
| 290 | const gitManager = { | |
| 291 | status: vi.fn().mockResolvedValue({ | |
| 292 | branch: "feature/test", | |
| 293 | hasWorkingTreeChanges: true, | |
| 294 | hasUpstream: false, | |
| 295 | aheadCount: 0, | |
| 296 | behindCount: 0, | |
| 297 | }), | |
| 298 | runStackedAction: vi.fn(), | |
| 299 | }; | |
| 300 | ||
| 301 | server = createTestServer({ cwd: "/test", gitManager }); | |
| 302 | await server.start(); | |
| 303 | const addr = server.httpServer.address(); | |
| 304 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 305 | ||
| 306 | const ws = await connectWs(port); | |
| 307 | connections.push(ws); | |
| 308 | await waitForMessage(ws); | |
| 309 | ||
| 310 | const response = await sendRequest(ws, WS_METHODS.gitStatus, { | |
| 311 | cwd: "/test", | |
| 312 | }); | |
| 313 | expect(response.error).toBeUndefined(); | |
| 314 | expect(response.result).toEqual({ | |
| 315 | branch: "feature/test", | |
| 316 | hasWorkingTreeChanges: true, | |
| 317 | hasUpstream: false, | |
| 318 | aheadCount: 0, | |
| 319 | behindCount: 0, | |
| 320 | }); | |
| 321 | expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" }); | |
| 322 | }); | |
| 323 | ||
| 324 | it("returns errors from git.runStackedAction", async () => { | |
| 325 | const gitManager = { | |
| 326 | status: vi.fn(), | |
| 327 | runStackedAction: vi | |
| 328 | .fn() | |
| 329 | .mockRejectedValue(new Error("Cannot push from detached HEAD.")), | |
| 330 | }; | |
| 331 | ||
| 332 | server = createTestServer({ cwd: "/test", gitManager }); | |
| 333 | await server.start(); | |
| 334 | const addr = server.httpServer.address(); | |
| 335 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 336 | ||
| 337 | const ws = await connectWs(port); | |
| 338 | connections.push(ws); | |
| 339 | await waitForMessage(ws); | |
| 340 | ||
| 341 | const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, { | |
| 342 | cwd: "/test", | |
| 343 | action: "commit_push", | |
| 344 | }); | |
| 345 | expect(response.result).toBeUndefined(); | |
| 346 | expect(response.error?.message).toContain("detached HEAD"); | |
| 347 | expect(gitManager.runStackedAction).toHaveBeenCalledWith({ | |
| 348 | cwd: "/test", | |
| 349 | action: "commit_push", | |
| 350 | }); | |
| 351 | }); | |
| 352 | ||
| 284 | 353 | it("prunes missing projects on startup", async () => { |
| 285 | 354 | const stateDir = makeTempDir("t3code-ws-prune-state-"); |
| 286 | 355 | const existing = makeTempDir("t3code-ws-existing-project-"); |
apps/server/src/wsServer.ts+10| ... | ... | @@ -19,6 +19,7 @@ import { WebSocketServer, type WebSocket } from "ws"; |
| 19 | 19 | import { createLogger } from "./logger"; |
| 20 | 20 | import { ProjectRegistry } from "./projectRegistry"; |
| 21 | 21 | import { ProviderManager } from "./providerManager"; |
| 22 | import { GitManager } from "./gitManager"; | |
| 22 | 23 | |
| 23 | 24 | const MIME_TYPES: Record<string, string> = { |
| 24 | 25 | ".html": "text/html; charset=utf-8", |
| ... | ... | @@ -43,6 +44,7 @@ export interface ServerOptions { |
| 43 | 44 | devUrl?: string | undefined; |
| 44 | 45 | logWebSocketEvents?: boolean | undefined; |
| 45 | 46 | projectRegistry?: ProjectRegistry | undefined; |
| 47 | gitManager?: GitManager | undefined; | |
| 46 | 48 | authToken?: string | undefined; |
| 47 | 49 | } |
| 48 | 50 | |
| ... | ... | @@ -63,11 +65,13 @@ export function createServer(options: ServerOptions) { |
| 63 | 65 | devUrl, |
| 64 | 66 | logWebSocketEvents: explicitLogWsEvents, |
| 65 | 67 | projectRegistry: providedRegistry, |
| 68 | gitManager: providedGitManager, | |
| 66 | 69 | authToken, |
| 67 | 70 | } = options; |
| 68 | 71 | const providerManager = new ProviderManager(); |
| 69 | 72 | const projectRegistry = |
| 70 | 73 | providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); |
| 74 | const gitManager = providedGitManager ?? new GitManager(); | |
| 71 | 75 | const clients = new Set<WebSocket>(); |
| 72 | 76 | const logger = createLogger("ws"); |
| 73 | 77 | const logWebSocketEvents = |
| ... | ... | @@ -304,6 +308,12 @@ export function createServer(options: ServerOptions) { |
| 304 | 308 | return undefined; |
| 305 | 309 | } |
| 306 | 310 | |
| 311 | case WS_METHODS.gitStatus: | |
| 312 | return gitManager.status(request.params as never); | |
| 313 | ||
| 314 | case WS_METHODS.gitRunStackedAction: | |
| 315 | return gitManager.runStackedAction(request.params as never); | |
| 316 | ||
| 307 | 317 | case WS_METHODS.serverGetConfig: |
| 308 | 318 | return { cwd }; |
| 309 | 319 |
apps/web/src/components/ChatView.tsx+197| ... | ... | @@ -1,5 +1,8 @@ |
| 1 | 1 | import { |
| 2 | 2 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 3 | type GitRunStackedActionResult, | |
| 4 | type GitStackedAction, | |
| 5 | type GitStatusResult, | |
| 3 | 6 | type ProviderApprovalDecision, |
| 4 | 7 | type ProviderEvent, |
| 5 | 8 | } from "@t3tools/contracts"; |
| ... | ... | @@ -7,6 +10,7 @@ import { |
| 7 | 10 | type FormEvent, |
| 8 | 11 | Fragment, |
| 9 | 12 | type KeyboardEvent, |
| 13 | useCallback, | |
| 10 | 14 | useEffect, |
| 11 | 15 | useMemo, |
| 12 | 16 | useRef, |
| ... | ... | @@ -52,6 +56,14 @@ function editorLabel(editor: (typeof EDITORS)[number]): string { |
| 52 | 56 | |
| 53 | 57 | const LAST_EDITOR_KEY = "t3code:last-editor"; |
| 54 | 58 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 59 | const GIT_ACTION_ITEMS: Array<{ | |
| 60 | id: GitStackedAction; | |
| 61 | label: string; | |
| 62 | }> = [ | |
| 63 | { id: "commit", label: "Commit" }, | |
| 64 | { id: "commit_push", label: "Commit & Push" }, | |
| 65 | { id: "commit_push_pr", label: "Commit, Push & Create PR" }, | |
| 66 | ]; | |
| 55 | 67 | |
| 56 | 68 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 57 | 69 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; |
| ... | ... | @@ -132,6 +144,35 @@ function derivePendingApprovals( |
| 132 | 144 | return Array.from(pending.values()); |
| 133 | 145 | } |
| 134 | 146 | |
| 147 | function summarizeGitActionResult(result: GitRunStackedActionResult): string { | |
| 148 | const parts: string[] = []; | |
| 149 | ||
| 150 | if (result.commit.status === "created") { | |
| 151 | parts.push(`Committed: ${result.commit.subject ?? "changes saved"}.`); | |
| 152 | } else { | |
| 153 | parts.push("No local changes to commit."); | |
| 154 | } | |
| 155 | ||
| 156 | if (result.push.status === "pushed") { | |
| 157 | const upstream = result.push.upstreamBranch; | |
| 158 | parts.push( | |
| 159 | upstream ? `Pushed to ${upstream}.` : "Pushed to remote branch.", | |
| 160 | ); | |
| 161 | } else if (result.push.status === "skipped_up_to_date") { | |
| 162 | parts.push("Push skipped: branch already up to date."); | |
| 163 | } | |
| 164 | ||
| 165 | if (result.pr.status === "created") { | |
| 166 | parts.push(result.pr.url ? `PR created: ${result.pr.url}` : "PR created."); | |
| 167 | } else if (result.pr.status === "opened_existing") { | |
| 168 | parts.push( | |
| 169 | result.pr.url ? `Opened existing PR: ${result.pr.url}` : "Opened existing PR.", | |
| 170 | ); | |
| 171 | } | |
| 172 | ||
| 173 | return parts.join(" "); | |
| 174 | } | |
| 175 | ||
| 135 | 176 | export default function ChatView() { |
| 136 | 177 | const { state, dispatch } = useStore(); |
| 137 | 178 | const api = useMemo(() => readNativeApi(), []); |
| ... | ... | @@ -140,6 +181,11 @@ export default function ChatView() { |
| 140 | 181 | const [isConnecting, setIsConnecting] = useState(false); |
| 141 | 182 | const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); |
| 142 | 183 | const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false); |
| 184 | const [isGitMenuOpen, setIsGitMenuOpen] = useState(false); | |
| 185 | const [isGitActionRunning, setIsGitActionRunning] = useState(false); | |
| 186 | const [gitStatus, setGitStatus] = useState<GitStatusResult | null>(null); | |
| 187 | const [gitActionNotice, setGitActionNotice] = useState<string | null>(null); | |
| 188 | const [gitActionError, setGitActionError] = useState<string | null>(null); | |
| 143 | 189 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { |
| 144 | 190 | const stored = localStorage.getItem(LAST_EDITOR_KEY); |
| 145 | 191 | return EDITORS.some((e) => e.id === stored) |
| ... | ... | @@ -160,6 +206,7 @@ export default function ChatView() { |
| 160 | 206 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 161 | 207 | const modelMenuRef = useRef<HTMLDivElement>(null); |
| 162 | 208 | const editorMenuRef = useRef<HTMLDivElement>(null); |
| 209 | const gitMenuRef = useRef<HTMLDivElement>(null); | |
| 163 | 210 | |
| 164 | 211 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 165 | 212 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| ... | ... | @@ -265,6 +312,49 @@ export default function ChatView() { |
| 265 | 312 | approvalPolicy: "on-request", |
| 266 | 313 | sandboxMode: "workspace-write", |
| 267 | 314 | } as const); |
| 315 | const gitActionsDisabled = | |
| 316 | !api || !activeProject || !gitStatus || isGitActionRunning; | |
| 317 | const pushActionDisabled = gitActionsDisabled || gitStatus.branch === null; | |
| 318 | ||
| 319 | const refreshGitStatus = useCallback(async () => { | |
| 320 | if (!api || !activeProject) { | |
| 321 | setGitStatus(null); | |
| 322 | return; | |
| 323 | } | |
| 324 | ||
| 325 | const nextStatus = await api.git.status({ cwd: activeProject.cwd }); | |
| 326 | setGitStatus(nextStatus); | |
| 327 | }, [api, activeProject]); | |
| 328 | ||
| 329 | const runGitAction = useCallback( | |
| 330 | async (action: GitStackedAction) => { | |
| 331 | if (!api || !activeProject) return; | |
| 332 | ||
| 333 | setIsGitMenuOpen(false); | |
| 334 | setIsGitActionRunning(true); | |
| 335 | setGitActionError(null); | |
| 336 | setGitActionNotice(null); | |
| 337 | try { | |
| 338 | const result = await api.git.runStackedAction({ | |
| 339 | cwd: activeProject.cwd, | |
| 340 | action, | |
| 341 | }); | |
| 342 | setGitActionNotice(summarizeGitActionResult(result)); | |
| 343 | } catch (error) { | |
| 344 | setGitActionError( | |
| 345 | error instanceof Error ? error.message : "Git action failed.", | |
| 346 | ); | |
| 347 | } finally { | |
| 348 | setIsGitActionRunning(false); | |
| 349 | try { | |
| 350 | await refreshGitStatus(); | |
| 351 | } catch { | |
| 352 | setGitStatus(null); | |
| 353 | } | |
| 354 | } | |
| 355 | }, | |
| 356 | [api, activeProject, refreshGitStatus], | |
| 357 | ); | |
| 268 | 358 | |
| 269 | 359 | const handleRuntimeModeChange = async ( |
| 270 | 360 | mode: "approval-required" | "full-access", |
| ... | ... | @@ -309,6 +399,42 @@ export default function ChatView() { |
| 309 | 399 | setExpandedWorkGroups({}); |
| 310 | 400 | }, [activeThread?.id]); |
| 311 | 401 | |
| 402 | useEffect(() => { | |
| 403 | setGitActionNotice(null); | |
| 404 | setGitActionError(null); | |
| 405 | }, [activeProject?.id]); | |
| 406 | ||
| 407 | useEffect(() => { | |
| 408 | let cancelled = false; | |
| 409 | if (!api || !activeProject) { | |
| 410 | setGitStatus(null); | |
| 411 | return; | |
| 412 | } | |
| 413 | ||
| 414 | const load = async () => { | |
| 415 | try { | |
| 416 | const nextStatus = await api.git.status({ cwd: activeProject.cwd }); | |
| 417 | if (!cancelled) { | |
| 418 | setGitStatus(nextStatus); | |
| 419 | } | |
| 420 | } catch (error) { | |
| 421 | if (!cancelled) { | |
| 422 | setGitStatus(null); | |
| 423 | setGitActionError( | |
| 424 | error instanceof Error | |
| 425 | ? error.message | |
| 426 | : "Failed to read git status.", | |
| 427 | ); | |
| 428 | } | |
| 429 | } | |
| 430 | }; | |
| 431 | ||
| 432 | void load(); | |
| 433 | return () => { | |
| 434 | cancelled = true; | |
| 435 | }; | |
| 436 | }, [api, activeProject]); | |
| 437 | ||
| 312 | 438 | // Auto-resize textarea |
| 313 | 439 | useEffect(() => { |
| 314 | 440 | const ta = textareaRef.current; |
| ... | ... | @@ -362,6 +488,22 @@ export default function ChatView() { |
| 362 | 488 | }; |
| 363 | 489 | }, [isEditorMenuOpen]); |
| 364 | 490 | |
| 491 | useEffect(() => { | |
| 492 | if (!isGitMenuOpen) return; | |
| 493 | ||
| 494 | const handleClickOutside = (event: MouseEvent) => { | |
| 495 | if (!gitMenuRef.current) return; | |
| 496 | if (event.target instanceof Node && !gitMenuRef.current.contains(event.target)) { | |
| 497 | setIsGitMenuOpen(false); | |
| 498 | } | |
| 499 | }; | |
| 500 | ||
| 501 | window.addEventListener("mousedown", handleClickOutside); | |
| 502 | return () => { | |
| 503 | window.removeEventListener("mousedown", handleClickOutside); | |
| 504 | }; | |
| 505 | }, [isGitMenuOpen]); | |
| 506 | ||
| 365 | 507 | // Cmd+O / Ctrl+O to open in last-used editor |
| 366 | 508 | useEffect(() => { |
| 367 | 509 | const handler = (e: globalThis.KeyboardEvent) => { |
| ... | ... | @@ -617,6 +759,49 @@ export default function ChatView() { |
| 617 | 759 | )} |
| 618 | 760 | </div> |
| 619 | 761 | )} |
| 762 | {/* Git actions */} | |
| 763 | {activeProject && ( | |
| 764 | <div className="relative" ref={gitMenuRef}> | |
| 765 | <button | |
| 766 | type="button" | |
| 767 | className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-[10px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-60" | |
| 768 | onClick={() => setIsGitMenuOpen((v) => !v)} | |
| 769 | disabled={!gitStatus || isGitActionRunning} | |
| 770 | > | |
| 771 | {isGitActionRunning ? "Running..." : "Git actions"} | |
| 772 | <span aria-hidden="true">▾</span> | |
| 773 | </button> | |
| 774 | {isGitMenuOpen && ( | |
| 775 | <div className="absolute right-0 top-full z-50 mt-1 w-[240px] rounded-xl border border-border bg-popover p-2 shadow-xl"> | |
| 776 | <p className="px-2 pb-1 text-[10px] uppercase tracking-[0.1em] text-muted-foreground/60"> | |
| 777 | Git actions | |
| 778 | </p> | |
| 779 | {GIT_ACTION_ITEMS.map((item) => { | |
| 780 | const disabled = | |
| 781 | item.id === "commit" ? gitActionsDisabled : pushActionDisabled; | |
| 782 | return ( | |
| 783 | <button | |
| 784 | key={item.id} | |
| 785 | type="button" | |
| 786 | className="mb-1 flex w-full items-center rounded-lg px-2 py-2 text-left text-[11px] text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50" | |
| 787 | disabled={disabled} | |
| 788 | onClick={() => { | |
| 789 | void runGitAction(item.id); | |
| 790 | }} | |
| 791 | > | |
| 792 | {item.label} | |
| 793 | </button> | |
| 794 | ); | |
| 795 | })} | |
| 796 | {gitStatus?.branch === null && ( | |
| 797 | <p className="px-2 pt-1 text-[10px] text-amber-500 dark:text-amber-300"> | |
| 798 | Detached HEAD: push and PR actions are unavailable. | |
| 799 | </p> | |
| 800 | )} | |
| 801 | </div> | |
| 802 | )} | |
| 803 | </div> | |
| 804 | )} | |
| 620 | 805 | {/* Diff toggle */} |
| 621 | 806 | <button |
| 622 | 807 | type="button" |
| ... | ... | @@ -639,6 +824,18 @@ export default function ChatView() { |
| 639 | 824 | </div> |
| 640 | 825 | )} |
| 641 | 826 | |
| 827 | {gitActionError && ( | |
| 828 | <div className="mx-4 mt-3 rounded-lg border border-rose-400/20 bg-rose-900/20 px-3 py-2 text-xs text-rose-200"> | |
| 829 | {gitActionError} | |
| 830 | </div> | |
| 831 | )} | |
| 832 | ||
| 833 | {gitActionNotice && ( | |
| 834 | <div className="mx-4 mt-3 rounded-lg border border-emerald-400/20 bg-emerald-500/[0.08] px-3 py-2 text-xs text-emerald-100"> | |
| 835 | {gitActionNotice} | |
| 836 | </div> | |
| 837 | )} | |
| 838 | ||
| 642 | 839 | {pendingApprovals.length > 0 && ( |
| 643 | 840 | <div className="mx-4 mt-3 space-y-2"> |
| 644 | 841 | {pendingApprovals.map((approval) => { |
apps/web/src/wsNativeApi.ts+4| ... | ... | @@ -96,6 +96,10 @@ export function createWsNativeApi(): NativeApi { |
| 96 | 96 | openInEditor: (cwd, editor) => |
| 97 | 97 | transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }), |
| 98 | 98 | }, |
| 99 | git: { | |
| 100 | status: (input) => transport.request(WS_METHODS.gitStatus, input), | |
| 101 | runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input), | |
| 102 | }, | |
| 99 | 103 | contextMenu: { |
| 100 | 104 | show: async <T extends string>( |
| 101 | 105 | items: readonly { id: T; label: string }[], |
packages/contracts/src/git.test.ts created+73| ... | ... | @@ -0,0 +1,73 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | gitRunStackedActionInputSchema, | |
| 5 | gitRunStackedActionResultSchema, | |
| 6 | gitStackedActionSchema, | |
| 7 | gitStatusInputSchema, | |
| 8 | gitStatusResultSchema, | |
| 9 | } from "./git"; | |
| 10 | ||
| 11 | describe("git contracts", () => { | |
| 12 | it("parses git status input and trims cwd", () => { | |
| 13 | const parsed = gitStatusInputSchema.parse({ cwd: " /tmp/repo " }); | |
| 14 | expect(parsed.cwd).toBe("/tmp/repo"); | |
| 15 | }); | |
| 16 | ||
| 17 | it("parses git status result", () => { | |
| 18 | const parsed = gitStatusResultSchema.parse({ | |
| 19 | branch: "feature/git-actions", | |
| 20 | hasWorkingTreeChanges: true, | |
| 21 | hasUpstream: false, | |
| 22 | aheadCount: 0, | |
| 23 | behindCount: 0, | |
| 24 | }); | |
| 25 | expect(parsed.branch).toBe("feature/git-actions"); | |
| 26 | expect(parsed.hasWorkingTreeChanges).toBe(true); | |
| 27 | }); | |
| 28 | ||
| 29 | it("accepts supported stacked actions", () => { | |
| 30 | expect(gitStackedActionSchema.parse("commit")).toBe("commit"); | |
| 31 | expect(gitStackedActionSchema.parse("commit_push")).toBe("commit_push"); | |
| 32 | expect(gitStackedActionSchema.parse("commit_push_pr")).toBe( | |
| 33 | "commit_push_pr", | |
| 34 | ); | |
| 35 | }); | |
| 36 | ||
| 37 | it("parses stacked action input", () => { | |
| 38 | const parsed = gitRunStackedActionInputSchema.parse({ | |
| 39 | cwd: "/tmp/repo", | |
| 40 | action: "commit_push", | |
| 41 | }); | |
| 42 | expect(parsed.action).toBe("commit_push"); | |
| 43 | }); | |
| 44 | ||
| 45 | it("parses stacked action result with metadata", () => { | |
| 46 | const parsed = gitRunStackedActionResultSchema.parse({ | |
| 47 | action: "commit_push_pr", | |
| 48 | commit: { | |
| 49 | status: "created", | |
| 50 | commitSha: "abc123", | |
| 51 | subject: "Implement stacked git actions", | |
| 52 | }, | |
| 53 | push: { | |
| 54 | status: "pushed", | |
| 55 | branch: "feature/git-actions", | |
| 56 | upstreamBranch: "origin/feature/git-actions", | |
| 57 | setUpstream: true, | |
| 58 | }, | |
| 59 | pr: { | |
| 60 | status: "created", | |
| 61 | url: "https://github.com/pingdotgg/codething-mvp/pull/123", | |
| 62 | number: 123, | |
| 63 | baseBranch: "main", | |
| 64 | headBranch: "feature/git-actions", | |
| 65 | title: "Add stacked git actions workflow", | |
| 66 | }, | |
| 67 | }); | |
| 68 | ||
| 69 | expect(parsed.commit.status).toBe("created"); | |
| 70 | expect(parsed.pr.number).toBe(123); | |
| 71 | }); | |
| 72 | }); | |
| 73 |
packages/contracts/src/git.ts created+66| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | import { z } from "zod"; | |
| 2 | ||
| 3 | export const gitStatusInputSchema = z.object({ | |
| 4 | cwd: z.string().trim().min(1), | |
| 5 | }); | |
| 6 | ||
| 7 | export const gitStatusResultSchema = z.object({ | |
| 8 | branch: z.string().min(1).nullable(), | |
| 9 | hasWorkingTreeChanges: z.boolean(), | |
| 10 | hasUpstream: z.boolean(), | |
| 11 | aheadCount: z.number().int().nonnegative(), | |
| 12 | behindCount: z.number().int().nonnegative(), | |
| 13 | }); | |
| 14 | ||
| 15 | export const gitStackedActionSchema = z.enum([ | |
| 16 | "commit", | |
| 17 | "commit_push", | |
| 18 | "commit_push_pr", | |
| 19 | ]); | |
| 20 | ||
| 21 | const gitCommitStepStatusSchema = z.enum(["created", "skipped_no_changes"]); | |
| 22 | const gitPushStepStatusSchema = z.enum([ | |
| 23 | "pushed", | |
| 24 | "skipped_not_requested", | |
| 25 | "skipped_up_to_date", | |
| 26 | ]); | |
| 27 | const gitPrStepStatusSchema = z.enum([ | |
| 28 | "created", | |
| 29 | "opened_existing", | |
| 30 | "skipped_not_requested", | |
| 31 | ]); | |
| 32 | ||
| 33 | export const gitRunStackedActionInputSchema = z.object({ | |
| 34 | cwd: z.string().trim().min(1), | |
| 35 | action: gitStackedActionSchema, | |
| 36 | }); | |
| 37 | ||
| 38 | export const gitRunStackedActionResultSchema = z.object({ | |
| 39 | action: gitStackedActionSchema, | |
| 40 | commit: z.object({ | |
| 41 | status: gitCommitStepStatusSchema, | |
| 42 | commitSha: z.string().min(1).optional(), | |
| 43 | subject: z.string().min(1).optional(), | |
| 44 | }), | |
| 45 | push: z.object({ | |
| 46 | status: gitPushStepStatusSchema, | |
| 47 | branch: z.string().min(1).optional(), | |
| 48 | upstreamBranch: z.string().min(1).optional(), | |
| 49 | setUpstream: z.boolean().optional(), | |
| 50 | }), | |
| 51 | pr: z.object({ | |
| 52 | status: gitPrStepStatusSchema, | |
| 53 | url: z.string().url().optional(), | |
| 54 | number: z.number().int().positive().optional(), | |
| 55 | baseBranch: z.string().min(1).optional(), | |
| 56 | headBranch: z.string().min(1).optional(), | |
| 57 | title: z.string().min(1).optional(), | |
| 58 | }), | |
| 59 | }); | |
| 60 | ||
| 61 | export type GitStatusInput = z.input<typeof gitStatusInputSchema>; | |
| 62 | export type GitStatusResult = z.infer<typeof gitStatusResultSchema>; | |
| 63 | export type GitStackedAction = z.infer<typeof gitStackedActionSchema>; | |
| 64 | export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>; | |
| 65 | export type GitRunStackedActionResult = z.infer<typeof gitRunStackedActionResultSchema>; | |
| 66 |
packages/contracts/src/index.ts+1| ... | ... | @@ -6,3 +6,4 @@ export * from "./provider"; |
| 6 | 6 | export * from "./model"; |
| 7 | 7 | export * from "./ws"; |
| 8 | 8 | export * from "./project"; |
| 9 | export * from "./git"; |
packages/contracts/src/ipc.ts+12| ... | ... | @@ -1,4 +1,10 @@ |
| 1 | 1 | import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; |
| 2 | import type { | |
| 3 | GitRunStackedActionInput, | |
| 4 | GitRunStackedActionResult, | |
| 5 | GitStatusInput, | |
| 6 | GitStatusResult, | |
| 7 | } from "./git"; | |
| 2 | 8 | import type { |
| 3 | 9 | ProviderEvent, |
| 4 | 10 | ProviderInterruptTurnInput, |
| ... | ... | @@ -62,6 +68,12 @@ export interface NativeApi { |
| 62 | 68 | shell: { |
| 63 | 69 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; |
| 64 | 70 | }; |
| 71 | git: { | |
| 72 | status: (input: GitStatusInput) => Promise<GitStatusResult>; | |
| 73 | runStackedAction: ( | |
| 74 | input: GitRunStackedActionInput, | |
| 75 | ) => Promise<GitRunStackedActionResult>; | |
| 76 | }; | |
| 65 | 77 | contextMenu: { |
| 66 | 78 | show: <T extends string>( |
| 67 | 79 | items: readonly { id: T; label: string }[], |
packages/contracts/src/ws.ts+4| ... | ... | @@ -19,6 +19,10 @@ export const WS_METHODS = { |
| 19 | 19 | // Shell methods |
| 20 | 20 | shellOpenInEditor: "shell.openInEditor", |
| 21 | 21 | |
| 22 | // Git methods | |
| 23 | gitStatus: "git.status", | |
| 24 | gitRunStackedAction: "git.runStackedAction", | |
| 25 | ||
| 22 | 26 | // Server meta |
| 23 | 27 | serverGetConfig: "server.getConfig", |
| 24 | 28 | } as const; |