Skip to content
Open
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
100 changes: 100 additions & 0 deletions src/ai/__tests__/assessPRFeedback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("@trigger.dev/sdk/v3", () => ({
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
metadata: { set: vi.fn(), append: vi.fn() },
}));

const mockRunClaudeCodeAgent = vi.fn();
vi.mock("../../sandboxes/runClaudeCodeAgent", () => ({
runClaudeCodeAgent: (...args: unknown[]) => mockRunClaudeCodeAgent(...args),
}));

vi.mock("../../sandboxes/logStep", () => ({
logStep: vi.fn(),
}));

const { assessPRFeedback } = await import("../assessPRFeedback");

const mockSandbox = {} as any;

const noFeedback = { reviews: [], comments: [] };
const withFeedback = {
reviews: [
{
author: "reviewer",
body: "Missing error handling in the route handler.",
state: "CHANGES_REQUESTED",
submittedAt: "2026-03-01T12:00:00Z",
},
],
comments: [],
};

beforeEach(() => {
vi.clearAllMocks();
});

describe("assessPRFeedback", () => {
it("returns no actionable feedback when reviews and comments are empty", async () => {
const result = await assessPRFeedback(mockSandbox, "recoupable/api", "Build feature X", noFeedback);

expect(result.hasActionableFeedback).toBe(false);
expect(mockRunClaudeCodeAgent).not.toHaveBeenCalled();
});

it("returns actionable feedback parsed from Claude Code response", async () => {
mockRunClaudeCodeAgent.mockResolvedValueOnce({
exitCode: 0,
stdout: JSON.stringify({
hasActionableFeedback: true,
feedbackSummary: "Add error handling",
implementation: "Wrap the handler in a try/catch and return 500 on error",
}),
stderr: "",
});

const result = await assessPRFeedback(
mockSandbox,
"recoupable/api",
"Build feature X",
withFeedback,
);

expect(result.hasActionableFeedback).toBe(true);
expect(result.feedbackSummary).toBe("Add error handling");
expect(result.implementation).toContain("try/catch");
});

it("returns no actionable feedback when Claude Code exits non-zero", async () => {
mockRunClaudeCodeAgent.mockResolvedValueOnce({
exitCode: 1,
stdout: "",
stderr: "error",
});

const result = await assessPRFeedback(mockSandbox, "recoupable/api", "Feature", withFeedback);

expect(result.hasActionableFeedback).toBe(false);
});

it("returns no actionable feedback when JSON parsing fails", async () => {
mockRunClaudeCodeAgent.mockResolvedValueOnce({
exitCode: 0,
stdout: "not valid json",
stderr: "",
});

const result = await assessPRFeedback(mockSandbox, "recoupable/api", "Feature", withFeedback);

expect(result.hasActionableFeedback).toBe(false);
});

it("returns no actionable feedback when runClaudeCodeAgent throws", async () => {
mockRunClaudeCodeAgent.mockRejectedValueOnce(new Error("Sandbox error"));

const result = await assessPRFeedback(mockSandbox, "recoupable/api", "Feature", withFeedback);

expect(result.hasActionableFeedback).toBe(false);
});
});
86 changes: 86 additions & 0 deletions src/ai/__tests__/generateFeaturePrompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("@trigger.dev/sdk/v3", () => ({
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
metadata: { set: vi.fn(), append: vi.fn() },
}));

const mockRunClaudeCodeAgent = vi.fn();
vi.mock("../../sandboxes/runClaudeCodeAgent", () => ({
runClaudeCodeAgent: (...args: unknown[]) => mockRunClaudeCodeAgent(...args),
}));

vi.mock("../../sandboxes/logStep", () => ({
logStep: vi.fn(),
}));

const { generateFeaturePrompt } = await import("../generateFeaturePrompt");

const mockSandbox = {} as any;

const mockCommits = [
{
submodule: "api",
repo: "recoupable/api",
commits: [
{ sha: "abc1234", message: "feat: add privy logins endpoint", author: "Dev", date: "2026-03-01T00:00:00Z" },
],
},
];

beforeEach(() => {
vi.clearAllMocks();
});

