Skip to content

About

No description, website, or topics provided.

Resources

Stars

10 stars

Watchers

1 watching

Forks

Latest commit

ย 

History

21 Commits

Folders and files

NameName
Last commit message
Last commit date
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

๐Ÿ›ก๏ธ GitHub Actions AI Agent for Automated Code Security Review

Pull Requests Page

An autonomous AI-powered security agent that automatically reviews every Pull Request for vulnerabilities, posts intelligent feedback directly on GitHub, and sends real-time Slack alerts โ€” all without any human intervention.

Python LangGraph GitHub Actions OpenRouter License: MIT


๐Ÿ“ธ Project Demo

1. Pull Requests Triggered for Security Testing

Three test branches were created to validate high-risk and low-risk code detection:

Pull Requests Page


2. GitHub Actions โ€” Workflows Running in Real-Time

The AI Code Review and PR Merged Notification workflows trigger automatically on every PR event:

Workflow Runs In Progress


3. Successful HIGH RISK TEST โ€” Workflow Run Details

The agent completed the feat: add authentication module (HIGH RISK TEST) run in 3 minutes 14 seconds with a Success status:

High Risk Workflow Run


4. Slack Alert โ€” CRITICAL Risk Score 95/100

When the risk score exceeds 50, the agent fires a Slack alert with full vulnerability details, OWASP mappings, and a direct PR link:

Slack Critical Alert


5. All Workflow Runs โ€” End-to-End Success

All 9 workflow runs completed successfully across HIGH RISK, LOW RISK, and merge notification scenarios:

All Workflow Runs


๐Ÿ“– Table of Contents


๐ŸŽฏ What This Project Does

I built an agentic DevOps pipeline that brings AI-powered security directly into the GitHub Pull Request workflow.

The problem it solves: Most teams either skip security reviews because they're slow and expensive, or they rely on basic static analysis tools that only do pattern matching. This agent uses a 550-billion-parameter AI model that actually understands code context and semantics โ€” not just keyword matching.

Every time a developer opens a Pull Request:

  • The agent wakes up automatically inside GitHub Actions
  • Reads the exact lines of code that changed (the diff)
  • Sends those changes to a powerful AI model for analysis
  • Gets back a structured security report with a risk score
  • Posts the full report as a comment directly on the Pull Request
  • If the risk is HIGH or CRITICAL, instantly alerts the entire team on Slack
  • When the PR is merged, sends a merge notification to Slack

Zero human intervention required. Zero infrastructure to manage. Zero cost (free tier).


๐Ÿ”„ How It All Works โ€” The Full Flow

Here is the complete end-to-end journey from a developer pushing code to the team getting a Slack alert:

STEP 1 โ”€โ”€โ”€ Developer pushes code and opens a Pull Request on GitHub
                              โ”‚
STEP 2 โ”€โ”€โ”€ GitHub detects the PR event (opened / updated / reopened)
                              โ”‚
STEP 3 โ”€โ”€โ”€ GitHub Actions reads .github/workflows/ai_review.yml
                              โ”‚
STEP 4 โ”€โ”€โ”€ GitHub spins up a fresh Ubuntu virtual machine (runner)
                              โ”‚
STEP 5 โ”€โ”€โ”€ Runner installs Python 3.11 and all dependencies
                              โ”‚
STEP 6 โ”€โ”€โ”€ Runner executes: python -m agent.graph
                              โ”‚
STEP 7 โ”€โ”€โ”€ LangGraph agent starts โ€” initializes the AgentState
                              โ”‚
STEP 8 โ”€โ”€โ”€ NODE: fetch_diff
           โ””โ”€โ”€ Calls GitHub API with PR number + repo details
           โ””โ”€โ”€ Gets back the raw git diff (all changed lines)
                              โ”‚
STEP 9 โ”€โ”€โ”€ NODE: validate_diff
           โ””โ”€โ”€ Counts tokens in the diff
           โ””โ”€โ”€ If > 12,000 tokens โ†’ truncates to fit AI context window
           โ””โ”€โ”€ If diff is empty โ†’ marks as skippable
                              โ”‚
STEP 10 โ”€โ”€ NODE: analyze_code
           โ””โ”€โ”€ Builds a security-focused system prompt
           โ””โ”€โ”€ Sends diff to NVIDIA Nemotron 550B via OpenRouter API
           โ””โ”€โ”€ If API key hits rate limit โ†’ automatically switches to key 2 or key 3
           โ””โ”€โ”€ Receives structured JSON response from the AI
                              โ”‚
STEP 11 โ”€โ”€ NODE: score_evaluation
           โ””โ”€โ”€ Parses the JSON: risk_score, issues[], recommendations[]
           โ””โ”€โ”€ Maps each issue to its OWASP Top 10 category
           โ””โ”€โ”€ Decides routing: risk >= 50 โ†’ go to notify_slack
                              โ”‚
STEP 12 โ”€โ”€ NODE: notify_slack (only if risk score >= 50)
           โ””โ”€โ”€ Builds a Block Kit formatted Slack message
           โ””โ”€โ”€ Color codes it: RED (critical), ORANGE (high)
           โ””โ”€โ”€ Includes top 3 issues, OWASP tags, file locations
           โ””โ”€โ”€ Posts to Slack webhook URL
                              โ”‚
STEP 13 โ”€โ”€ NODE: finalize
           โ””โ”€โ”€ Formats the full review as a Markdown comment
           โ””โ”€โ”€ Posts it on the GitHub Pull Request via GitHub API
           โ””โ”€โ”€ Comment includes: score, all issues, recommendations
                              โ”‚
