Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ wheels/
.DS_Store
.env
vectors/
.env.*
.env.*
logs/stdout.log
38 changes: 38 additions & 0 deletions .reviewignore.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# .reviewignore - Patterns for files to exclude from code review
# This file works similar to .gitignore with glob pattern matching

# Dependency lock files (already in global blacklist, but shown as example)
# go.sum
# go.mod
# package-lock.json

# Configuration files you might want to skip
# *.yaml
# *.yml
# *.json

# Test fixtures or mock data
# test/fixtures/*
# **/mocks/*
# **/__snapshots__/*

# Generated code or build artifacts
# **/*.generated.ts
# **/*.pb.go
# dist/*
# build/*

# Documentation that doesn't need review
# docs/*
# *.md

# Specific files you want to exclude
# legacy/old-code.py
# vendor/*

# Pattern examples:
# - Exact filename: config.json
# - Wildcard: *.min.js
# - Directory: node_modules/*
# - Nested: **/test/**/*.txt
# - Multiple extensions: *.{jpg,png,gif}
26 changes: 26 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
FROM python:3.12-slim

WORKDIR /app

# base Linux tooling + git
RUN apt-get update && apt-get install -y \
git \
bash \
findutils \
coreutils \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*

# install uv
RUN pip install --no-cache-dir uv

# copy project
COPY . .

# install deps
RUN uv sync --frozen

EXPOSE 8122

CMD ["uv", "run", "uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8122"]
69 changes: 37 additions & 32 deletions examples/webhook_api.py → api.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,31 @@
import asyncio
import os
import re

import dotenv
import requests
from fastapi import FastAPI, HTTPException, Request
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse

from src.reviewbot.main import post_merge_request_note, work_agent
from src.reviewbot.agent.workflow import post_mr_note, work_agent
from src.reviewbot.infra.config.env import load_env

dotenv.load_dotenv()

app = FastAPI()

GITLAB_SECRET = os.environ.get("GITLAB_WEBHOOK_SECRET")
GITLAB_TOKEN = os.environ.get("GITLAB_BOT_TOKEN")
GITLAB_API_V4 = os.environ.get("GITLAB_API_V4_URL")
BOT_USERNAME = os.environ.get("GITLAB_BOT_USERNAME") # without @

def get_required_env(key: str) -> str:
"""Get required environment variable or raise error with helpful message."""
value = os.environ.get(key)
if not value:
raise ValueError(f"Missing required environment variable: {key}")
return value

def post_mr_note(project_id: str, mr_iid: str, body: str):
url = f"{GITLAB_API_V4.rstrip('/')}/projects/{project_id}/merge_requests/{mr_iid}/notes"
r = requests.post(
url,
headers={"PRIVATE-TOKEN": GITLAB_TOKEN},
data={"body": body},
timeout=30,
)
r.raise_for_status()

GITLAB_SECRET = get_required_env("GITLAB_WEBHOOK_SECRET")
GITLAB_TOKEN = get_required_env("GITLAB_BOT_TOKEN")
GITLAB_API_V4 = get_required_env("GITLAB_API_V4_URL") + "/api/v4"
BOT_USERNAME = os.environ.get("GITLAB_BOT_USERNAME")
BOT_ID = get_required_env("GITLAB_BOT_AUTHOR_ID")


def get_pipeline_status(project_id: str, pipeline_id: int) -> str:
Expand Down Expand Up @@ -61,7 +59,7 @@ def pipeline_passed(project_id: str, pipeline_id: int) -> bool:


@app.post("/webhook")
async def gitlab_webhook(req: Request):
async def gitlab_webhook(req: Request, background_tasks: BackgroundTasks):
token = req.headers.get("X-Gitlab-Token")
if GITLAB_SECRET and token != GITLAB_SECRET:
raise HTTPException(status_code=403, detail="Invalid token")
Expand All @@ -82,9 +80,12 @@ async def gitlab_webhook(req: Request):
return JSONResponse({"ignored": "bot note"})

text = note.get("note", "")
pattern = rf"(?:/review\b.*@{re.escape(BOT_USERNAME)}|@{re.escape(BOT_USERNAME)}.*?/review\b)"
if not re.search(pattern, text):
return JSONResponse({"ignored": "no /review command"})
# pattern = rf"(?:/review\b.*@{re.escape(BOT_USERNAME)}|@{re.escape(BOT_USERNAME)}.*?/review\b)"
# if not re.search(pattern, text):
# return JSONResponse({"ignored": "no /review command"})

if text.strip() != "/reviewbot review":
return JSONResponse({"ignored": "not a review command"})

mr = payload.get("merge_request")
if not mr:
Expand All @@ -93,12 +94,12 @@ async def gitlab_webhook(req: Request):
project_id = payload["project"]["id"]
mr_iid = mr["iid"]

