DEV Community

Cover image for Build a personal PR-reviewer with Gaia, Langchain, and Next.js.
Tobiloba Adedeji for Gaia

Posted on • Edited on

Build a personal PR-reviewer with Gaia, Langchain, and Next.js.

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:

  1. The agent has domain knowledge on a few languages and can understand them well enough with a low chance of hallucinating.
  2. You have given the agent a well-written prompt of what you want it to do.
  3. The agent has access to fetch and write data to your remote repository hosting option (GitHub, GitLab, or Bitbucket).
  4. 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

  1. Access to Deepseek on Gaia
  2. Gaia API key (make sure to enable Developer free trial)
  3. Access to https://api.github.com/
  4. GitHub API key for use with the requests
  5. LangChain
  6. 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.

Preview of PR reviewer agent

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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",
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Finally, run the development server

npm run dev
Enter fullscreen mode Exit fullscreen mode

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!

PR review demo

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)

Collapse
 
oreoluwa_balogun profile image
Oreoluwa Balogun

Great one 👏👏
Well done Toby

Collapse
 
tobysolutions profile image
Tobiloba Adedeji Gaia

Thanks Oreo!!