STEP 14 โ”€โ”€ Developer sees the AI review comment on their PR
           Team sees the Slack alert (if high risk)
           PR is either approved or blocked based on the score
                              โ”‚
STEP 15 โ”€โ”€ (Separate workflow) When PR is merged:
           โ””โ”€โ”€ pr_merged_notification.yml triggers
           โ””โ”€โ”€ Sends merge notification to Slack

๐Ÿ“ Project Structure โ€” Every File Explained

github-ai-reviewer/
โ”‚
โ”œโ”€โ”€ .github/
โ”‚   โ””โ”€โ”€ workflows/
โ”‚       โ”œโ”€โ”€ ai_review.yml                  โ† Triggers on PR open/update
โ”‚       โ””โ”€โ”€ pr_merged_notification.yml     โ† Triggers on PR merge
โ”‚
โ”œโ”€โ”€ agent/
โ”‚   โ”œโ”€โ”€ __init__.py                        โ† Makes agent a Python package
โ”‚   โ”œโ”€โ”€ graph.py                           โ† LangGraph state machine (the brain)
โ”‚   โ”œโ”€โ”€ nodes.py                           โ† Each step of the review process
โ”‚   โ”œโ”€โ”€ state.py                           โ† Shared data structure (AgentState)
โ”‚   โ””โ”€โ”€ prompts.py                         โ† Instructions sent to the AI model
โ”‚
โ”œโ”€โ”€ core/
โ”‚   โ”œโ”€โ”€ __init__.py                        โ† Makes core a Python package
โ”‚   โ”œโ”€โ”€ github_client.py                   โ† Talks to GitHub API
โ”‚   โ”œโ”€โ”€ llm_client.py                      โ† Talks to OpenRouter / AI model
โ”‚   โ””โ”€โ”€ slack_client.py                    โ† Sends Slack messages
โ”‚
โ”œโ”€โ”€ config/
โ”‚   โ”œโ”€โ”€ __init__.py                        โ† Makes config a Python package
โ”‚   โ””โ”€โ”€ settings.py                        โ† Loads all environment variables
โ”‚
โ”œโ”€โ”€ tests/
โ”‚   โ”œโ”€โ”€ test_github_client.py              โ† Unit tests for GitHub client
โ”‚   โ”œโ”€โ”€ test_llm_client.py                 โ† Unit tests for LLM client
โ”‚   โ”œโ”€โ”€ test_slack_client.py               โ† Unit tests for Slack client
โ”‚   โ”œโ”€โ”€ test_nodes.py                      โ† Unit tests for agent nodes
โ”‚   โ””โ”€โ”€ test_graph.py                      โ† Integration tests for full workflow
โ”‚
โ”œโ”€โ”€ scripts/
โ”‚   โ””โ”€โ”€ test_local.py                      โ† Run the agent locally without GitHub
โ”‚
โ”œโ”€โ”€ .env.example                           โ† Template showing required env variables
โ”œโ”€โ”€ requirements.txt                       โ† All Python packages needed
โ””โ”€โ”€ README.md                              โ† This file

โšก Part 1 โ€” GitHub Actions Workflows (The Trigger System)

GitHub Actions is the automation platform built into every GitHub repository. It listens for events (like a PR being opened) and runs your code automatically in a cloud virtual machine.

I created two workflow files:


Workflow 1: ai_review.yml โ€” The Main Security Review

name: AI Code Review

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  pull-requests: write   # Allows the agent to post comments on PRs
  contents: read         # Allows reading the code

jobs:
  review:
    runs-on: ubuntu-latest
    steps:

      # Step 1: Download the repo code onto the runner machine
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0   # Gets full git history so we can compute diffs

      # Step 2: Install Python 3.11
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      # Step 3: Install all required libraries from requirements.txt
      - name: Install dependencies
        run: pip install -r requirements.txt

      # Step 4: Run the AI agent, injecting all secrets as environment variables
      - name: Run AI Review Agent
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}         # Auto-provided by GitHub
          OPENROUTER_API_KEY_1: ${{ secrets.OPENROUTER_API_KEY_1 }}
          OPENROUTER_API_KEY_2: ${{ secrets.OPENROUTER_API_KEY_2 }}
          OPENROUTER_API_KEY_3: ${{ secrets.OPENROUTER_API_KEY_3 }}
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          REPO_OWNER: ${{ github.repository_owner }}
          REPO_NAME: ${{ github.event.repository.name }}
        run: python -m agent.graph

What each part means:

  • on: pull_request: types: [opened, synchronize, reopened] โ€” This is the trigger. opened means a brand new PR. synchronize means someone pushed new commits to an existing PR. reopened means a previously closed PR was reopened.
  • permissions: pull-requests: write โ€” Without this, the agent cannot post comments on PRs. GitHub requires explicit permission declarations.
  • runs-on: ubuntu-latest โ€” GitHub spins up a fresh Ubuntu Linux virtual machine every time this runs. It's completely isolated and destroyed after the job finishes.
  • fetch-depth: 0 โ€” By default, checkout only gets the latest commit. Setting this to 0 gets the full history, which is needed to properly compute what changed in the PR.
  • ${{ secrets.GITHUB_TOKEN }} โ€” This is automatically created by GitHub for every workflow run. It has permissions to interact with the current repository.
  • ${{ github.event.pull_request.number }} โ€” GitHub automatically provides context variables about the event. This gives the PR number (e.g., 3) so the agent knows which PR to comment on.