await asyncio.to_thread(
config = load_env()
background_tasks.add_task(
work_agent,
GITLAB_API_V4,
config,
project_id,
mr_iid,
GITLAB_TOKEN,
)

return JSONResponse({"status": "manual review triggered"})
Expand All @@ -112,38 +113,42 @@ async def gitlab_webhook(req: Request):
detailed_status = attrs.get("detailed_status")

project_id = payload["project"]["id"]
mr_iid = mr["iid"]

if detailed_status not in ["passed", "failed"]:
return JSONResponse({"ignored": "pipeline is not in a final state"})

if not mr:
return JSONResponse({"ignored": "not an MR pipeline"})

mr_iid = mr["iid"]

if detailed_status != "passed":
post_merge_request_note(
post_mr_note(
GITLAB_API_V4,
GITLAB_TOKEN,
project_id,
mr_iid,
"Pipeline was not successful. If you want ReviewBot to review your changes, please re-run the pipeline, and make sure it passes. Or you can manually call ReviewBot by typing: \n\n @project_29_bot_5a466f228cb9d019289c41195219f291 /review",
"Pipeline was not successful. If you want ReviewBot to review your changes, please re-run the pipeline, and make sure it passes. Or you can manually call ReviewBot by typing: \n\n /reviewbot review",
)
return JSONResponse({"ignored": "pipeline failed"})

# conditions
if mr_has_conflicts(mr):
post_merge_request_note(
post_mr_note(
GITLAB_API_V4,
GITLAB_TOKEN,
project_id,
mr_iid,
"Merge conflicts present. Please resolve them and commit changes to re-run the pipeline. Or you can manually call ReviewBot by typing: \n\n @project_29_bot_5a466f228cb9d019289c41195219f291 /review",
"Merge conflicts present. Please resolve them and commit changes to re-run the pipeline. Or you can manually call ReviewBot by typing: \n\n /reviewbot review",
)
return JSONResponse({"ignored": "merge conflicts present"})

await asyncio.to_thread(
config = load_env()
background_tasks.add_task(
work_agent,
GITLAB_API_V4,
config,
project_id,
mr_iid,
GITLAB_TOKEN,
)

return JSONResponse({"status": "auto review triggered"})
Expand Down
41 changes: 41 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
services:
gitlab:
image: gitlab/gitlab-ce:latest
container_name: gitlab
hostname: gitlab.local
restart: always
ports:
- "8081:80"
- "8443:443"
- "2222:22"
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'http://gitlab.reviewbot.orb.local:8081'
gitlab_rails['gitlab_shell_ssh_port'] = 2222
volumes:
- gitlab_config:/etc/gitlab
- gitlab_logs:/var/log/gitlab
- gitlab_data:/var/opt/gitlab

reviewbot:
build: .
container_name: reviewbot
ports:
- "8122:8122"
env_file:
- .env
volumes:
- .:/app
- ./logs:/logs
command: >
sh -c "uv run uvicorn api:app
--host 0.0.0.0
--port 8122
--reload
2>&1 | tee /logs/stdout.log"
restart: unless-stopped

volumes:
gitlab_config:
gitlab_logs:
gitlab_data:
24 changes: 24 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ requires-python = ">=3.13"
dependencies = [
"dotenv>=0.9.9",
"faiss-cpu>=1.13.1",
"fastapi>=0.125.0",
"langchain>=1.2.0",
"langchain-community>=0.4.1",
"langchain-google-genai>=4.1.2",
Expand All @@ -18,6 +19,7 @@ dependencies = [
"rich>=14.2.0",
"transformers>=4.57.3",
"typer>=0.20.0",
"uvicorn>=0.40.0",
"xai-review>=0.48.0",
]
[tool.uv]
Expand All @@ -27,8 +29,30 @@ reviewbot = "reviewbot.main:app"
[tool.pyright]
typeCheckingMode = "strict"

[tool.ruff]
line-length = 100
target-version = "py313"

[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"UP", # pyupgrade
]
ignore = [
"E501", # line too long (handled by formatter)
]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"

[dependency-groups]
dev = [
"ruff>=0.8.6",
"ty>=0.0.4",
]
[project.optional-dependencies]
Expand Down
9 changes: 6 additions & 3 deletions src/reviewbot/agent/base.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Callable, List, Optional

from langgraph.func import entrypoint # type: ignore
from rich.console import Console
Expand All @@ -19,15 +19,17 @@ class AgentRunnerInput:
agent: Agent
context: Context
settings: ToolCallerSettings = field(default_factory=ToolCallerSettings)
on_file_complete: Optional[Callable[[str, List[IssueModel]], None]] = None
on_file_complete: Callable[[str, list[IssueModel]], None] | None = None
quick_scan_agent: Agent | None = None


@entrypoint()
def agent_runner(input: AgentRunnerInput) -> List[Issue]:
def agent_runner(input: AgentRunnerInput) -> list[Issue]:
agent = input.agent
settings = input.settings
context = input.context
on_file_complete = input.on_file_complete
quick_scan_agent = input.quick_scan_agent

issue_store = context.get("issue_store")
if not issue_store:
Expand All @@ -44,6 +46,7 @@ def agent_runner(input: AgentRunnerInput) -> List[Issue]:
context=context,
settings=settings,
on_file_complete=on_file_complete,
quick_scan_agent=quick_scan_agent,
)
).result()

Expand Down
Loading