describe("generateFeaturePrompt", () => {
it("returns the Claude Code generated prompt on success", async () => {
mockRunClaudeCodeAgent.mockResolvedValueOnce({
exitCode: 0,
stdout: "Build a feature X in the api submodule.",
stderr: "",
});

const result = await generateFeaturePrompt(mockSandbox, mockCommits);

expect(result).toBe("Build a feature X in the api submodule.");
expect(mockRunClaudeCodeAgent).toHaveBeenCalledWith(
mockSandbox,
expect.objectContaining({
label: "Generate feature prompt",
message: expect.stringContaining("abc1234"),
}),
);
});

it("returns fallback prompt when Claude Code exits non-zero", async () => {
mockRunClaudeCodeAgent.mockResolvedValueOnce({
exitCode: 1,
stdout: "",
stderr: "error",
});

const result = await generateFeaturePrompt(mockSandbox, mockCommits);

expect(result).toContain("PROGRESS.md");
});

it("returns fallback prompt when Claude Code returns empty stdout", async () => {
mockRunClaudeCodeAgent.mockResolvedValueOnce({
exitCode: 0,
stdout: "",
stderr: "",
});

const result = await generateFeaturePrompt(mockSandbox, mockCommits);

expect(result).toContain("PROGRESS.md");
});

it("returns fallback prompt when runClaudeCodeAgent throws", async () => {
mockRunClaudeCodeAgent.mockRejectedValueOnce(new Error("Sandbox error"));

const result = await generateFeaturePrompt(mockSandbox, mockCommits);

expect(result).toContain("PROGRESS.md");
});
});
93 changes: 93 additions & 0 deletions src/ai/assessPRFeedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { Sandbox } from "@vercel/sandbox";
import type { PRFeedback } from "../github/fetchPRReviews";
import { runClaudeCodeAgent } from "../sandboxes/runClaudeCodeAgent";
import { logStep } from "../sandboxes/logStep";

export interface FeedbackAssessment {
hasActionableFeedback: boolean;
feedbackSummary: string;
implementation: string;
}

/**
* Uses Claude Code in a sandbox to assess PR review feedback and determine
* whether any changes should be implemented by the coding agent.
*
* Ignores automated bot comments and approvals without substantive feedback.
* Returns a structured assessment with what (if anything) should be changed.
*/
export async function assessPRFeedback(
sandbox: Sandbox,
repo: string,
featureDescription: string,
feedback: PRFeedback,
): Promise<FeedbackAssessment> {
const noFeedback: FeedbackAssessment = {
hasActionableFeedback: false,
feedbackSummary: "No feedback",
implementation: "",
};

if (feedback.reviews.length === 0 && feedback.comments.length === 0) {
return noFeedback;
}

const reviewsText = feedback.reviews
.map((r) => `Review by ${r.author} (${r.state}): ${r.body}`)
.join("\n");

const commentsText = feedback.comments
.map((c) => `Comment by ${c.author} on ${c.path}: ${c.body}`)
.join("\n");

const feedbackText = [reviewsText, commentsText].filter(Boolean).join("\n\n");

const message = [
`You are reviewing PR feedback for the repo "${repo}".`,
``,
`Feature that was implemented: ${featureDescription.slice(0, 500)}`,
``,
`PR feedback received:`,
feedbackText,
``,
`Determine if there is actionable feedback that the coding agent should implement.`,
`Ignore: automated bot comments, approval messages, "LGTM", CI failure notices.`,
`Focus on: code quality issues, bugs, missing functionality, style violations.`,
``,
`Respond with JSON only (no markdown):`,
`{"hasActionableFeedback": boolean, "feedbackSummary": "brief summary", "implementation": "specific changes to make, or empty string"}`,
].join("\n");

try {
const result = await runClaudeCodeAgent(sandbox, {
label: `Assess PR feedback for ${repo}`,
message,
});

if (result.exitCode !== 0) {
logStep("Claude Code failed to assess PR feedback", false, {
exitCode: result.exitCode,
stderr: result.stderr.slice(-500),
});
return noFeedback;
}

const text = result.stdout.trim();

try {
const parsed = JSON.parse(text) as FeedbackAssessment;
logStep("PR feedback assessment complete", false, {
repo,
hasActionableFeedback: parsed.hasActionableFeedback,
summary: parsed.feedbackSummary,
});
return parsed;
} catch {
logStep("Failed to parse feedback assessment JSON", false, { text: text.slice(0, 200) });
return noFeedback;
}
} catch (error) {
logStep("Failed to assess PR feedback", false, { error: String(error) });
return noFeedback;
}
}
78 changes: 78 additions & 0 deletions src/ai/generateFeaturePrompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { Sandbox } from "@vercel/sandbox";
import type { SubmoduleCommit } from "../github/fetchRecentSubmoduleCommits";
import { runClaudeCodeAgent } from "../sandboxes/runClaudeCodeAgent";
import { logStep } from "../sandboxes/logStep";
import { getFallbackPrompt } from "./getFallbackPrompt";