Workflow 2: pr_merged_notification.yml โ€” The Merge Notification

name: PR Merged Notification

on:
  pull_request:
    types: [closed]

jobs:
  notify:
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - run: pip install -r requirements.txt
      - name: Send Merge Notification
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          PR_TITLE: ${{ github.event.pull_request.title }}
          PR_AUTHOR: ${{ github.event.pull_request.user.login }}
          REPO_NAME: ${{ github.event.repository.name }}
        run: python -m core.slack_client --merge-notify

Key detail: if: github.event.pull_request.merged == true

The closed event fires for both merged PRs AND closed-without-merge PRs. This condition ensures the notification only goes out when the PR was actually merged, not just closed.


๐Ÿง  Part 2 โ€” The LangGraph Agent (The Brain)

LangGraph is a framework for building AI agents as state machines. Instead of writing one giant function, I broke the review process into individual nodes (steps), and LangGraph handles routing between them.


agent/state.py โ€” The Shared Data Container

from typing import TypedDict, Optional, List

class AgentState(TypedDict):
    # Input
    pr_number: int
    repo_owner: str
    repo_name: str

    # After fetch_diff node
    diff: str
    diff_token_count: int

    # After analyze_code node
    raw_ai_response: str
    risk_score: int                    # 0 to 100
    security_issues: List[dict]        # List of vulnerabilities found
    recommendations: List[str]         # How to fix them

    # After score_evaluation node
    risk_level: str                    # "LOW", "MEDIUM", "HIGH", "CRITICAL"
    should_notify_slack: bool          # True if score >= 50

    # Control flags
    skip_analysis: bool                # True if diff is empty
    error_message: Optional[str]       # Holds any error that occurred

This TypedDict is the single source of truth that every node reads from and writes to. Think of it like a baton passed between runners in a relay race. Each node picks it up, adds its results, and passes it to the next node.


agent/prompts.py โ€” Instructions for the AI

This file contains the system prompt โ€” the instructions I give to the AI model telling it exactly how to behave:

SECURITY_REVIEW_SYSTEM_PROMPT = """
You are an expert security code reviewer with deep knowledge of:
- OWASP Top 10 vulnerabilities
- Common attack vectors (SQL injection, XSS, command injection, etc.)
- Secure coding best practices
- Cryptographic standards

You will receive a git diff (code changes from a Pull Request).
Your job is to analyze ONLY the changed lines (lines starting with +).

You MUST respond with a valid JSON object in exactly this format:
{
    "risk_score": <integer 0-100>,
    "risk_level": "<LOW|MEDIUM|HIGH|CRITICAL>",
    "summary": "<2-3 sentence overview>",
    "security_issues": [
        {
            "title": "<issue name>",
            "severity": "<CRITICAL|HIGH|MEDIUM|LOW>",
            "owasp_category": "<A0X:2021 โ€“ Category Name>",
            "location": "<filename>:<line_number>",
            "description": "<what the vulnerability is>",
            "recommendation": "<how to fix it>"
        }
    ],
    "merge_recommendation": "<APPROVE|REVIEW|BLOCK>"
}

Risk Score Guide:
- 0-30: No significant issues found
- 31-49: Minor issues, suggest review
- 50-69: Significant issues, fix before merging
- 70-100: Critical vulnerabilities, block merge immediately
"""

Why structured JSON output? Because I need to programmatically parse the AI's response. If I let it respond in free-form text, it would be extremely hard to extract the risk score and post it cleanly on GitHub. By forcing JSON, I can reliably parse response["risk_score"] every time.


agent/nodes.py โ€” Every Step of the Review

Each function here is one node in the LangGraph state machine. They all take AgentState as input and return an updated AgentState.

Node 1: fetch_diff

def fetch_diff(state: AgentState) -> AgentState:
    """
    Calls the GitHub API to get the code diff for this PR.
    The diff shows exactly what lines were added or removed.
    """
    github = GitHubClient()

    diff = github.get_pr_diff(
        owner=state["repo_owner"],
        repo=state["repo_name"],
        pr_number=state["pr_number"]
    )

    if not diff or len(diff.strip()) == 0:
        return {**state, "skip_analysis": True, "diff": ""}

    return {
        **state,
        "diff": diff,
        "skip_analysis": False
    }

What the GitHub API returns (example diff):

diff --git a/auth.py b/auth.py
+++ b/auth.py
@@ -20,6 +20,10 @@
+def authenticate_user(username, password):
+    query = f"SELECT * FROM users WHERE username='{username}'"
+    cursor.execute(query)  # โ† SQL injection vulnerability here
+    return cursor.fetchone()

The lines starting with + are newly added code. Lines starting with - are removed. The AI focuses on + lines because those are the new code being introduced.


Node 2: validate_diff

def validate_diff(state: AgentState) -> AgentState:
    """
    Checks the diff size to avoid exceeding the AI model's context window.
    Nemotron 550B has a context limit. If the diff is too large,
    we truncate it to the first 12,000 tokens.
    """
    if state["skip_analysis"]:
        return state

    # Rough token estimate: 1 token โ‰ˆ 4 characters
    token_count = len(state["diff"]) // 4

    if token_count > 12000:
        # Truncate the diff
        max_chars = 12000 * 4
        truncated_diff = state["diff"][:max_chars]
        truncated_diff += "\n\n[DIFF TRUNCATED โ€” showing first 48,000 characters]"
        return {**state, "diff": truncated_diff, "diff_token_count": 12000}

    return {**state, "diff_token_count": token_count}

