| 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,6 +85,10 @@ describe("WebSocket Server", () => { |
| 85 | devUrl?: string; | 85 | devUrl?: string; |
| 86 | authToken?: string; | 86 | authToken?: string; |
| 87 | stateDir?: string; | 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 | ): ReturnType<typeof createServer> { | 93 | ): ReturnType<typeof createServer> { |
| 90 | const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); | 94 | const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); |
| ... | @@ -94,6 +98,7 @@ describe("WebSocket Server", () => { | ... | @@ -94,6 +98,7 @@ describe("WebSocket Server", () => { |
| 94 | ...(options.devUrl ? { devUrl: options.devUrl } : {}), | 98 | ...(options.devUrl ? { devUrl: options.devUrl } : {}), |
| 95 | ...(options.authToken ? { authToken: options.authToken } : {}), | 99 | ...(options.authToken ? { authToken: options.authToken } : {}), |
| 96 | projectRegistry: new ProjectRegistry(stateDir), | 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,6 +286,70 @@ describe("WebSocket Server", () => { |
| 281 | expect(afterRemove.result).toEqual([]); | 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 | it("prunes missing projects on startup", async () => { | 353 | it("prunes missing projects on startup", async () => { |
| 285 | const stateDir = makeTempDir("t3code-ws-prune-state-"); | 354 | const stateDir = makeTempDir("t3code-ws-prune-state-"); |
| 286 | const existing = makeTempDir("t3code-ws-existing-project-"); | 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,6 +19,7 @@ import { WebSocketServer, type WebSocket } from "ws"; |
| 19 | import { createLogger } from "./logger"; | 19 | import { createLogger } from "./logger"; |
| 20 | import { ProjectRegistry } from "./projectRegistry"; | 20 | import { ProjectRegistry } from "./projectRegistry"; |
| 21 | import { ProviderManager } from "./providerManager"; | 21 | import { ProviderManager } from "./providerManager"; |
| 22 | import { GitManager } from "./gitManager"; | ||
| 22 | 23 | ||
| 23 | const MIME_TYPES: Record<string, string> = { | 24 | const MIME_TYPES: Record<string, string> = { |
| 24 | ".html": "text/html; charset=utf-8", | 25 | ".html": "text/html; charset=utf-8", |
| ... | @@ -43,6 +44,7 @@ export interface ServerOptions { | ... | @@ -43,6 +44,7 @@ export interface ServerOptions { |
| 43 | devUrl?: string | undefined; | 44 | devUrl?: string | undefined; |
| 44 | logWebSocketEvents?: boolean | undefined; | 45 | logWebSocketEvents?: boolean | undefined; |
| 45 | projectRegistry?: ProjectRegistry | undefined; | 46 | projectRegistry?: ProjectRegistry | undefined; |
| 47 | gitManager?: GitManager | undefined; | ||
| 46 | authToken?: string | undefined; | 48 | authToken?: string | undefined; |
| 47 | } | 49 | } |
| 48 | 50 | ||
| ... | @@ -63,11 +65,13 @@ export function createServer(options: ServerOptions) { | ... | @@ -63,11 +65,13 @@ export function createServer(options: ServerOptions) { |
| 63 | devUrl, | 65 | devUrl, |
| 64 | logWebSocketEvents: explicitLogWsEvents, | 66 | logWebSocketEvents: explicitLogWsEvents, |
| 65 | projectRegistry: providedRegistry, | 67 | projectRegistry: providedRegistry, |
| 68 | gitManager: providedGitManager, | ||
| 66 | authToken, | 69 | authToken, |
| 67 | } = options; | 70 | } = options; |
| 68 | const providerManager = new ProviderManager(); | 71 | const providerManager = new ProviderManager(); |
| 69 | const projectRegistry = | 72 | const projectRegistry = |
| 70 | providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); | 73 | providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); |
| 74 | const gitManager = providedGitManager ?? new GitManager(); | ||
| 71 | const clients = new Set<WebSocket>(); | 75 | const clients = new Set<WebSocket>(); |
| 72 | const logger = createLogger("ws"); | 76 | const logger = createLogger("ws"); |
| 73 | const logWebSocketEvents = | 77 | const logWebSocketEvents = |
| ... | @@ -304,6 +308,12 @@ export function createServer(options: ServerOptions) { | ... | @@ -304,6 +308,12 @@ export function createServer(options: ServerOptions) { |
| 304 | return undefined; | 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 | case WS_METHODS.serverGetConfig: | 317 | case WS_METHODS.serverGetConfig: |
| 308 | return { cwd }; | 318 | return { cwd }; |
| 309 | 319 |
apps/web/src/components/ChatView.tsx+197| ... | @@ -1,5 +1,8 @@ | ... | @@ -1,5 +1,8 @@ |
| 1 | import { | 1 | import { |
| 2 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | 2 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 3 | type GitRunStackedActionResult, | ||
| 4 | type GitStackedAction, | ||
| 5 | type GitStatusResult, | ||
| 3 | type ProviderApprovalDecision, | 6 | type ProviderApprovalDecision, |
| 4 | type ProviderEvent, | 7 | type ProviderEvent, |
| 5 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| ... | @@ -7,6 +10,7 @@ import { | ... | @@ -7,6 +10,7 @@ import { |
| 7 | type FormEvent, | 10 | type FormEvent, |
| 8 | Fragment, | 11 | Fragment, |
| 9 | type KeyboardEvent, | 12 | type KeyboardEvent, |
| 13 | useCallback, | ||
| 10 | useEffect, | 14 | useEffect, |
| 11 | useMemo, | 15 | useMemo, |
| 12 | useRef, | 16 | useRef, |
| ... | @@ -52,6 +56,14 @@ function editorLabel(editor: (typeof EDITORS)[number]): string { | ... | @@ -52,6 +56,14 @@ function editorLabel(editor: (typeof EDITORS)[number]): string { |
| 52 | 56 | ||
| 53 | const LAST_EDITOR_KEY = "t3code:last-editor"; | 57 | const LAST_EDITOR_KEY = "t3code:last-editor"; |
| 54 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | 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 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | 68 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 57 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; | 69 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; |
| ... | @@ -132,6 +144,35 @@ function derivePendingApprovals( | ... | @@ -132,6 +144,35 @@ function derivePendingApprovals( |
| 132 | return Array.from(pending.values()); | 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 | export default function ChatView() { | 176 | export default function ChatView() { |
| 136 | const { state, dispatch } = useStore(); | 177 | const { state, dispatch } = useStore(); |
| 137 | const api = useMemo(() => readNativeApi(), []); | 178 | const api = useMemo(() => readNativeApi(), []); |
| ... | @@ -140,6 +181,11 @@ export default function ChatView() { | ... | @@ -140,6 +181,11 @@ export default function ChatView() { |
| 140 | const [isConnecting, setIsConnecting] = useState(false); | 181 | const [isConnecting, setIsConnecting] = useState(false); |
| 141 | const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); | 182 | const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); |
| 142 | const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false); | 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 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { | 189 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { |
| 144 | const stored = localStorage.getItem(LAST_EDITOR_KEY); | 190 | const stored = localStorage.getItem(LAST_EDITOR_KEY); |
| 145 | return EDITORS.some((e) => e.id === stored) | 191 | return EDITORS.some((e) => e.id === stored) |
| ... | @@ -160,6 +206,7 @@ export default function ChatView() { | ... | @@ -160,6 +206,7 @@ export default function ChatView() { |
| 160 | const textareaRef = useRef<HTMLTextAreaElement>(null); | 206 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 161 | const modelMenuRef = useRef<HTMLDivElement>(null); | 207 | const modelMenuRef = useRef<HTMLDivElement>(null); |
| 162 | const editorMenuRef = useRef<HTMLDivElement>(null); | 208 | const editorMenuRef = useRef<HTMLDivElement>(null); |
| 209 | const gitMenuRef = useRef<HTMLDivElement>(null); | ||
| 163 | 210 | ||
| 164 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | 211 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 165 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); | 212 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| ... | @@ -265,6 +312,49 @@ export default function ChatView() { | ... | @@ -265,6 +312,49 @@ export default function ChatView() { |
| 265 | approvalPolicy: "on-request", | 312 | approvalPolicy: "on-request", |
| 266 | sandboxMode: "workspace-write", | 313 | sandboxMode: "workspace-write", |
| 267 | } as const); | 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 | const handleRuntimeModeChange = async ( | 359 | const handleRuntimeModeChange = async ( |
| 270 | mode: "approval-required" | "full-access", | 360 | mode: "approval-required" | "full-access", |
| ... | @@ -309,6 +399,42 @@ export default function ChatView() { | ... | @@ -309,6 +399,42 @@ export default function ChatView() { |
| 309 | setExpandedWorkGroups({}); | 399 | setExpandedWorkGroups({}); |
| 310 | }, [activeThread?.id]); | 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 | // Auto-resize textarea | 438 | // Auto-resize textarea |
| 313 | useEffect(() => { | 439 | useEffect(() => { |
| 314 | const ta = textareaRef.current; | 440 | const ta = textareaRef.current; |
| ... | @@ -362,6 +488,22 @@ export default function ChatView() { | ... | @@ -362,6 +488,22 @@ export default function ChatView() { |
| 362 | }; | 488 | }; |
| 363 | }, [isEditorMenuOpen]); | 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 | // Cmd+O / Ctrl+O to open in last-used editor | 507 | // Cmd+O / Ctrl+O to open in last-used editor |
| 366 | useEffect(() => { | 508 | useEffect(() => { |
| 367 | const handler = (e: globalThis.KeyboardEvent) => { | 509 | const handler = (e: globalThis.KeyboardEvent) => { |
| ... | @@ -617,6 +759,49 @@ export default function ChatView() { | ... | @@ -617,6 +759,49 @@ export default function ChatView() { |
| 617 | )} | 759 | )} |
| 618 | </div> | 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 | {/* Diff toggle */} | 805 | {/* Diff toggle */} |
| 621 | <button | 806 | <button |
| 622 | type="button" | 807 | type="button" |
| ... | @@ -639,6 +824,18 @@ export default function ChatView() { | ... | @@ -639,6 +824,18 @@ export default function ChatView() { |
| 639 | </div> | 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 | {pendingApprovals.length > 0 && ( | 839 | {pendingApprovals.length > 0 && ( |
| 643 | <div className="mx-4 mt-3 space-y-2"> | 840 | <div className="mx-4 mt-3 space-y-2"> |
| 644 | {pendingApprovals.map((approval) => { | 841 | {pendingApprovals.map((approval) => { |
apps/web/src/wsNativeApi.ts+4| ... | @@ -96,6 +96,10 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -96,6 +96,10 @@ export function createWsNativeApi(): NativeApi { |
| 96 | openInEditor: (cwd, editor) => | 96 | openInEditor: (cwd, editor) => |
| 97 | transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }), | 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 | contextMenu: { | 103 | contextMenu: { |
| 100 | show: async <T extends string>( | 104 | show: async <T extends string>( |
| 101 | items: readonly { id: T; label: string }[], | 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,3 +6,4 @@ export * from "./provider"; |
| 6 | export * from "./model"; | 6 | export * from "./model"; |
| 7 | export * from "./ws"; | 7 | export * from "./ws"; |
| 8 | export * from "./project"; | 8 | export * from "./project"; |
| 9 | export * from "./git"; |
packages/contracts/src/ipc.ts+12| ... | @@ -1,4 +1,10 @@ | ... | @@ -1,4 +1,10 @@ |
| 1 | import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; | 1 | import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; |
| 2 | import type { | ||
| 3 | GitRunStackedActionInput, | ||
| 4 | GitRunStackedActionResult, | ||
| 5 | GitStatusInput, | ||
| 6 | GitStatusResult, | ||
| 7 | } from "./git"; | ||
| 2 | import type { | 8 | import type { |
| 3 | ProviderEvent, | 9 | ProviderEvent, |
| 4 | ProviderInterruptTurnInput, | 10 | ProviderInterruptTurnInput, |
| ... | @@ -62,6 +68,12 @@ export interface NativeApi { | ... | @@ -62,6 +68,12 @@ export interface NativeApi { |
| 62 | shell: { | 68 | shell: { |
| 63 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; | 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 | contextMenu: { | 77 | contextMenu: { |
| 66 | show: <T extends string>( | 78 | show: <T extends string>( |
| 67 | items: readonly { id: T; label: string }[], | 79 | items: readonly { id: T; label: string }[], |
packages/contracts/src/ws.ts+4| ... | @@ -19,6 +19,10 @@ export const WS_METHODS = { | ... | @@ -19,6 +19,10 @@ export const WS_METHODS = { |
| 19 | // Shell methods | 19 | // Shell methods |
| 20 | shellOpenInEditor: "shell.openInEditor", | 20 | shellOpenInEditor: "shell.openInEditor", |
| 21 | 21 | ||
| 22 | // Git methods | ||
| 23 | gitStatus: "git.status", | ||
| 24 | gitRunStackedAction: "git.runStackedAction", | ||
| 25 | |||
| 22 | // Server meta | 26 | // Server meta |
| 23 | serverGetConfig: "server.getConfig", | 27 | serverGetConfig: "server.getConfig", |
| 24 | } as const; | 28 | } as const; |