const SYSTEM_CONTEXT = `You are a senior software engineer on the Recoupable platform — a music industry management tool for record labels and artist managers.

The platform has these main components:
- chat: Next.js frontend where music managers chat with their AI agent
- api: Backend API (Next.js) with AI/MCP tools, Supabase DB, Slack bot integration
- tasks: Trigger.dev background jobs (pulse emails, content creation, coding agent)
- admin: Internal admin dashboard (Next.js)
- cli: Command-line interface for power users
- docs: API documentation (Mintlify)

Your task: analyze recent commits and propose the single most valuable small feature to implement next.

Rules:
- Pick something that builds naturally on recent work
- Keep it focused — a single, shippable improvement
- Favor real user value (music managers need to manage artists, track metrics, send communications)
- DO NOT suggest refactors, tests, or documentation updates

Respond with ONLY an implementation prompt for an AI coding agent. The prompt should:
- Say exactly what to build and in which submodule(s)
- Reference specific files/routes/components when relevant
- Include clear acceptance criteria
- NOT ask for planning or approval — just direct the agent to implement it`;

/**
* Uses Claude Code in a sandbox to generate an actionable feature implementation prompt
* based on the recent commit history across monorepo submodules.
*
* Falls back to a generic improvement prompt if the sandbox call fails.
*/
export async function generateFeaturePrompt(
sandbox: Sandbox,
recentCommits: SubmoduleCommit[],
): Promise<string> {
const commitsContext = recentCommits
.map(
({ submodule, commits }) =>
`### ${submodule}\n${commits.map((c) => `- ${c.sha} ${c.message} (${c.date.slice(0, 10)})`).join("\n")}`,
)
.join("\n\n");

const message = `${SYSTEM_CONTEXT}

Here are the most recent commits across the Recoupable monorepo:

${commitsContext}

Based on this recent work, write a specific implementation prompt for an AI coding agent to implement the next most valuable feature.`;

try {
const result = await runClaudeCodeAgent(sandbox, {
label: "Generate feature prompt",
message,
});

const text = result.stdout.trim();

if (result.exitCode !== 0 || !text) {
logStep("Claude Code failed to generate feature prompt", false, {
exitCode: result.exitCode,
stderr: result.stderr.slice(-500),
});
return getFallbackPrompt();
}

logStep("Generated Agent Day feature prompt", false, { preview: text.slice(0, 200) });
return text;
} catch (error) {
logStep("Failed to generate feature prompt", false, { error: String(error) });
return getFallbackPrompt();
}
}
16 changes: 16 additions & 0 deletions src/ai/getFallbackPrompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Returns a generic feature implementation prompt used as a fallback
* when Claude Code fails to generate a specific one from recent commits.
*/
export function getFallbackPrompt(): string {
return [
"Read PROGRESS_USAGE.md and PROGRESS.md in the mono repo codebase first.",
"",
"Review the last 10 commits across the api, chat, admin, and tasks submodules.",
"Identify the single most impactful small improvement that builds on recent work",
"— a bug fix, a missing endpoint, a UI polish, or a small new feature.",
"",
"Implement it end-to-end (API route + frontend if needed), write any relevant tests,",
"then update PROGRESS.md with what you built.",
].join("\n");
}
Loading
Loading