Why this matters: Every AI model has a maximum number of tokens it can process in one request. If a developer changes 5,000 lines of code in one PR, the diff would be enormous. Without this check, the API call would fail with an error. By truncating, the agent still reviews as much as possible within the limit.


Node 3: analyze_code

def analyze_code(state: AgentState) -> AgentState:
    """
    Sends the diff to the AI model and gets back a security analysis.
    This is the core intelligence of the entire agent.
    """
    if state["skip_analysis"]:
        return {**state, "risk_score": 0, "security_issues": [], "risk_level": "LOW"}

    llm = LLMClient()

    user_message = f"""
    Please review this Pull Request diff for security vulnerabilities:

    ```diff
    {state["diff"]}
    ```

    Respond ONLY with valid JSON. No preamble, no explanation outside the JSON.
    """

    response = llm.complete(
        system_prompt=SECURITY_REVIEW_SYSTEM_PROMPT,
        user_message=user_message
    )

    # Parse the JSON response
    parsed = json.loads(response)

    return {
        **state,
        "raw_ai_response": response,
        "risk_score": parsed["risk_score"],
        "risk_level": parsed["risk_level"],
        "security_issues": parsed["security_issues"],
        "recommendations": [issue["recommendation"] for issue in parsed["security_issues"]]
    }

Node 4: score_evaluation

def score_evaluation(state: AgentState) -> AgentState:
    """
    Evaluates the risk score and decides whether to send a Slack alert.
    """
    risk_score = state["risk_score"]
    threshold = int(os.getenv("RISK_THRESHOLD", "50"))

    should_notify = risk_score >= threshold

    return {
        **state,
        "should_notify_slack": should_notify
    }

Simple but important โ€” this node makes the binary decision that routes the workflow either to Slack notification or straight to posting the GitHub comment.


Node 5: notify_slack

def notify_slack(state: AgentState) -> AgentState:
    """
    Sends a formatted Slack alert with the top security issues.
    Only runs if should_notify_slack is True.
    """
    slack = SlackClient()

    slack.send_security_alert(
        risk_score=state["risk_score"],
        risk_level=state["risk_level"],
        issues=state["security_issues"],
        pr_number=state["pr_number"],
        repo_name=state["repo_name"],
        repo_owner=state["repo_owner"]
    )

    return state

Node 6: finalize

def finalize(state: AgentState) -> AgentState:
    """
    Formats the complete review as Markdown and posts it as a
    comment directly on the GitHub Pull Request.
    This always runs โ€” even if the AI failed (posts an error message).
    """
    github = GitHubClient()

    comment = format_review_comment(
        risk_score=state["risk_score"],
        risk_level=state["risk_level"],
        issues=state["security_issues"],
        error=state.get("error_message")
    )

    github.post_pr_comment(
        owner=state["repo_owner"],
        repo=state["repo_name"],
        pr_number=state["pr_number"],
        comment=comment
    )

    return state

Critical design choice: finalize always runs, no matter what. Even if the AI call throws an exception, the agent catches it, stores the error in state["error_message"], and still posts a comment saying "Review failed โ€” please review manually." This ensures developers are never left wondering if the review happened.


agent/graph.py โ€” Connecting the Nodes

from langgraph.graph import StateGraph, END
from agent.state import AgentState
from agent.nodes import fetch_diff, validate_diff, analyze_code, score_evaluation, notify_slack, finalize

def build_graph():
    graph = StateGraph(AgentState)

    # Register all nodes
    graph.add_node("fetch_diff", fetch_diff)
    graph.add_node("validate_diff", validate_diff)
    graph.add_node("analyze_code", analyze_code)
    graph.add_node("score_evaluation", score_evaluation)
    graph.add_node("notify_slack", notify_slack)
    graph.add_node("finalize", finalize)

    # Define the linear flow
    graph.set_entry_point("fetch_diff")
    graph.add_edge("fetch_diff", "validate_diff")
    graph.add_edge("validate_diff", "analyze_code")
    graph.add_edge("analyze_code", "score_evaluation")

    # Conditional routing โ€” only go to Slack if risk is high
    graph.add_conditional_edges(
        "score_evaluation",
        lambda state: "notify_slack" if state["should_notify_slack"] else "finalize",
        {
            "notify_slack": "notify_slack",
            "finalize": "finalize"
        }
    )

    graph.add_edge("notify_slack", "finalize")
    graph.add_edge("finalize", END)

    return graph.compile()


if __name__ == "__main__":
    import os
    agent = build_graph()
    agent.invoke({
        "pr_number": int(os.getenv("PR_NUMBER")),
        "repo_owner": os.getenv("REPO_OWNER"),
        "repo_name": os.getenv("REPO_NAME"),
        "skip_analysis": False,
        "error_message": None
    })

Visual representation of the graph:

fetch_diff
    โ”‚
validate_diff
    โ”‚
analyze_code
    โ”‚
score_evaluation
    โ”‚
    โ”œโ”€โ”€ (risk >= 50) โ”€โ”€โ†’ notify_slack โ”€โ”€โ†’ finalize โ”€โ”€โ†’ END
    โ”‚
    โ””โ”€โ”€ (risk < 50)  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ†’ finalize โ”€โ”€โ†’ END

This is the power of LangGraph โ€” the routing logic is declarative and visual, not buried inside nested if-else chains.


