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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
dist
.vercel
.env
.env.local
*.local
200 changes: 200 additions & 0 deletions api/analyze.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import handler from "./analyze";
import { makeReq, makeRes, stubFetchOnce } from "../tests/helpers";
import type { VercelRequest, VercelResponse } from "@vercel/node";

beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
delete process.env.ANTHROPIC_API_KEY;
});

describe("POST /api/analyze", () => {
it("rejects non-POST methods with 405", async () => {
const req = makeReq({ method: "GET" });
const res = makeRes();
await handler(req as unknown as VercelRequest, res as unknown as VercelResponse);

expect(res.statusCode).toBe(405);
expect(res.jsonBody).toEqual({ error: "Method not allowed" });
});

it("returns 501 when ANTHROPIC_API_KEY is not configured", async () => {
delete process.env.ANTHROPIC_API_KEY;

const req = makeReq({ method: "POST", body: { files: [] } });
const res = makeRes();
await handler(req as unknown as VercelRequest, res as unknown as VercelResponse);

expect(res.statusCode).toBe(501);
expect(res.jsonBody).toEqual({ error: "AI analysis not configured" });
});

it("rejects requests with no auth token (401)", async () => {
const req = makeReq({ method: "POST", body: { files: [] } });
const res = makeRes();
await handler(req as unknown as VercelRequest, res as unknown as VercelResponse);

expect(res.statusCode).toBe(401);
expect(res.jsonBody).toEqual({ error: "Missing auth token" });
});

it("rejects requests whose token GitHub rejects (401)", async () => {
// GitHub /user returns non-ok for the provided token.
const fetchMock = stubFetchOnce([{ ok: false, status: 401 }]);

const req = makeReq({
method: "POST",
headers: { authorization: "Bearer bogus" },
body: { files: [] },
});
const res = makeRes();
await handler(req as unknown as VercelRequest, res as unknown as VercelResponse);

expect(res.statusCode).toBe(401);
expect(res.jsonBody).toEqual({ error: "Invalid auth token" });
// Only the GitHub validation request should have been made — never call
// the paid Anthropic API for an unauthenticated caller.
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe("https://api.github.com/user");
});

it("rejects requests with no `files` array (400) after auth succeeds", async () => {
stubFetchOnce([{ ok: true, status: 200 }]); // GitHub /user OK

const req = makeReq({
method: "POST",
headers: { authorization: "Bearer good-token" },
body: {},
});
const res = makeRes();
await handler(req as unknown as VercelRequest, res as unknown as VercelResponse);

expect(res.statusCode).toBe(400);
expect(res.jsonBody).toEqual({ error: "Missing files" });
});

it("accepts the token from the request body as a fallback to the Authorization header", async () => {
const fetchMock = stubFetchOnce([{ ok: true, status: 200 }]);

const req = makeReq({
method: "POST",
body: { token: "body-token", files: "not-an-array" },
});
const res = makeRes();
await handler(req as unknown as VercelRequest, res as unknown as VercelResponse);

// Auth passed (we got past 401), we just fail on `files` validation.
expect(res.statusCode).toBe(400);
expect(fetchMock).toHaveBeenCalledTimes(1);
const authUsed = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record<string, string>;
expect(authUsed.Authorization).toBe("Bearer body-token");
});

it("returns 502 when the Anthropic API returns a non-ok response", async () => {
stubFetchOnce([
{ ok: true, status: 200 }, // GitHub /user
{ ok: false, status: 500, text: "anthropic boom" }, // Anthropic
]);

const req = makeReq({
method: "POST",
headers: { authorization: "Bearer good-token" },
body: { files: [{ filename: "a.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }] },
});
const res = makeRes();
await handler(req as unknown as VercelRequest, res as unknown as VercelResponse);

expect(res.statusCode).toBe(502);
expect(res.jsonBody).toEqual({ error: "AI API call failed" });
});

it("returns 502 when the Anthropic response body is not valid JSON", async () => {
stubFetchOnce([
{ ok: true, status: 200 },
{ ok: true, status: 200, json: { content: [{ text: "this is not json" }] } },
]);

const req = makeReq({
method: "POST",
headers: { authorization: "Bearer good-token" },
body: { files: [{ filename: "a.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }] },
});
const res = makeRes();
await handler(req as unknown as VercelRequest, res as unknown as VercelResponse);

expect(res.statusCode).toBe(502);
expect(res.jsonBody).toEqual({ error: "Failed to parse AI response" });
// Error must be logged, not swallowed — this was the original reviewer concern.
expect(console.error).toHaveBeenCalledWith(
"[analyze] failed to parse AI response:",
expect.any(Error),
"raw:",
expect.any(String),
);
});

it("returns structured areas on success", async () => {
const areas = [
{ title: "Auth flow", description: "OAuth callback hardening", files: ["api/auth/callback.ts"] },
{ title: "AI endpoint", description: "Adds auth + budget cap", files: ["api/analyze.ts"] },
];
stubFetchOnce([
{ ok: true, status: 200 }, // GitHub
{ ok: true, status: 200, json: { content: [{ text: JSON.stringify(areas) }] } },
]);

const req = makeReq({
method: "POST",
headers: { authorization: "Bearer good-token" },
body: {
files: [
{ filename: "api/auth/callback.ts", status: "modified", additions: 10, deletions: 2, patch: "@@" },
{ filename: "api/analyze.ts", status: "modified", additions: 20, deletions: 1, patch: "@@" },
],
},
});
const res = makeRes();
await handler(req as unknown as VercelRequest, res as unknown as VercelResponse);

expect(res.statusCode).toBe(200);
expect(res.jsonBody).toEqual({
areas: [
{ id: "area-0", title: "Auth flow", description: "OAuth callback hardening", files: ["api/auth/callback.ts"], checked: false },
{ id: "area-1", title: "AI endpoint", description: "Adds auth + budget cap", files: ["api/analyze.ts"], checked: false },
],
});
});

it("logs the original error when the handler throws unexpectedly", async () => {
// GitHub /user returns ok, but the second fetch (Anthropic) throws.
let call = 0;
vi.stubGlobal(
"fetch",
vi.fn(async () => {
call += 1;
if (call === 1) {
return { ok: true, status: 200, json: async () => ({}), text: async () => "" } as unknown as Response;
}
throw new Error("kaboom");
}),
);

const req = makeReq({
method: "POST",
headers: { authorization: "Bearer good-token" },
body: { files: [{ filename: "a.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }] },
});
const res = makeRes();
await handler(req as unknown as VercelRequest, res as unknown as VercelResponse);

expect(res.statusCode).toBe(500);
expect(res.jsonBody).toEqual({ error: "Analysis failed" });
expect(console.error).toHaveBeenCalledWith("[analyze] handler threw:", expect.any(Error));
});
});
135 changes: 135 additions & 0 deletions api/analyze.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { VercelRequest, VercelResponse } from "@vercel/node";

// Cap the total prompt size so a large PR cannot blow out the token budget
// (or cost) of the downstream AI call.
const PER_FILE_PATCH_LIMIT = 2000;
const TOTAL_DIFF_CHAR_BUDGET = 40_000;

interface DiffFile {
filename: string;
status: string;
additions: number;
deletions: number;
patch?: string;
}

async function validateGitHubToken(token: string): Promise<boolean> {
try {
const r = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
return r.ok;
} catch (e) {
console.error("[analyze] github token validation failed:", e);
return false;
}
}

function buildDiffSummary(files: DiffFile[]): string {
const parts: string[] = [];
let used = 0;
for (const f of files) {
const patch = f.patch ? f.patch.slice(0, PER_FILE_PATCH_LIMIT) : "(no patch)";
const entry = `File: ${f.filename} (${f.status}, +${f.additions} -${f.deletions})\n${patch}`;
if (used + entry.length > TOTAL_DIFF_CHAR_BUDGET) {
parts.push(`... (${files.length - parts.length} more files omitted to stay under token budget)`);
break;
}
parts.push(entry);
used += entry.length + 2; // +2 for the join separator
}
return parts.join("\n\n");
}

export default async function handler(req: VercelRequest, res: VercelResponse) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}

const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
return res.status(501).json({ error: "AI analysis not configured" });
}

// Require a GitHub token and verify it with GitHub so this endpoint
// cannot be abused by unauthenticated callers to burn our AI budget.
const authHeader = req.headers.authorization || "";
const bearer = authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7).trim() : "";
const bodyToken = typeof req.body?.token === "string" ? req.body.token : "";
const token = bearer || bodyToken;
if (!token) {
return res.status(401).json({ error: "Missing auth token" });
}
const ok = await validateGitHubToken(token);
if (!ok) {
return res.status(401).json({ error: "Invalid auth token" });
}

const { files } = req.body ?? {};
if (!files || !Array.isArray(files)) {
return res.status(400).json({ error: "Missing files" });
}

const diffSummary = buildDiffSummary(files as DiffFile[]);

try {
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-haiku-4-5-20251001",
max_tokens: 1024,
messages: [
{
role: "user",
content: `Analyze this PR diff and identify the affected areas that a reviewer should test. Group by feature area, route, or component.

Return JSON only (no markdown fences), in this format:
[{"title": "Area name", "description": "What changed and what to test", "files": ["file1.ts", "file2.ts"]}]

Diff:
${diffSummary}`,
},
],
}),
});

if (!response.ok) {
const body = await response.text().catch(() => "");
console.error("[analyze] anthropic non-ok:", response.status, body.slice(0, 300));
return res.status(502).json({ error: "AI API call failed" });
}

const data = (await response.json()) as { content?: Array<{ text?: string }> };
const text = data.content?.[0]?.text || "[]";

let areas: Array<{ title: string; description: string; files: string[] }>;
try {
areas = JSON.parse(text);
} catch (e) {
console.error("[analyze] failed to parse AI response:", e, "raw:", text.slice(0, 300));
return res.status(502).json({ error: "Failed to parse AI response" });
}

return res.json({
areas: areas.map((a, i) => ({
id: `area-${i}`,
title: a.title,
description: a.description,
files: a.files,
checked: false,
})),
});
} catch (e) {
console.error("[analyze] handler threw:", e);
return res.status(500).json({ error: "Analysis failed" });
}
}
Loading