Building AI-Powered Code Review Assistants with LLM APIs
Build a code review assistant that hooks into CI, uses LLM APIs to scan pull requests for bugs, style and security issues, and comments on the PR.

Beyond Linters: Semantic Code Review
Linters catch syntax violations and formatting issues. But they cannot tell you that a function silently swallows errors, that a database query will N+1 under load, or that an API endpoint lacks rate limiting. LLMs can reason about code semantically, making them powerful assistants for the review patterns that static analysis misses.
Extracting PR Diffs for Analysis
The first step is getting the pull request diff in a format the LLM can process. Git diffs contain noise—whitespace changes, renames, generated files. Filter these before sending tokens to the model.
interface PRFile {
filename: string;
status: "added" | "modified" | "removed" | "renamed";
patch: string;
additions: number;
deletions: number;
}
async function getFilteredPRFiles(
owner: string,
repo: string,
prNumber: number
): Promise<PRFile[]> {
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const { data: files } = await octokit.pulls.listFiles({
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
const ignoredPatterns = [
/\.lock$/,
/package-lock\.json$/,
/\.min\.(js|css)$/,
/dist\//,
/\.generated\./,
/\.snap$/,
];
return files
.filter((file) => !ignoredPatterns.some((p) => p.test(file.filename)))
.filter((file) => file.patch) // Has actual diff content
.map((file) => ({
filename: file.filename,
status: file.status as PRFile["status"],
patch: file.patch!,
additions: file.additions,
deletions: file.deletions,
}));
}Structuring the LLM Prompt
The prompt design determines review quality. Give the model a clear role, specific review criteria, and a structured output format. Avoid asking it to "review everything"—instead, focus on categories of issues.
// ❌ Vague prompt — produces generic, unhelpful reviews
const badPrompt = `Review this code: ${diff}`;
// ✅ Structured prompt with specific criteria and output format
function buildReviewPrompt(file: PRFile, context: string): string {
return `You are a senior software engineer reviewing a pull request.
Analyze the following code diff and identify specific issues.
## Review Criteria
1. **Bugs**: Logic errors, off-by-one, null/undefined risks, race conditions
2. **Security**: Injection vulnerabilities, missing auth checks, exposed secrets
3. **Performance**: N+1 queries, unnecessary re-renders, missing indexes
4. **Error Handling**: Swallowed errors, missing try/catch, unclear error messages
## File Context
Filename: ${file.filename}
Change type: ${file.status}
${context ? `Related context:\n${context}` : ""}
## Diff
\`\`\`
${file.patch}
\`\`\`
## Output Format
Respond with a JSON array of issues found. If no issues, return an empty array.
Each issue must include:
- line: the line number in the new file
- severity: "error" | "warning" | "suggestion"
- category: one of the review criteria above
- message: specific, actionable feedback (1-2 sentences)
- suggestion: optional code fix
Return ONLY the JSON array, no other text.`;
}Calling the LLM API with Structured Output
Parse the LLM response into typed review comments. Handle malformed responses gracefully—LLMs do not always follow output formats perfectly.
interface ReviewComment {
line: number;
severity: "error" | "warning" | "suggestion";
category: string;
message: string;
suggestion?: string;
}
async function analyzeFile(
file: PRFile,
context: string
): Promise<ReviewComment[]> {
const prompt = buildReviewPrompt(file, context);
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: "gpt-4o",
messages: [{ role: "user", content: prompt }],
temperature: 0.1, // Low temperature for consistent analysis
response_format: { type: "json_object" },
}),
});
const data = await response.json();
const content = data.choices[0]?.message?.content;
if (!content) return [];
try {
const parsed = JSON.parse(content);
const comments = Array.isArray(parsed) ? parsed : parsed.issues ?? [];
return comments.filter(isValidComment);
} catch {
console.error(`Failed to parse LLM response for ${file.filename}`);
return [];
}
}
function isValidComment(comment: unknown): comment is ReviewComment {
if (typeof comment !== "object" || comment === null) return false;
const c = comment as Record<string, unknown>;
return (
typeof c.line === "number" &&
typeof c.message === "string" &&
["error", "warning", "suggestion"].includes(c.severity as string)
);
}Posting Comments on the Pull Request
Map LLM review comments back to specific lines in the PR diff. GitHub's review API requires the position within the diff, not the absolute line number, so you need to translate.
async function postReviewComments(
owner: string,
repo: string,
prNumber: number,
commitSha: string,
fileComments: Map<string, ReviewComment[]>
): Promise<void> {
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const comments: Array<{
path: string;
position?: number;
line: number;
side: "RIGHT";
body: string;
}> = [];
for (const [filename, fileReviewComments] of fileComments) {
for (const comment of fileReviewComments) {
const icon =
comment.severity === "error" ? "🔴" :
comment.severity === "warning" ? "🟡" : "🔵";
let body = `${icon} **${comment.category}** (${comment.severity})\n\n${comment.message}`;
if (comment.suggestion) {
body += `\n\n\`\`\`suggestion\n${comment.suggestion}\n\`\`\``;
}
comments.push({
path: filename,
line: comment.line,
side: "RIGHT",
body,
});
}
}
if (comments.length === 0) {
await octokit.pulls.createReview({
owner,
repo,
pull_number: prNumber,
commit_id: commitSha,
body: "✅ AI review found no issues in the changed files.",
event: "COMMENT",
});
return;
}
await octokit.pulls.createReview({
owner,
repo,
pull_number: prNumber,
commit_id: commitSha,
body: `🤖 AI Code Review — Found ${comments.length} item(s) to review.`,
event: "COMMENT",
comments,
});
}CI Integration with GitHub Actions
Run the review assistant automatically on every pull request. Set token limits and cost controls to prevent runaway API bills.
# .github/workflows/ai-review.yml
name: AI Code Review
on:
pull_request:
types: [opened, synchronize]
jobs:
review:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Run AI Review
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
node scripts/ai-review.mjs \
--owner=${{ github.repository_owner }} \
--repo=${{ github.event.repository.name }} \
--pr=${{ github.event.pull_request.number }} \
--sha=${{ github.event.pull_request.head.sha }} \
--max-files=20 \
--max-tokens=50000Key Takeaways
LLM-powered code review assistants complement humans—they catch semantic issues that linters miss while humans focus on architecture and design decisions. Filter diffs to exclude generated files and lock files before sending to the API. Structure prompts with specific review criteria and a strict output format to get actionable, parseable comments.
Validate LLM responses defensively—models do not always produce valid JSON or follow instructions perfectly. Post comments directly on PR lines using the review API so feedback appears in context. Set cost controls through file limits and token budgets. Run the assistant in CI for consistency, and keep the model temperature low for reproducible analysis. The assistant should surface issues for human judgment, never approve or merge autonomously.