๐Ÿ”ง Part 3 โ€” The Core Clients (The Hands)

These are the three modules that actually communicate with external services.


core/github_client.py โ€” Talking to GitHub

import requests
import os

class GitHubClient:
    def __init__(self):
        self.token = os.getenv("GITHUB_TOKEN")
        self.base_url = "https://api.github.com"
        self.headers = {
            "Authorization": f"Bearer {self.token}",
            "Accept": "application/vnd.github.v3.diff",   # Request diff format
            "X-GitHub-Api-Version": "2022-11-28"
        }

    def get_pr_diff(self, owner: str, repo: str, pr_number: int) -> str:
        """
        Fetches the raw git diff for a Pull Request.
        Example URL: GET /repos/Naveen15github/github-ai-reviewer/pulls/3
        """
        url = f"{self.base_url}/repos/{owner}/{repo}/pulls/{pr_number}"
        response = requests.get(url, headers=self.headers)
        response.raise_for_status()
        return response.text   # Returns the raw diff as text

    def post_pr_comment(self, owner: str, repo: str, pr_number: int, comment: str):
        """
        Posts a comment on a Pull Request.
        Example URL: POST /repos/Naveen15github/github-ai-reviewer/issues/3/comments
        """
        # GitHub uses "issues" endpoint for PR comments
        url = f"{self.base_url}/repos/{owner}/{repo}/issues/{pr_number}/comments"
        payload = {"body": comment}

        # Switch Accept header for posting comments (JSON, not diff)
        headers = {**self.headers, "Accept": "application/vnd.github.v3+json"}
        response = requests.post(url, json=payload, headers=headers)
        response.raise_for_status()
        return response.json()

Why issues endpoint for PR comments? In GitHub's API, Pull Requests are a superset of Issues. PR comments and Issue comments use the same endpoint. This is a GitHub API quirk that trips up many developers.


core/llm_client.py โ€” Talking to the AI Model

This is the most critical client. It implements the 3-key rotation system:

import requests
import json
import os
import time

class LLMClient:
    def __init__(self):
        # Load all three API keys โ€” keys 2 and 3 are optional backups
        self.api_keys = [
            k for k in [
                os.getenv("OPENROUTER_API_KEY_1"),
                os.getenv("OPENROUTER_API_KEY_2"),
                os.getenv("OPENROUTER_API_KEY_3"),
            ] if k  # Filter out None values
        ]
        self.current_key_index = 0
        self.model = os.getenv("OPENROUTER_MODEL", "nvidia/nemotron-3-ultra-550b-a55b:free")
        self.base_url = "https://openrouter.ai/api/v1/chat/completions"

    def _get_current_key(self) -> str:
        return self.api_keys[self.current_key_index]

    def _rotate_key(self):
        """Move to the next API key. Raises if all keys are exhausted."""
        self.current_key_index += 1
        if self.current_key_index >= len(self.api_keys):
            raise Exception("All API keys exhausted โ€” rate limited on all keys")

    def complete(self, system_prompt: str, user_message: str) -> str:
        """
        Sends a message to the AI model and returns the response.
        Automatically retries with the next key on rate limit errors.
        """
        for attempt in range(len(self.api_keys)):
            try:
                response = requests.post(
                    self.base_url,
                    headers={
                        "Authorization": f"Bearer {self._get_current_key()}",
                        "Content-Type": "application/json",
                        "HTTP-Referer": "https://github.com/Naveen15github/github-ai-reviewer",
                    },
                    json={
                        "model": self.model,
                        "messages": [
                            {"role": "system", "content": system_prompt},
                            {"role": "user", "content": user_message}
                        ],
                        "temperature": 0.1,   # Low temperature = more consistent, deterministic responses
                        "max_tokens": 4096
                    },
                    timeout=120   # 2 minute timeout for large model responses
                )

                # Handle rate limiting โ€” rotate key and retry
                if response.status_code in (429, 402):
                    print(f"Key {self.current_key_index + 1} rate limited. Rotating to next key...")
                    self._rotate_key()
                    time.sleep(2)   # Brief pause before retrying
                    continue

                response.raise_for_status()
                data = response.json()
                return data["choices"][0]["message"]["content"]

            except requests.exceptions.Timeout:
                # Model took too long โ€” try next key
                self._rotate_key()
                continue

        raise Exception("Failed to get AI response after trying all API keys")

Why temperature 0.1? Temperature controls how "creative" or "random" the AI's responses are. At 1.0, responses can vary significantly between calls. At 0.1, the model gives very consistent, predictable answers โ€” which is exactly what you want for security analysis. You don't want the AI to say "SQL injection" on one run and miss it on the next run of the same code.

Why 3 keys? OpenRouter's free tier has rate limits per API key. By having 3 keys, the agent can handle 3x the throughput before hitting limits. In practice, this gives near-100% uptime even on a free plan.


core/slack_client.py โ€” Sending Slack Alerts

import requests
import os

