From 4b832735a9490a1a9561afa66f19726062f33a57 Mon Sep 17 00:00:00 2001 From: Cash-Codes Date: Mon, 27 Apr 2026 21:44:17 +0100 Subject: [PATCH] feat: fix PR flow github client and worktree manager --- apps/api/src/app.ts | 11 +- apps/api/src/clients/github.mock.test.ts | 31 ++++ apps/api/src/clients/github.mock.ts | 26 +++ apps/api/src/clients/github.ts | 121 ++++++++++++++ apps/api/src/orchestrator/index.ts | 12 +- apps/api/src/orchestrator/openFixPR/index.ts | 158 ++++++++++++++++++ .../api/src/orchestrator/openFixPR/prompts.ts | 82 +++++++++ .../orchestrator/openFixPR/worktree.test.ts | 86 ++++++++++ .../src/orchestrator/openFixPR/worktree.ts | 139 +++++++++++++++ apps/api/src/routes/chat.ts | 56 ++++++- apps/api/src/server.ts | 16 ++ packages/shared/src/ticket.ts | 11 ++ 12 files changed, 741 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/clients/github.mock.test.ts create mode 100644 apps/api/src/clients/github.mock.ts create mode 100644 apps/api/src/clients/github.ts create mode 100644 apps/api/src/orchestrator/openFixPR/index.ts create mode 100644 apps/api/src/orchestrator/openFixPR/prompts.ts create mode 100644 apps/api/src/orchestrator/openFixPR/worktree.test.ts create mode 100644 apps/api/src/orchestrator/openFixPR/worktree.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c18a6d6..30de4f4 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,6 +1,7 @@ import cors from "cors"; import express, { type Express, Router } from "express"; import type { ClaudeClient } from "./clients/claude.js"; +import type { GithubClient } from "./clients/github.js"; import type { ShortcutClient } from "./clients/shortcut.js"; import type { AppConfig } from "./config.js"; import type { FixtureLibrary } from "./fixtures/index.js"; @@ -27,8 +28,12 @@ export interface CreateAppDeps { claude?: ClaudeClient; /** Injectable Shortcut client (live or mock). When absent, pipeline stub is used. */ shortcut?: ShortcutClient; + /** Injectable GitHub client (live or mock). Required only for live PR flow. */ + github?: GithubClient; /** Fixture library — powers the mock client's ticket/PR metadata lookup. */ fixtures?: FixtureLibrary; + /** Absolute path to the product repo — required for the live fix-PR flow. */ + productRepoPath?: string; /** Skips static /widget/* and /demo/* mounting — useful in tests. */ skipWidgetStatic?: boolean; } @@ -40,7 +45,9 @@ export function createApp({ retriever, claude, shortcut, + github, fixtures, + productRepoPath, skipWidgetStatic = false, }: CreateAppDeps): Express { const app = express(); @@ -73,7 +80,9 @@ export function createApp({ retriever, claude, shortcut, + github, fixtures, + productRepoPath, }), ); if (!skipWidgetStatic) { @@ -94,7 +103,7 @@ export function createApp({ port: config.port, claude: claude?.mode ?? config.claude.mode, shortcut: shortcut?.mode ?? config.shortcut.mode, - github: config.github.mode, + github: github?.mode ?? config.github.mode, demoMode: config.demoMode, widgetOriginsConfigured: config.cors.widgetOrigins.length, fixtures: fixtures?.all().length ?? 0, diff --git a/apps/api/src/clients/github.mock.test.ts b/apps/api/src/clients/github.mock.test.ts new file mode 100644 index 0000000..cbac4ca --- /dev/null +++ b/apps/api/src/clients/github.mock.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createMockGithubClient } from "./github.mock.js"; + +const draft = { + title: "fix: foo", + body: "body", + branch: "fix/foo", + baseBranch: "main", +}; + +describe("createMockGithubClient", () => { + it("returns a mock-provider PR with a deterministic URL based on branch", async () => { + const client = createMockGithubClient(); + const pr = await client.createPullRequest(draft, { cwd: "/tmp/repo" }); + expect(pr.provider).toBe("mock"); + expect(pr.branch).toBe("fix/foo"); + expect(pr.prUrl).toBe("https://example.test/pr/fix/foo"); + }); + + it("records each draft in `created` and reset() clears", async () => { + const client = createMockGithubClient(); + await client.createPullRequest(draft, { cwd: "/tmp/repo" }); + await client.createPullRequest( + { ...draft, branch: "fix/bar" }, + { cwd: "/tmp/repo" }, + ); + expect(client.created.map((d) => d.branch)).toEqual(["fix/foo", "fix/bar"]); + client.reset(); + expect(client.created).toHaveLength(0); + }); +}); diff --git a/apps/api/src/clients/github.mock.ts b/apps/api/src/clients/github.mock.ts new file mode 100644 index 0000000..7ee7c90 --- /dev/null +++ b/apps/api/src/clients/github.mock.ts @@ -0,0 +1,26 @@ +import type { PRDraft, PRSummary } from "@ai-support/shared"; +import type { GithubClient } from "./github.js"; + +export interface MockGithubClient extends GithubClient { + created: PRDraft[]; + reset(): void; +} + +export function createMockGithubClient(): MockGithubClient { + const created: PRDraft[] = []; + return { + mode: "mock", + created, + reset() { + created.length = 0; + }, + async createPullRequest(draft): Promise { + created.push(draft); + return { + prUrl: `https://example.test/pr/${draft.branch}`, + branch: draft.branch, + provider: "mock", + }; + }, + }; +} diff --git a/apps/api/src/clients/github.ts b/apps/api/src/clients/github.ts new file mode 100644 index 0000000..908ca06 --- /dev/null +++ b/apps/api/src/clients/github.ts @@ -0,0 +1,121 @@ +import { spawn } from "node:child_process"; +import type { PRDraft, PRSummary } from "@ai-support/shared"; +import type { Logger } from "../logger.js"; + +/** + * GithubClient opens a pull request from a branch that has already been + * pushed to the remote. This client does NOT push the branch — that step + * happens inside the worktree before the client is invoked. + */ +export interface GithubClient { + createPullRequest(draft: PRDraft, opts: { cwd: string }): Promise; + mode: "live" | "mock"; +} + +export interface CreateLiveGithubClientOptions { + /** Optional explicit token; otherwise relies on `gh auth status`. */ + token?: string; + logger?: Logger; + timeoutMs?: number; +} + +const DEFAULT_TIMEOUT_MS = 30_000; + +/** + * Live GitHub client. Uses the `gh` CLI to open the PR — `gh` handles + * auth via either GH_TOKEN env or a prior `gh auth login`. Same machine + * model as the live Claude client: spawn → wait → parse. + */ +export function createLiveGithubClient( + opts: CreateLiveGithubClientOptions = {}, +): GithubClient { + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const env = opts.token + ? { ...process.env, GH_TOKEN: opts.token } + : process.env; + + return { + mode: "live", + async createPullRequest( + draft: PRDraft, + { cwd }: { cwd: string }, + ): Promise { + const args = [ + "pr", + "create", + "--title", + draft.title, + "--body", + draft.body, + "--head", + draft.branch, + "--base", + draft.baseBranch, + ]; + + const { stdout } = await runGh(args, { cwd, env, timeoutMs }); + const url = stdout.trim().split("\n").pop() ?? ""; + if (!/^https?:\/\//.test(url)) { + throw new Error( + `gh pr create: unexpected output "${stdout.slice(0, 200)}"`, + ); + } + opts.logger?.info( + { prUrl: url, branch: draft.branch }, + "github: PR opened", + ); + return { + prUrl: url, + branch: draft.branch, + provider: "github", + }; + }, + }; +} + +interface RunResult { + stdout: string; + stderr: string; +} + +function runGh( + args: string[], + opts: { cwd: string; env: NodeJS.ProcessEnv; timeoutMs: number }, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn("gh", args, { cwd: opts.cwd, env: opts.env }); + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + let settled = false; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGTERM"); + reject(new Error(`gh CLI timed out after ${opts.timeoutMs}ms`)); + }, opts.timeoutMs); + + child.stdout.on("data", (c: Buffer) => stdoutChunks.push(c.toString())); + child.stderr.on("data", (c: Buffer) => stderrChunks.push(c.toString())); + + child.on("error", (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(new Error(`gh CLI failed to spawn: ${err.message}`)); + }); + + child.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + const stdout = stdoutChunks.join(""); + const stderr = stderrChunks.join(""); + if (code !== 0) { + reject(new Error(`gh CLI exit ${code}: ${stderr.slice(0, 240)}`)); + return; + } + resolve({ stdout, stderr }); + }); + }); +} diff --git a/apps/api/src/orchestrator/index.ts b/apps/api/src/orchestrator/index.ts index a714021..759bfa4 100644 --- a/apps/api/src/orchestrator/index.ts +++ b/apps/api/src/orchestrator/index.ts @@ -47,7 +47,11 @@ export interface PipelineOverrides { i: IntakeResult, ) => Promise; /** Returns null when the scenario has no PR (treated as skipped by the orchestrator). */ - openFixPR?: (r: ResolutionResult) => Promise; + openFixPR?: ( + r: ResolutionResult, + c: CodeInvestigationResult | null, + i: IntakeResult, + ) => Promise; } export interface PipelineContext { @@ -146,7 +150,11 @@ export async function runPipeline( if (ctx.flags.prFlow && resolution.confidence === "high") { if (ctx.overrides?.openFixPR) { try { - const maybePR = await ctx.overrides.openFixPR(resolution); + const maybePR = await ctx.overrides.openFixPR( + resolution, + investigation, + intake, + ); if (maybePR) { pr = maybePR; emit({ phase: "openFixPR", status: "completed" }); diff --git a/apps/api/src/orchestrator/openFixPR/index.ts b/apps/api/src/orchestrator/openFixPR/index.ts new file mode 100644 index 0000000..3ec44f5 --- /dev/null +++ b/apps/api/src/orchestrator/openFixPR/index.ts @@ -0,0 +1,158 @@ +import type { PRDraft, PRSummary } from "@ai-support/shared"; +import { extractLastJsonBlock } from "../../clients/claude/parse.js"; +import { spawnClaude } from "../../clients/claude/spawn.js"; +import type { GithubClient } from "../../clients/github.js"; +import type { Logger } from "../../logger.js"; +import type { CodeInvestigationResult } from "../codeInvestigation.js"; +import type { IntakeResult } from "../intake.js"; +import type { ResolutionResult } from "../resolution.js"; +import { + type FixOutput, + buildFixPrompt, + normalizeFixOutput, +} from "./prompts.js"; +import { + type WorktreeHandle, + createWorktree, + removeWorktree, +} from "./worktree.js"; + +export interface OpenFixPROptions { + /** Absolute path to the product repo (with .git + a working remote). */ + productRepoPath: string; + baseBranch?: string; + github: GithubClient; + logger?: Logger; + /** Override timeouts (ms). Defaults: edit=300_000. */ + timeouts?: { edit?: number }; + /** Slug used in the branch name (snake-case-friendly). */ + slug?: string; +} + +export interface OpenFixPRInput { + intake: IntakeResult; + resolution: ResolutionResult; + investigation: CodeInvestigationResult; +} + +/** + * Composes the full fix-and-PR flow: + * 1. Create a fresh worktree on a new branch + * 2. Spawn Claude with Read/Write/Edit/Bash(git *) inside that worktree + * 3. Parse the agent's outcome — bail if it didn't make changes + * 4. Open a PR via the GithubClient + * 5. Always clean up the worktree (success or failure) + * + * Returns null when the agent reported no_changes (treated by the + * orchestrator as a clean "skipped"). Throws on infrastructure failures + * — orchestrator surfaces those as `openFixPR: failed`. + */ +export async function runOpenFixPR( + input: OpenFixPRInput, + opts: OpenFixPROptions, +): Promise { + const slug = opts.slug ?? deriveSlug(input.intake.normalized); + const baseBranch = opts.baseBranch ?? "main"; + + let worktree: WorktreeHandle | undefined; + try { + worktree = await createWorktree({ + repoPath: opts.productRepoPath, + slug, + baseBranch, + logger: opts.logger, + }); + + const fix = await runFixAgent({ + worktree, + input, + logger: opts.logger, + timeoutMs: opts.timeouts?.edit ?? 300_000, + }); + + if (fix.outcome !== "success" || fix.files_changed.length === 0) { + opts.logger?.warn( + { outcome: fix.outcome, summary: fix.summary }, + "openFixPR: agent did not produce a usable fix", + ); + return null; + } + + const draft: PRDraft = { + branch: worktree.branch, + baseBranch, + title: fix.commit_message || `fix: ${slug}`, + body: composePRBody({ input, fix }), + }; + + const pr = await opts.github.createPullRequest(draft, { + cwd: worktree.path, + }); + opts.logger?.info( + { prUrl: pr.prUrl, branch: pr.branch }, + "openFixPR: PR opened", + ); + return pr; + } finally { + if (worktree) { + await removeWorktree(opts.productRepoPath, worktree, opts.logger); + } + } +} + +interface RunFixAgentArgs { + worktree: WorktreeHandle; + input: OpenFixPRInput; + logger?: Logger; + timeoutMs: number; +} + +async function runFixAgent(args: RunFixAgentArgs): Promise { + const { stdout } = await spawnClaude({ + prompt: buildFixPrompt({ + intake: args.input.intake, + investigation: args.input.investigation, + resolution: args.input.resolution, + branch: args.worktree.branch, + }), + cwd: args.worktree.path, + allowedTools: ["Read", "Write", "Edit", "Grep", "Glob", "Bash(git *)"], + maxTurns: 60, + timeoutMs: args.timeoutMs, + logger: args.logger, + tag: "fix-agent", + }); + const parsed = extractLastJsonBlock>(stdout); + if (!parsed.ok || !parsed.data) { + throw new Error(`fix agent: ${parsed.reason ?? "parse failed"}`); + } + return normalizeFixOutput(parsed.data); +} + +function composePRBody(args: { + input: OpenFixPRInput; + fix: FixOutput; +}): string { + const sections: string[] = []; + sections.push(`## Reported issue\n\n> ${args.input.intake.originalMessage}`); + sections.push(`## Root cause\n\n${args.input.investigation.rootCause}`); + if (args.fix.files_changed.length > 0) { + const list = args.fix.files_changed.map((f) => `- \`${f}\``).join("\n"); + sections.push(`## Files changed\n\n${list}`); + } + sections.push(`## Summary\n\n${args.fix.summary}`); + sections.push( + `---\n_Opened by AI Support Engineer · session \`${args.input.intake.sessionId}\`_`, + ); + return sections.join("\n\n"); +} + +function deriveSlug(s: string): string { + return ( + s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40) || "support-fix" + ); +} diff --git a/apps/api/src/orchestrator/openFixPR/prompts.ts b/apps/api/src/orchestrator/openFixPR/prompts.ts new file mode 100644 index 0000000..7eaaf90 --- /dev/null +++ b/apps/api/src/orchestrator/openFixPR/prompts.ts @@ -0,0 +1,82 @@ +import type { CodeInvestigationResult } from "../codeInvestigation.js"; +import type { IntakeResult } from "../intake.js"; +import type { ResolutionResult } from "../resolution.js"; + +export function buildFixPrompt(args: { + intake: IntakeResult; + investigation: CodeInvestigationResult; + resolution: ResolutionResult; + branch: string; +}): string { + const filesList = args.investigation.affectedFiles.length + ? args.investigation.affectedFiles.map((f) => ` - ${f}`).join("\n") + : " (none specified — locate them yourself)"; + + return `You are the implementation stage of an AI support agent. The +investigation phase has already located the probable root cause; your +job is to make the actual code change, commit it, and push the branch. + +# User-reported issue +${args.intake.originalMessage} + +# Probable root cause (from investigation) +${args.investigation.rootCause} + +# Affected files +${filesList} + +# Workaround the support reply already shipped +${args.resolution.workaround} + +# Working environment +You are inside a fresh git worktree on branch \`${args.branch}\` (already +checked out). Make the smallest correct change that fixes the root cause. +Do not refactor unrelated code, do not add new dependencies. + +# Required steps (use the Edit / Write / Read / Grep / Glob tools, then Bash for git) +1. Read the affected files to confirm the diagnosis. +2. Apply the fix. Keep changes minimal and well-scoped. +3. Commit your changes: \`git add -A && git commit -m ""\` +4. Push the branch: \`git push --set-upstream origin ${args.branch}\` + +If you cannot apply the fix (file gone, ambiguity, etc.), do NOT push. +Set "outcome": "no_changes" or "error" in the result block and explain. + +# Output contract +Your response MUST end with a single \`\`\`json fenced block in this shape: + +{ + "outcome": "success" | "no_changes" | "error", + "summary": string, + "files_changed": string[], // repo-relative paths + "commit_message": string +} + +Return only the JSON in the final \`\`\`json block.`; +} + +export interface FixOutput { + outcome: "success" | "no_changes" | "error"; + summary: string; + files_changed: string[]; + commit_message: string; +} + +export function normalizeFixOutput(d: Partial): FixOutput { + const valid = new Set(["success", "no_changes", "error"]); + const outcome = ( + typeof d.outcome === "string" && valid.has(d.outcome) ? d.outcome : "error" + ) as FixOutput["outcome"]; + const files = Array.isArray(d.files_changed) + ? (d.files_changed as unknown[]).filter( + (x): x is string => typeof x === "string", + ) + : []; + return { + outcome, + summary: typeof d.summary === "string" ? d.summary : "", + files_changed: files, + commit_message: + typeof d.commit_message === "string" ? d.commit_message : "", + }; +} diff --git a/apps/api/src/orchestrator/openFixPR/worktree.test.ts b/apps/api/src/orchestrator/openFixPR/worktree.test.ts new file mode 100644 index 0000000..716e979 --- /dev/null +++ b/apps/api/src/orchestrator/openFixPR/worktree.test.ts @@ -0,0 +1,86 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createWorktree, removeWorktree, runGit } from "./worktree.js"; + +let tmp: string; +let repoPath: string; + +beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), "wt-test-")); + repoPath = path.join(tmp, "repo"); + await fs.mkdir(repoPath, { recursive: true }); + // Initialize a real git repo with one commit on main. + await runGit(["init", "-b", "main"], repoPath); + await runGit(["config", "user.email", "test@test"], repoPath); + await runGit(["config", "user.name", "test"], repoPath); + await fs.writeFile(path.join(repoPath, "README.md"), "# Test\n"); + await runGit(["add", "."], repoPath); + await runGit(["commit", "-m", "initial"], repoPath); +}); +afterEach(async () => { + await fs.rm(tmp, { recursive: true, force: true }); +}); + +describe("createWorktree + removeWorktree", () => { + it("creates a worktree at a new branch off main and tears it down", async () => { + const handle = await createWorktree({ + repoPath, + slug: "basket-fix", + parentDir: tmp, + }); + + expect(handle.branch).toMatch(/^support\/basket-fix-/); + expect(handle.path).toContain("support-worktree-basket-fix-"); + + // Worktree directory exists with the README copied from main + const stat = await fs.stat(handle.path); + expect(stat.isDirectory()).toBe(true); + const readme = await fs.readFile( + path.join(handle.path, "README.md"), + "utf8", + ); + expect(readme).toContain("# Test"); + + // Branch is recognized by the source repo + const { stdout: branchList } = await runGit(["branch"], repoPath); + expect(branchList).toContain(handle.branch); + + await removeWorktree(repoPath, handle); + + // Directory gone + await expect(fs.stat(handle.path)).rejects.toBeDefined(); + // Branch gone + const { stdout: branchListAfter } = await runGit(["branch"], repoPath); + expect(branchListAfter).not.toContain(handle.branch); + }); + + it("removeWorktree is idempotent — safe to call when partially gone", async () => { + const handle = await createWorktree({ + repoPath, + slug: "twice", + parentDir: tmp, + }); + await removeWorktree(repoPath, handle); + // second call shouldn't throw even though everything is gone + await expect(removeWorktree(repoPath, handle)).resolves.not.toThrow(); + }); + + it("creates each worktree at a unique branch + path", async () => { + const a = await createWorktree({ + repoPath, + slug: "shared", + parentDir: tmp, + }); + const b = await createWorktree({ + repoPath, + slug: "shared", + parentDir: tmp, + }); + expect(a.branch).not.toBe(b.branch); + expect(a.path).not.toBe(b.path); + await removeWorktree(repoPath, a); + await removeWorktree(repoPath, b); + }); +}); diff --git a/apps/api/src/orchestrator/openFixPR/worktree.ts b/apps/api/src/orchestrator/openFixPR/worktree.ts new file mode 100644 index 0000000..e65a558 --- /dev/null +++ b/apps/api/src/orchestrator/openFixPR/worktree.ts @@ -0,0 +1,139 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { Logger } from "../../logger.js"; + +export interface WorktreeHandle { + /** Absolute path to the new worktree's working directory. */ + path: string; + /** Branch name created in the worktree. */ + branch: string; +} + +export interface CreateWorktreeOptions { + /** Absolute path to the source repo. */ + repoPath: string; + /** Slug used to name the branch (e.g. "basket-push-fix"). */ + slug: string; + /** Base branch to fork from — defaults to "main". */ + baseBranch?: string; + /** Override the parent dir for the worktree (defaults to OS temp). */ + parentDir?: string; + logger?: Logger; +} + +/** + * Creates a fresh git worktree off the given repo at a new branch. Returns + * a handle the caller MUST clean up with `removeWorktree`. + * + * Worktrees give us an isolated working directory whose changes don't + * affect the user's checkout — exactly what we need for an autonomous + * agent to make edits without stepping on the dev's working tree. + */ +export async function createWorktree( + opts: CreateWorktreeOptions, +): Promise { + const baseBranch = opts.baseBranch ?? "main"; + const parentDir = opts.parentDir ?? os.tmpdir(); + await fs.mkdir(parentDir, { recursive: true }); + + const shortId = randomUUID().slice(0, 8); + const branch = `support/${opts.slug}-${shortId}`; + const worktreePath = path.join( + parentDir, + `support-worktree-${opts.slug}-${shortId}`, + ); + + await runGit( + ["worktree", "add", "-b", branch, worktreePath, baseBranch], + opts.repoPath, + ); + + opts.logger?.info( + { branch, worktreePath, repoPath: opts.repoPath, baseBranch }, + "worktree: created", + ); + + return { path: worktreePath, branch }; +} + +/** + * Removes a worktree and its branch (locally). Idempotent — safe to call + * even when partial cleanup is needed. + */ +export async function removeWorktree( + repoPath: string, + handle: WorktreeHandle, + logger?: Logger, +): Promise { + try { + await runGit(["worktree", "remove", "--force", handle.path], repoPath); + } catch (err) { + logger?.warn( + { err: err instanceof Error ? err.message : String(err) }, + "worktree: remove failed (continuing)", + ); + // Last-ditch: rm -rf the directory so we don't leave stale files. + await fs.rm(handle.path, { recursive: true, force: true }).catch(() => {}); + } + try { + await runGit(["branch", "-D", handle.branch], repoPath); + } catch { + // Branch might already be deleted (or never created remotely); ignore. + } +} + +interface RunResult { + stdout: string; + stderr: string; +} + +export function runGit( + args: string[], + cwd: string, + opts: { timeoutMs?: number; env?: NodeJS.ProcessEnv } = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? 30_000; + return new Promise((resolve, reject) => { + const child = spawn("git", args, { cwd, env: opts.env ?? process.env }); + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + let settled = false; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGTERM"); + reject(new Error(`git ${args[0]} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + child.stdout.on("data", (c: Buffer) => stdoutChunks.push(c.toString())); + child.stderr.on("data", (c: Buffer) => stderrChunks.push(c.toString())); + + child.on("error", (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(new Error(`git failed to spawn: ${err.message}`)); + }); + + child.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + const stdout = stdoutChunks.join(""); + const stderr = stderrChunks.join(""); + if (code !== 0) { + reject( + new Error( + `git ${args[0]} exit ${code} (in ${cwd}): ${stderr.trim().slice(0, 240)}`, + ), + ); + return; + } + resolve({ stdout, stderr }); + }); + }); +} diff --git a/apps/api/src/routes/chat.ts b/apps/api/src/routes/chat.ts index d59a036..373042f 100644 --- a/apps/api/src/routes/chat.ts +++ b/apps/api/src/routes/chat.ts @@ -3,12 +3,14 @@ import type { ChatMessage, ChatResponse, PhaseEvent } from "@ai-support/shared"; import { Router, type Router as RouterType } from "express"; import { z } from "zod"; import type { ClaudeClient } from "../clients/claude.js"; +import type { GithubClient } from "../clients/github.js"; import type { ShortcutClient } from "../clients/shortcut.js"; import type { AppConfig } from "../config.js"; import { type FixtureLibrary, buildMockPR } from "../fixtures/index.js"; import type { Logger } from "../logger.js"; import { composeTicket } from "../orchestrator/composeTicket.js"; import { type PipelineOverrides, runPipeline } from "../orchestrator/index.js"; +import { runOpenFixPR } from "../orchestrator/openFixPR/index.js"; import type { Retriever } from "../rag/indexer.js"; import type { SessionStore } from "../sessions/store.js"; @@ -30,19 +32,34 @@ export interface ChatRouterDeps { retriever?: Retriever; claude?: ClaudeClient; shortcut?: ShortcutClient; + github?: GithubClient; fixtures?: FixtureLibrary; + /** Required for the live PR flow — absolute path to the product repo. */ + productRepoPath?: string; } export function buildChatRouter(deps: ChatRouterDeps): RouterType { - const { config, logger, sessions, retriever, claude, shortcut, fixtures } = - deps; + const { + config, + logger, + sessions, + retriever, + claude, + shortcut, + github, + fixtures, + productRepoPath, + } = deps; const router: RouterType = Router(); const overrides = buildPipelineOverrides({ retriever, claude, shortcut, + github, fixtures, + productRepoPath, + logger, }); router.post("/chat", async (req, res) => { @@ -162,14 +179,25 @@ interface OverridesDeps { retriever?: Retriever; claude?: ClaudeClient; shortcut?: ShortcutClient; + github?: GithubClient; fixtures?: FixtureLibrary; + productRepoPath?: string; + logger: Logger; } function buildPipelineOverrides( deps: OverridesDeps, ): PipelineOverrides | undefined { - const { retriever, claude, shortcut, fixtures } = deps; - const hasAny = retriever || claude || shortcut || fixtures; + const { + retriever, + claude, + shortcut, + github, + fixtures, + productRepoPath, + logger, + } = deps; + const hasAny = retriever || claude || shortcut || github || fixtures; if (!hasAny) return undefined; const overrides: PipelineOverrides = {}; @@ -205,7 +233,25 @@ function buildPipelineOverrides( }; } - if (fixtures) { + // openFixPR routing: + // live path — both Claude and GitHub clients are live AND the + // product repo path is set: spawn the real fix agent. + // fixture path — fall back to the fixture's preformed PR data so + // demos keep working without git/gh tooling. + const liveFixPath = + claude?.mode === "live" && + github?.mode === "live" && + typeof productRepoPath === "string"; + + if (liveFixPath && github && productRepoPath) { + overrides.openFixPR = async (resolution, investigation, intake) => { + if (!investigation) return null; + return runOpenFixPR( + { intake, resolution, investigation }, + { productRepoPath, github, logger }, + ); + }; + } else if (fixtures) { overrides.openFixPR = async (resolution) => { const fix = findFixtureByResolution(fixtures, resolution); return fix ? (buildMockPR(fix) ?? null) : null; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index fed9fca..eb725e8 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -5,6 +5,8 @@ import { createApp } from "./app.js"; import type { ClaudeClient } from "./clients/claude.js"; import { createLiveClaudeClient } from "./clients/claude.js"; import { createMockClaudeClient } from "./clients/claude.mock.js"; +import { type GithubClient, createLiveGithubClient } from "./clients/github.js"; +import { createMockGithubClient } from "./clients/github.mock.js"; import { type ShortcutClient, createLiveShortcutClient, @@ -118,6 +120,18 @@ if (claudeMode === "live" && productRepo.path) { logger.warn("claude: mock client active with no fixtures"); } +// Pick the GitHub client. Live mode requires both ENABLE_PR_FLOW=true and +// either GH_TOKEN/GITHUB_TOKEN (gh CLI authentication). The mock client +// returns a deterministic fake URL. +let github: GithubClient; +if (config.github.mode === "live") { + github = createLiveGithubClient({ token: config.github.token, logger }); + logger.info({ repo: config.github.repo }, "github: live client active"); +} else { + github = createMockGithubClient(); + logger.info("github: mock client active"); +} + // Pick the Shortcut client. Live mode requires both SHORTCUT_API_TOKEN // (already in config) and — for most workspaces — a workflow_state_id. In // every other case we use the deterministic mock. @@ -143,7 +157,9 @@ const app = createApp({ retriever, claude, shortcut, + github, fixtures, + productRepoPath: productRepo.path, }); app.listen(config.port, () => { diff --git a/packages/shared/src/ticket.ts b/packages/shared/src/ticket.ts index 7d8db2d..8c46036 100644 --- a/packages/shared/src/ticket.ts +++ b/packages/shared/src/ticket.ts @@ -22,3 +22,14 @@ export interface TicketDraft { storyType: StoryType; labels: string[]; } + +/** + * Platform-agnostic PR draft. The openFixPR phase composes one of these + * after running the fix; a GithubClient (live or mock) opens the PR. + */ +export interface PRDraft { + title: string; + body: string; + branch: string; + baseBranch: string; +}