Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
}
Expand All @@ -40,7 +45,9 @@ export function createApp({
retriever,
claude,
shortcut,
github,
fixtures,
productRepoPath,
skipWidgetStatic = false,
}: CreateAppDeps): Express {
const app = express();
Expand Down Expand Up @@ -73,7 +80,9 @@ export function createApp({
retriever,
claude,
shortcut,
github,
fixtures,
productRepoPath,
}),
);
if (!skipWidgetStatic) {
Expand All @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions apps/api/src/clients/github.mock.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
26 changes: 26 additions & 0 deletions apps/api/src/clients/github.mock.ts
Original file line number Diff line number Diff line change
@@ -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<PRSummary> {
created.push(draft);
return {
prUrl: `https://example.test/pr/${draft.branch}`,
branch: draft.branch,
provider: "mock",
};
},
};
}
121 changes: 121 additions & 0 deletions apps/api/src/clients/github.ts
Original file line number Diff line number Diff line change
@@ -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<PRSummary>;
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<PRSummary> {
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<RunResult> {
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 });
});
});
}
12 changes: 10 additions & 2 deletions apps/api/src/orchestrator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ export interface PipelineOverrides {
i: IntakeResult,
) => Promise<TicketSummary>;
/** Returns null when the scenario has no PR (treated as skipped by the orchestrator). */
openFixPR?: (r: ResolutionResult) => Promise<PRSummary | null>;
openFixPR?: (
r: ResolutionResult,
c: CodeInvestigationResult | null,
i: IntakeResult,
) => Promise<PRSummary | null>;
}

export interface PipelineContext {
Expand Down Expand Up @@ -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" });
Expand Down
Loading
Loading