class SlackClient:
    def __init__(self):
        self.webhook_url = os.getenv("SLACK_WEBHOOK_URL")

    def send_security_alert(self, risk_score, risk_level, issues, pr_number, repo_name, repo_owner):
        """
        Sends a formatted Block Kit message to Slack.
        Block Kit is Slack's rich message format โ€” supports buttons, colors, sections.
        """
        # Map risk level to color (shown as left border on Slack message)
        color_map = {
            "CRITICAL": "#FF0000",   # Red
            "HIGH":     "#FF6600",   # Orange
            "MEDIUM":   "#FFD700",   # Yellow
            "LOW":      "#00AA00"    # Green
        }
        color = color_map.get(risk_level, "#888888")

        pr_url = f"https://github.com/{repo_owner}/{repo_name}/pull/{pr_number}"

        # Build the Block Kit payload
        payload = {
            "attachments": [
                {
                    "color": color,
                    "blocks": [
                        {
                            "type": "header",
                            "text": {
                                "type": "plain_text",
                                "text": f"๐Ÿšจ AI Code Review Alert โ€” {risk_level} Risk (Score: {risk_score}/100)"
                            }
                        },
                        {
                            "type": "section",
                            "fields": [
                                {"type": "mrkdwn", "text": f"*Risk Score:*\n{risk_score}/100 {'๐Ÿ”ด' if risk_level == 'CRITICAL' else '๐ŸŸ '}"},
                                {"type": "mrkdwn", "text": f"*Merge Recommendation:*\n{'๐Ÿšซ BLOCK' if risk_score >= 70 else 'โ›” FIX REQUIRED'}"},
                            ],
                            "accessory": {
                                "type": "button",
                                "text": {"type": "plain_text", "text": "View PR"},
                                "url": pr_url,
                                "style": "primary"
                            }
                        },
                        # Top 3 security issues
                        *[self._build_issue_block(issue) for issue in issues[:3]]
                    ]
                }
            ]
        }

        response = requests.post(self.webhook_url, json=payload)
        response.raise_for_status()

    def _build_issue_block(self, issue: dict) -> dict:
        """Formats a single security issue as a Slack Block Kit section."""
        return {
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": (
                    f"๐Ÿšจ *{issue['title']}*\n"
                    f"`{issue['owasp_category']}` | ๐Ÿ“ `{issue['location']}`\n"
                    f"๐Ÿ’ก {issue['recommendation']}"
                )
            }
        }

๐Ÿ”ฉ Part 4 โ€” Configuration & Settings

config/settings.py โ€” Central Configuration Manager

import os
from dataclasses import dataclass
from typing import Optional

@dataclass
class Settings:
    # OpenRouter API Keys
    openrouter_api_key_1: str
    openrouter_api_key_2: Optional[str]
    openrouter_api_key_3: Optional[str]
    openrouter_model: str

    # GitHub
    github_token: str
    pr_number: int
    repo_owner: str
    repo_name: str

    # Slack
    slack_webhook_url: Optional[str]

    # Thresholds
    risk_threshold: int
    max_diff_tokens: int

    @classmethod
    def from_env(cls) -> "Settings":
        """Load and validate all settings from environment variables."""

        # Validate required fields
        required = ["OPENROUTER_API_KEY_1", "GITHUB_TOKEN", "PR_NUMBER", "REPO_OWNER", "REPO_NAME"]
        missing = [key for key in required if not os.getenv(key)]

        if missing:
            raise EnvironmentError(
                f"Missing required environment variables: {', '.join(missing)}\n"
                f"Make sure these are set in GitHub Secrets."
            )

        return cls(
            openrouter_api_key_1=os.getenv("OPENROUTER_API_KEY_1"),
            openrouter_api_key_2=os.getenv("OPENROUTER_API_KEY_2"),
            openrouter_api_key_3=os.getenv("OPENROUTER_API_KEY_3"),
            openrouter_model=os.getenv("OPENROUTER_MODEL", "nvidia/nemotron-3-ultra-550b-a55b:free"),
            github_token=os.getenv("GITHUB_TOKEN"),
            pr_number=int(os.getenv("PR_NUMBER")),
            repo_owner=os.getenv("REPO_OWNER"),
            repo_name=os.getenv("REPO_NAME"),
            slack_webhook_url=os.getenv("SLACK_WEBHOOK_URL"),
            risk_threshold=int(os.getenv("RISK_THRESHOLD", "50")),
            max_diff_tokens=int(os.getenv("MAX_DIFF_TOKENS", "12000"))
        )

# Singleton โ€” imported once, reused everywhere
settings = Settings.from_env()

Why validate at startup? If OPENROUTER_API_KEY_1 is missing, the agent would run for 3 minutes, process the diff, send it to the AI, and only then fail when trying to authenticate. By validating all required variables at the very start, the agent fails immediately with a clear error message โ€” saving time and making debugging easy.

.env.example โ€” The Template for New Users

# Required
OPENROUTER_API_KEY_1=sk-or-v1-your-primary-key-here
GITHUB_TOKEN=ghp_your-token-here   # Auto-provided in GitHub Actions
PR_NUMBER=1                         # Auto-provided in GitHub Actions
REPO_OWNER=YourGitHubUsername       # Auto-provided in GitHub Actions
REPO_NAME=your-repo-name            # Auto-provided in GitHub Actions

# Optional โ€” for backup API keys
OPENROUTER_API_KEY_2=sk-or-v1-your-backup-key-2
OPENROUTER_API_KEY_3=sk-or-v1-your-backup-key-3

# Optional โ€” for Slack notifications
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../...

# Optional โ€” tune the agent behavior
RISK_THRESHOLD=50    # Send Slack alert if score >= this value
MAX_DIFF_TOKENS=12000

๐Ÿ“Š Part 5 โ€” Risk Scoring System

The AI model generates a score from 0 to 100 based on the severity and number of vulnerabilities found. Here is how the score maps to actions:

