The wave of AI agents nowadays has made some major software development procedures and processes more susceptible to automation.
Think Model Context Protocol (MCP), tool-calling, and agentic frameworks capable of bringing any software for use with any Large Language Model (LLM) via API calls in a backend server with some additional parsing logic for response cleaning and the presentation of external data in a way that most LLMs can use to give human-readable responses or execute on tasks.
In this blog, we dive into this cool project I built using Langchain, Next.js, and Gaia's free compute for AI inferencing and free LLMs.
You can check out my demo video below also:
Contents
- How a PR review agent should work
- Prerequisites and setup
- Adding the agent actions
- Adding the frontend
- Putting it all together
- Conclusion
How a PR review agent should work
Building a PR review agent, you would want to satisfy four few criteria in my opinion:
- The agent has domain knowledge on a few languages and can understand them well enough with a low chance of hallucinating.
- You have given the agent a well-written prompt of what you want it to do.
- The agent has access to fetch and write data to your remote repository hosting option (GitHub, GitLab, or Bitbucket).
- The agent should not expose confidential code to any external database.
You may have your opinion on a few other criteria that I am missing, but I am open to hearing them from you in the comments. We will also be using GitHub as our remote repository option here.
You can check out my finished project here in case you want to skip over the entire blog and just get straight to running the project:
https://github.com/tobySolutions/github-pr-review-agent
Prerequisites and setup
- Access to Deepseek on Gaia
- Gaia API key (make sure to enable Developer free trial)
- Access to
https://api.github.com/
- GitHub API key for use with the requests
- LangChain
- Next.js for the frontend UI.
So, the approach I used here was to make simple actions in the LangChain setup as functions that we can then call in the frontend. These LangChain actions use Gaia for inferencing.
Setup
Step 1: Create a New Next.js Project
First, create a new Next.js project with TypeScript, Tailwind CSS, and the App Router:
npx create-next-app@latest github-pr-review
When prompted, select the following options:
- Would you like to use TypeScript? Yes
- Would you like to use ESLint? Yes
- Would you like to use Tailwind CSS? Yes
- Would you like to use
src/
directory? No - Would you like to use App Router? Yes
- Would you like to customize the default import alias? Yes (Use
@/*
)
Step 2: Install Dependencies
Navigate to your project directory and install the required dependencies:
cd github-pr-review
# Install UI and form dependencies
npm install @radix-ui/react-checkbox @radix-ui/react-alert @hookform/resolvers zod react-hook-form class-variance-authority clsx tailwind-merge lucide-react
# Install markdown and syntax highlighting
npm install react-markdown react-syntax-highlighter
npm install --save-dev @types/react-syntax-highlighter
# Install AI and API dependencies
npm install @langchain/openai
npm install @tailwindcss/typography
Step 3: Set Up shadcn/ui Components
We'll use shadcn/ui for our UI components. First, initialize it in your project:
npx shadcn@latest init
When prompted, select:
- Would you like to use TypeScript? Yes
- Which style would you like to use? Default
- Which color would you like to use as base color? Slate
- Where is your global CSS file? app/globals.css
- Do you want to use CSS variables? Yes
- Where is your tailwind.config.js located? tailwind.config.ts
- Configure the import alias? @/
Now, install the required components:
npx shadcn@latest add button card form input checkbox alert
Step 4: Set Up Environment Variables
Create a .env.local
file in the root of your project:
GITHUB_TOKEN=your_github_personal_access_token
GAIA_API_KEY=your_ai_api_key
GAIA_API_BASE_URL=your_ai_api_base_url
GAIA_MODEL=DeepSeek-R1-Distill-Llama-8B-Q5_K_M
Replace the placeholder values with your actual API keys and URLs.
Step 5: Create the Project Structure
Create the following file structure:
github-pr-review/
├── app/
│ ├── actions.ts
│ ├── globals.css (already exists)
│ ├── layout.tsx (already exists)
│ └── page.tsx (already exists)
├── components/
│ ├── review-form.tsx
│ ├── theme-provider.tsx
│ └── ui/ (created by shadcn/ui)
├── lib/
│ └── utils.ts (created by shadcn/ui)
Adding the agent actions
Create app/actions.ts
with the following code:
"use server"
import { z } from "zod"
import { ChatOpenAI } from "@langchain/openai"
// Define Zod schema for the form input
const reviewInputSchema = z.object({
owner: z.string().min(1),
repo: z.string().min(1),
prNumber: z.number().int().positive(),
postComment: z.boolean().optional(),
})
// Define Zod schema for the state
const stateSchema = z.object({
owner: z.string(),
repo: z.string(),
prNumber: z.number(),
diff: z.string().optional(),
feedback: z.string().optional(),
})
type ReviewState = z.infer<typeof stateSchema>
// Fetch GitHub PR diff
async function fetchPullRequestDiff(state: ReviewState): Promise<ReviewState> {
const url = `https://api.github.com/repos/${state.owner}/${state.repo}/pulls/${state.prNumber}`
try {
const response = await fetch(url, {
headers: {
Accept: "application/vnd.github.v3.diff",
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
},
})
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`)
}
const diff = await response.text()
return { ...state, diff }
} catch (error) {
console.error("Error fetching PR diff:", error)
throw new Error(error instanceof Error ? error.message : "Failed to fetch the pull request diff")
}
}
// Analyze PR with AI
async function analyzeDiff(state: ReviewState): Promise<ReviewState> {
if (!state.diff) throw new Error("No diff to analyze")
try {
const prompt = `You are an AI code reviewer. Review the following diff:\n\n\`\`\`diff\n${state.diff}\n\`\`\`\n\nGive categorized feedback on Style, Security, Performance, and Design. Format your response in Markdown with proper headings, lists, and code blocks where appropriate.`
const llm = new ChatOpenAI({
model: process.env.GAIA_MODEL || "gpt-4o",
configuration: {
apiKey: process.env.GAIA_API_KEY!,
baseURL: process.env.GAIA_API_BASE_URL,
},
})
const res = await llm.invoke(prompt)
const content = typeof res === "string" ? res : (res as any)?.content || String(res)
return { ...state, feedback: content }
} catch (error) {
console.error("Error analyzing diff:", error)
throw new Error(error instanceof Error ? error.message : "Failed to analyze the pull request")
}
}
// Post review back to GitHub
async function postGithubComment(state: ReviewState): Promise<void> {
if (!state.feedback) return
const url = `https://api.github.com/repos/${state.owner}/${state.repo}/issues/${state.prNumber}/comments`
try {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github.v3+json",
},
body: JSON.stringify({ body: state.feedback }),
})
if (!response.ok) {
throw new Error(`Failed to post GitHub comment: ${response.statusText}`)
}
} catch (error) {
console.error("Error posting GitHub comment:", error)
throw new Error(error instanceof Error ? error.message : "Failed to post comment to GitHub")
}
}
// Main server action
export async function reviewPullRequest(input: z.infer<typeof reviewInputSchema>) {
try {
// Validate input
const validatedInput = reviewInputSchema.parse(input)
// Initialize state
const initialState: ReviewState = {
owner: validatedInput.owner,
repo: validatedInput.repo,
prNumber: validatedInput.prNumber,
}
// Process the PR review
const stateWithDiff = await fetchPullRequestDiff(initialState)
const reviewedState = await analyzeDiff(stateWithDiff)
// Post comment to GitHub if requested
if (validatedInput.postComment) {
await postGithubComment(reviewedState)
}
return {
feedback: reviewedState.feedback,
commentPosted: validatedInput.postComment || false,
}
} catch (error) {
console.error("PR review error:", error)
return {
error: error instanceof Error ? error.message : "An unexpected error occurred",
}
}
}
Adding the frontend
Create components/theme-provider.tsx
:
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import type { ThemeProviderProps } from "next-themes"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
Step 8: Create the Review Form Component
Create components/review-form.tsx
:
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Loader2, Github, Check } from 'lucide-react'
import ReactMarkdown from "react-markdown"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"
import type { CodeProps } from "react-markdown/lib/ast-to-react"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Checkbox } from "@/components/ui/checkbox"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { reviewPullRequest } from "@/app/actions"
const formSchema = z.object({
owner: z.string().min(1, "Repository owner is required"),
repo: z.string().min(1, "Repository name is required"),
prNumber: z.coerce.number().int("PR number must be an integer").positive("PR number must be positive"),
postComment: z.boolean().default(false),
})
export function ReviewForm() {
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
const [feedback, setFeedback] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [commentPosted, setCommentPosted] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
owner: "",
repo: "",
prNumber: undefined,
postComment: false,
},
})
async function onSubmit(values: z.infer<typeof formSchema>) {
setIsLoading(true)
setFeedback(null)
setError(null)
setCommentPosted(false)
try {
const result = await reviewPullRequest(values)
if (result.error) {
setError(result.error)
} else {
setFeedback(result.feedback || null)
setCommentPosted(!!result.commentPosted)
}
} catch (err) {
setError("An unexpected error occurred. Please try again.")
console.error(err)
} finally {
setIsLoading(false)
router.refresh()
}
}
return (
<div className="space-y-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="owner"
render={({ field }) => (
<FormItem>
<FormLabel>Repository Owner</FormLabel>
<FormControl>
<Input placeholder="e.g., vercel" {...field} />
</FormControl>
<FormDescription>The GitHub username or organization</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="repo"
render={({ field }) => (
<FormItem>
<FormLabel>Repository Name</FormLabel>
<FormControl>
<Input placeholder="e.g., next.js" {...field} />
</FormControl>
<FormDescription>The name of the repository</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="prNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Pull Request Number</FormLabel>
<FormControl>
<Input
type="number"
placeholder="e.g., 123"
{...field}
onChange={(e) => {
const value = e.target.value
field.onChange(value === "" ? undefined : Number(value))
}}
/>
</FormControl>
<FormDescription>The number of the pull request to review</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="postComment"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Post review as a GitHub comment</FormLabel>
<FormDescription>The AI review will be posted as a comment on the pull request</FormDescription>
</div>
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Analyzing PR...
</>
) : (
"Review Pull Request"
)}
</Button>
</form>
</Form>
{error && (
<Card className="border-red-300 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
<p className="text-red-800 dark:text-red-400">{error}</p>
</Card>
)}
{commentPosted && (
<Alert className="border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-900/20">
<Check className="h-4 w-4 text-green-600 dark:text-green-400" />
<AlertTitle className="text-green-800 dark:text-green-400">Comment Posted</AlertTitle>
<AlertDescription className="text-green-700 dark:text-green-500">
The review has been successfully posted as a comment on the GitHub pull request.
</AlertDescription>
</Alert>
)}
{feedback && (
<div className="rounded-lg border bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white">AI Review Feedback</h2>
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400">
<Github className="mr-1 h-4 w-4" />
<span>
{form.getValues().owner}/{form.getValues().repo}#{form.getValues().prNumber}
</span>
</div>
</div>
<div className="prose max-w-none dark:prose-invert">
<ReactMarkdown
components={{
code(props) {
const { children, className, node, ...rest } = props as CodeProps
const match = /language-(\w+)/.exec(className || "")
return !props.inline && match ? (
<SyntaxHighlighter {...rest} style={vscDarkPlus} language={match[1]} PreTag="div">
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code {...rest} className={className}>
{children}
</code>
)
},
}}
>
{feedback}
</ReactMarkdown>
</div>
</div>
)}
</div>
)
}
Then you update app/page.tsx
:
import { ReviewForm } from "@/components/review-form"
import { GithubIcon } from 'lucide-react'
export default function Home() {
return (
<main className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
<div className="container mx-auto px-4 py-16">
<div className="mx-auto max-w-3xl">
<div className="mb-8 flex items-center justify-center gap-3">
<GithubIcon className="h-8 w-8 text-gray-800 dark:text-white" />
<h1 className="text-center text-3xl font-bold text-gray-800 dark:text-white">GitHub PR Review AI</h1>
</div>
<div className="rounded-lg border bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<p className="mb-6 text-center text-gray-600 dark:text-gray-400">
Enter your GitHub repository details to get an AI-powered code review for your pull request.
</p>
<ReviewForm />
</div>
</div>
</div>
</main>
)
}
Update the Root Layout
Update app/layout.tsx
to include the theme provider:
import type { Metadata } from "next"
import { Inter } from 'next/font/google'
import "./globals.css"
import { ThemeProvider } from "@/components/theme-provider"
const inter = Inter({ subsets: ["latin"] })
export const metadata: Metadata = {
title: "GitHub PR Review AI",
description: "AI-powered code reviews for your GitHub pull requests",
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
{children}
</ThemeProvider>
</body>
</html>
)
}
Then update your tailwind.config.ts
:
import type { Config } from "tailwindcss"
const config = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
} satisfies Config
export default config
Finally, run the development server
npm run dev
Visit http://localhost:3000
in your browser to see the application running.
Putting it all together
The above is an agent that automatically analyzes pull request diffs using AI models to provide instant code feedback. It fetches PR changes from GitHub, generates comprehensive reviews categorized by style, security, performance, and design considerations.
The app can optionally post these AI-generated reviews directly as comments on GitHub PRs, helping developers identify issues before human reviewers even look at the code.
Cet finit!
Conclusion
This project is a glimpse into how AI agents are transforming developer workflows—from static tools to intelligent collaborators. By integrating LangChain, Gaia's free LLMs, and GitHub’s API within a Next.js frontend, we created a practical, automated PR review agent that’s both easy to deploy and powerful enough to offer real value.
As AI tooling matures, expect these kinds of agentic workflows to become the norm—handling repetitive review tasks, improving code quality, and freeing up time for more creative engineering work.
Give it a try, tweak it to your needs.
Top comments (2)
Great one 👏👏
Well done Toby
Thanks Oreo!!