Score Range Risk Level Color GitHub Comment Slack Alert Merge Decision
0 โ€“ 30 ๐ŸŸข LOW Green โœ… Posted โŒ No alert Safe to merge
31 โ€“ 49 ๐ŸŸก MEDIUM Yellow โœ… Posted โŒ No alert Review suggested
50 โ€“ 69 ๐ŸŸ  HIGH Orange โœ… Posted โœ… Alert sent Fix before merge
70 โ€“ 100 ๐Ÿ”ด CRITICAL Red โœ… Posted โœ… Alert sent Block merge

OWASP Top 10 โ€” Full Coverage

Every vulnerability the AI finds is mapped to an OWASP category. These are the industry-standard vulnerability classifications:

OWASP ID Category Examples Detected
A01:2021 Broken Access Control Missing auth checks, privilege escalation
A02:2021 Cryptographic Failures MD5/SHA1 usage, plaintext passwords
A03:2021 Injection SQL injection, command injection, XSS
A04:2021 Insecure Design Logic flaws, missing security controls
A05:2021 Security Misconfiguration Debug mode on, default credentials
A06:2021 Vulnerable Components Outdated libraries with known CVEs
A07:2021 Authentication Failures Weak passwords, no MFA, session issues
A08:2021 Data Integrity Failures Insecure deserialization
A09:2021 Logging Failures Missing audit logs, logging sensitive data
A10:2021 Server-Side Request Forgery Unvalidated URL redirects

๐Ÿ” Part 6 โ€” Security Design

How Secrets Are Protected

GitHub Secrets โ€” All API keys and webhook URLs are stored in GitHub's encrypted secrets vault, not in the code. When the workflow runs, GitHub injects them as environment variables. They never appear in logs โ€” GitHub automatically redacts them.

GITHUB_TOKEN โ€” This is automatically generated by GitHub for each workflow run with minimal permissions (only what I declared in permissions:). It expires as soon as the workflow finishes.

3-Key Rotation โ€” If a key is leaked, the other two still work. Rotating one key doesn't disrupt the system.

Error Handling Strategy

AI call succeeds โ†’ Full review posted on PR
       โ”‚
AI call fails (timeout) โ†’ Retry with next key
       โ”‚
All keys exhausted โ†’ Post fallback comment: "AI review failed, please review manually"
       โ”‚
GitHub API fails โ†’ Log error, exit with non-zero code (marks workflow as failed)

The principle: always post something on the PR. Developers should never open a PR and see no review activity โ€” they might assume the system ran but found nothing, when actually it crashed silently.


๐Ÿ“ Sample AI Review Output

This is what gets posted as a comment directly on the Pull Request:

๐Ÿค– GitHub AI Code Reviewer โ€” Risk Assessment

โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”

RISK SCORE: 95/100  [CRITICAL]
MERGE RECOMMENDATION: ๐Ÿšซ BLOCK

โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”

SUMMARY:
This PR introduces an authentication module containing multiple critical
security vulnerabilities including SQL injection in 3 separate functions,
hardcoded credentials, MD5 password hashing, and command injection via
os.system(). This code should not be merged in its current state.

SECURITY ISSUES (5 found):

1. SQL Injection in authenticate_user()  [CRITICAL]
   OWASP: A03:2021 โ€“ Injection
   Location: vulnerable_auth.py:24
   Description: Username directly interpolated into SQL string
   Fix: Use parameterized queries โ€” cursor.execute("SELECT * FROM users
        WHERE username = ? AND password = ?", (username, password))

2. SQL Injection in get_user_profile()  [CRITICAL]
   OWASP: A03:2021 โ€“ Injection
   Location: vulnerable_auth.py:41
   Description: user_id directly interpolated into SQL string
   Fix: cursor.execute("SELECT * FROM profiles WHERE user_id = ?", (user_id,))

3. Hardcoded Credentials  [HIGH]
   OWASP: A07:2021 โ€“ Authentication Failures
   Location: vulnerable_auth.py:10-12
   Description: API keys and DB passwords committed to source code
   Fix: Use environment variables or a secrets manager

4. Weak Cryptography โ€” MD5 Password Hashing  [HIGH]
   OWASP: A02:2021 โ€“ Cryptographic Failures
   Location: vulnerable_auth.py:33
   Description: MD5 is cryptographically broken for password storage
   Fix: Use bcrypt, scrypt, or Argon2id for password hashing

5. Command Injection via os.system()  [CRITICAL]
   OWASP: A03:2021 โ€“ Injection
   Location: vulnerable_auth.py:67
   Description: User-controlled input passed to os.system()
   Fix: Use subprocess.run(["command", arg1, arg2]) with a list, never a string

โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
Generated by GitHub AI Code Reviewer โ€ข Powered by OpenRouter

๐Ÿš€ Setup & Installation

Prerequisites

Before you start, make sure you have:

  • A GitHub repository (public or private)
  • GitHub Actions enabled (it's on by default)
  • An OpenRouter account โ€” sign up free at openrouter.ai
  • A Slack workspace with an incoming webhook configured (optional but recommended)
  • Python 3.11+ installed locally (only needed if you want to test locally)

Step 1 โ€” Clone This Repository

git clone https://github.com/Naveen15github/github-ai-reviewer.git
cd github-ai-reviewer

Step 2 โ€” Get Your OpenRouter API Key

  1. Go to openrouter.ai and create a free account
  2. Navigate to Keys in the dashboard
  3. Click Create Key
  4. Copy the key โ€” it starts with sk-or-v1-...
  5. Optionally create 2 more keys for backup (highly recommended)

Step 3 โ€” Set Up GitHub Secrets

In your GitHub repository, go to: Settings โ†’ Secrets and variables โ†’ Actions โ†’ New repository secret

Add these secrets one by one:

Secret Name Value Required?
OPENROUTER_API_KEY_1 Your primary OpenRouter key โœ… Yes
OPENROUTER_API_KEY_2 Your backup key #2 โŒ Optional
OPENROUTER_API_KEY_3 Your backup key #3 โŒ Optional
SLACK_WEBHOOK_URL Your Slack webhook URL โŒ Optional

Note: GITHUB_TOKEN, PR_NUMBER, REPO_OWNER, and REPO_NAME are all automatically provided by GitHub Actions โ€” you do NOT need to add them.

Step 4 โ€” Set Up Slack Webhook (Optional)

  1. Go to api.slack.com/apps
  2. Click Create New App โ†’ From Scratch
  3. Give it a name like "AI Code Reviewer"
  4. Go to Incoming Webhooks โ†’ Activate Incoming Webhooks
  5. Click Add New Webhook to Workspace
  6. Choose the channel (e.g., #all-projects)
  7. Copy the webhook URL and add it as SLACK_WEBHOOK_URL in GitHub Secrets

Step 5 โ€” The Workflows Are Already Ready

The .github/workflows/ directory contains both workflow files. GitHub Actions will automatically pick them up โ€” no additional configuration needed.

Step 6 โ€” Test It Live

Create a test PR to see the agent in action:

git checkout -b test-ai-review
echo "# Test" >> test_file.py
git add test_file.py
git commit -m "test: trigger AI review"
git push origin test-ai-review

Then open a Pull Request on GitHub. Within 30โ€“60 seconds you should see:

  • โœ… A workflow run appear under the Actions tab
  • โœ… An AI review comment posted on your PR
  • โœ… A Slack alert (if the risk score is โ‰ฅ 50)

๐Ÿงช Testing

Run All Tests

pip install -r requirements.txt
pytest tests/ -v

Run With Coverage Report

pytest tests/ --cov=agent --cov=core --cov=config --cov-report=term-missing

Test Locally Without GitHub

# Uses a sample diff bundled in scripts/test_local.py
python scripts/test_local.py

# Test against a real PR (needs GITHUB_TOKEN set locally)
python scripts/test_local.py https://github.com/owner/repo/pull/42

What the Tests Cover

Test File What It Tests
test_github_client.py Fetching diffs, posting comments, API error handling
test_llm_client.py Key rotation logic, JSON parsing, timeout handling
test_slack_client.py Block Kit message formatting, webhook posting
test_nodes.py Each agent node in isolation with mock inputs
test_graph.py Full end-to-end workflow with all nodes connected

๐Ÿ› ๏ธ Tech Stack

Component Technology Why I Chose It
Orchestration LangGraph State machine model is perfect for multi-step conditional workflows
AI Model NVIDIA Nemotron 3 Ultra 550B 550B parameters, excellent code understanding, free tier available
AI Platform OpenRouter API Unified API for multiple models, generous free tier
CI/CD GitHub Actions Zero infrastructure, native GitHub integration
Language Python 3.11+ Best ecosystem for AI/ML tooling
Notifications Slack Webhooks Simple, reliable, supports rich Block Kit formatting
Data Validation Pydantic Type-safe parsing of AI responses

Key Python Dependencies

langgraph>=0.2.0       # Agent state machine
langchain-core>=0.3.0  # LangChain core (used by LangGraph)
requests>=2.32.0       # HTTP client for all API calls
python-dotenv>=1.0.0   # Load .env file for local development
pydantic>=2.7.0        # Validate and parse AI JSON responses

๐Ÿ“ˆ Performance

Metric Value
Time from PR open to review comment 30 โ€“ 60 seconds
AI model analysis time 5 โ€“ 15 seconds
Maximum PR diff size ~48 KB (~12,000 tokens)
Uptime with 3-key rotation 99%+
Monthly cost $0 (free tier)

๐ŸŒŸ Key Design Principles

1. Always post a comment โ€” The finalize node runs unconditionally. Whether the review succeeded, failed, or was skipped, the developer always gets a comment on their PR.

2. Never block on rate limits โ€” The 3-key rotation means a single key hitting its limit doesn't stall the workflow. The agent silently moves to the next key and keeps going.

3. Structured AI output โ€” By instructing the AI to respond only in JSON, I can reliably parse, validate, and display results โ€” no fragile regex on free-form text.

4. Separation of concerns โ€” Each file has one job: graph.py routes, nodes.py processes, github_client.py posts, llm_client.py analyzes. This makes each component independently testable and replaceable.


๐Ÿ”ฎ Future Enhancements

  • Support for JavaScript, TypeScript, Java, Go (currently optimized for Python)
  • Custom per-repo security rules via a .security-rules.yml file
  • Jira / Linear integration โ€” automatically create tickets for HIGH/CRITICAL issues
  • Historical risk score dashboard โ€” track security trends across PRs over time
  • Auto-fix mode โ€” generate secure replacement code as a PR suggestion
  • Branch protection integration โ€” automatically block merges for CRITICAL scores via GitHub's required status checks

๐Ÿ”— Resources


๐Ÿ‘ค Author

Naveen GitHub: @Naveen15github


๐Ÿ“„ License

MIT License โ€” see LICENSE for details.

About

No description, website, or topics provided.

Resources

Stars

10 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages