Author SHA1 Message Date
armistace bc7ddd29d4 update to proper gitea webhook syntax and provide immediate onit 2026-05-21 21:16:41 +10:00
armistace 77c335a4e9 updated readme 2026-05-21 21:06:42 +10:00
armistace a34a3c3a67 Merge pull request 'git-webook-entrypoint' (#2) from git-webook-entrypoint into master
Build and Push Image / Build and push image (push) Successful in 38m36s
Reviewed-on: #2
2026-05-20 23:05:54 +10:00
armistace 4ace5932ff drop trivy 2026-05-20 23:04:52 +10:00
armistace 5054f609dd gitea compliant var names 2026-05-20 22:54:31 +10:00
armistace 7ab856727a add webhook capability 2026-05-20 22:48:03 +10:00
5 changed files with 250 additions and 38 deletions
+6 -1
View File
@@ -22,4 +22,9 @@ SEMGRAPH_API_TOKEN=
TOTAL_FLOW_TIMEOUT=600
PER_CREW_TIMEOUT=300
LOG_LEVEL=INFO
LOG_LEVEL=INFO
# Gitea Webhook Configuration
ACCESS_GITEA_URL=http://192.168.178.160:3000
ACCESS_GITEA_TOKEN=your_gitea_personal_access_token_here
ACCESS_GITEA_SECRET=your_webhook_secret_here
+3 -8
View File
@@ -44,14 +44,6 @@ jobs:
tags: |
git.aridgwayweb.com/armistace/pr-reviewer:latest
- name: Trivy Scan
run: |
TRIVY_VERSION=$(curl -s https://api.github.com/repos/aquasecurity/trivy/releases/latest | grep '"tag_name"' | cut -d'"' -f4)
wget -qO /tmp/trivy.tar.gz "https://github.com/aquasecurity/trivy/releases/download/${TRIVY_VERSION}/trivy_${TRIVY_VERSION#v}_Linux-64bit.tar.gz"
tar xzf /tmp/trivy.tar.gz -C /usr/local/bin trivy
chmod +x /usr/local/bin/trivy
trivy image --format table --exit-code 1 --ignore-unfixed --vuln-type os,library --severity HIGH,CRITICAL git.aridgwayweb.com/armistace/pr-reviewer:latest
- name: Deploy
run: |
echo "Installing Kubectl"
@@ -73,5 +65,8 @@ jobs:
--from-literal=LOG_LEVEL=INFO \
--from-literal=TOTAL_FLOW_TIMEOUT=600 \
--from-literal=PER_CREW_TIMEOUT=300 \
--from-literal=ACCESS_GITEA_URL=${{ vars.ACCESS_GITEA_URL }} \
--from-literal=ACCESS_GITEA_TOKEN=${{ secrets.ACCESS_GITEA_TOKEN }} \
--from-literal=ACCESS_GITEA_SECRET=${{ secrets.ACCESS_GITEA_SECRET }} \
--namespace=pr-reviewer
kubectl apply -f kube/pr-reviewer_deployment.yaml && kubectl apply -f kube/pr-reviewer_service.yaml
+83 -27
View File
@@ -2,7 +2,7 @@
Automated pull request review system using [CrewAI](https://crewai.com) Flows and MCP (Model Context Protocol) tools.
Performs three parallel reviews — code quality, security, and infrastructure — then synthesizes a consolidated report via a REST API.
Performs three parallel reviews — code quality, security, and infrastructure — then synthesizes a consolidated report via a REST API. Supports both a direct API and a Gitea webhook integration that fetches diffs automatically and posts the review as a PR comment.
## Features
@@ -10,32 +10,10 @@ Performs three parallel reviews — code quality, security, and infrastructure
- **Security Review** — vulnerabilities, injection risks, auth issues (powered by Trivy)
- **Infrastructure Review** — Dockerfiles, Kubernetes manifests, IaC (powered by Hadolint + Checkov)
- **Summarisation** — merges all three reviews into a single actionable report
- **REST API** — FastAPI endpoints for health check and review trigger
- **REST API** — FastAPI endpoints for health check, manual review trigger, and Gitea webhook
- **Gitea Webhook** — process PR events directly; fetches diffs, runs reviews, posts results as a PR comment
- **Dockerized** — multi-stage build with all tools bundled
## Architecture
```
POST /api/v1/review
│
▼
CodeReviewFlow (CrewAI Flow)
│
┌────┼──────────────┐
▼ ▼ ▼
Code Security Infra
Review Review Review
│ │ │
└─────┼────────────┘
▼
Summariser
│
▼
JSON Response
```
LLM-agnostic via CrewAI's LLM abstraction — works with OpenAI, Anthropic, or Ollama.
## Quick Start
### Prerequisites
@@ -85,6 +63,34 @@ curl -X POST http://localhost:8000/api/v1/review \
}'
```
## Architecture
```
POST /api/v1/review POST /api/v1/gitea-webhook
│ │
│ Gitea webhook payload
│ │
│ fetch diffs from
│ Gitea API
│ │
▼ ▼
CodeReviewFlow (CrewAI Flow)
│
┌────┼──────────────┐
▼ ▼ ▼
Code Security Infra
Review Review Review
│ │ │
└─────┼────────────┘
▼
Summariser
│
▼
JSON Response / PR Comment
```
LLM-agnostic via CrewAI's LLM abstraction — works with OpenAI, Anthropic, or Ollama.
## API
### `GET /api/v1/health`
@@ -97,7 +103,7 @@ Returns service status.
### `POST /api/v1/review`
Triggers a full PR review.
Triggers a full PR review. Provide file contents and diffs directly in the request body.
**Request body:**
@@ -144,6 +150,42 @@ Triggers a full PR review.
}
```
### `POST /api/v1/gitea-webhook`
Receives Gitea webhook events. Only processes `pull_request` events with actions `opened`, `synchronize`, or `reopened`. All other events and actions are ignored.
The endpoint:
1. Validates the `X-Gitea-Signature` header using HMAC-SHA256 (if `ACCESS_GITEA_SECRET` is configured)
2. Fetches changed files and their contents from the Gitea API
3. Runs the full review pipeline (code, security, infrastructure, summariser)
4. Posts the review summary as a comment on the PR via the Gitea API
## Gitea Webhook Setup
### 1. Create an access token
In your Gitea instance, go to **Settings → Applications → Generate New Token** and create a token with `read:repository` scope.
### 2. Add the webhook
In your Gitea repository, go to **Settings → Webhooks → Add Webhook → Gitea**:
- **Target URL**: `http://<host>:30001/api/v1/gitea-webhook`
- **HTTP Method**: `POST`
- **Secret**: a random string (optional but recommended)
- **Trigger On**: Pull Request
### 3. Configure environment variables
Set the following in the container (or k8s secret):
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `ACCESS_GITEA_URL` | yes | `http://192.168.178.160:3000` | Gitea instance base URL |
| `ACCESS_GITEA_TOKEN` | yes | — | Gitea personal access token with `read:repository` scope |
| `ACCESS_GITEA_SECRET` | no | `""` | Webhook secret; if set, signatures are validated |
## Configuration
All configuration via environment variables in `.env`:
@@ -154,10 +196,23 @@ All configuration via environment variables in `.env`:
| `LLM_PROVIDER` | (required) | `openai`, `anthropic`, or `ollama` |
| `LLM_BASE_URL` | — | API base URL |
| `LLM_API_KEY` | — | API key (not needed for Ollama) |
| `ACCESS_GITEA_URL` | `http://192.168.178.160:3000` | Gitea instance base URL |
| `ACCESS_GITEA_TOKEN` | — | Gitea personal access token with `read:repository` scope |
| `ACCESS_GITEA_SECRET` | — | Webhook secret for HMAC-SHA256 signature verification |
| `TOTAL_FLOW_TIMEOUT` | `600` | Max seconds for full review |
| `PER_CREW_TIMEOUT` | `300` | Max seconds per crew |
| `LOG_LEVEL` | `INFO` | Logging level |
## Deployment
### Kubernetes
The repo includes a CI pipeline (`.gitea/workflows/build_push.yml`) that builds a multi-arch Docker image, pushes it to the registry, and deploys to Kubernetes.
The k8s deployment uses a NodePort service exposing port 30001, which maps to the container's port 8000.
Environment variables are stored in a k8s secret (`pr-reviewer-env`). The CI pipeline creates this secret automatically — add `ACCESS_GITEA_URL`, `ACCESS_GITEA_TOKEN`, and `ACCESS_GITEA_SECRET` as Gitea repo variables/secrets.
## Development
```bash
@@ -179,12 +234,13 @@ uvicorn src.pr_reviewer.main:app --reload
├── crews/ # Crew definitions (code, security, infra, summariser)
├── mcp_servers/ # MCP tool wrappers (Hadolint, Checkov)
├── src/pr_reviewer/ # Core application code
│ ├── main.py # FastAPI app
│ ├── main.py # FastAPI app, endpoints, webhook handler
│ ├── flow.py # CrewAI Flow orchestration
│ ├── state.py # Pydantic state models
│ ├── llm.py # LLM factory
│ └── context.py # Context resolution
├── tests/ # Unit and integration tests
├── kube/ # Kubernetes manifests
├── docker-compose.yaml
├── Dockerfile
└── pyproject.toml
+2 -1
View File
@@ -26,7 +26,8 @@ dependencies = [
"mcpadapt",
"pydantic>=2.5.0",
"python-dotenv>=1.0.0",
"gitpython>=3.1.0"
"gitpython>=3.1.0",
"requests>=2.28.0"
]
[project.optional-dependencies]
+156 -1
View File
@@ -1,6 +1,9 @@
import logging
import os
from fastapi import FastAPI, HTTPException, Request
import hmac
import hashlib
import base64
from fastapi import FastAPI, HTTPException, Request, BackgroundTasks
from fastapi.responses import JSONResponse
import uvicorn
from typing import Dict, Any, List, Optional
@@ -8,6 +11,7 @@ import asyncio
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
import time
import uuid
import requests
from .flow import CodeReviewFlow
from .state import PRReviewState, FileInfo, ContextOverrides
@@ -27,6 +31,10 @@ app = FastAPI(
TOTAL_FLOW_TIMEOUT = int(os.getenv("TOTAL_FLOW_TIMEOUT", "600")) # Default 10 minutes
PER_CREW_TIMEOUT = int(os.getenv("PER_CREW_TIMEOUT", "300")) # Default 5 minutes
ACCESS_GITEA_URL = os.getenv("ACCESS_GITEA_URL", "http://192.168.178.160:3000")
ACCESS_GITEA_TOKEN = os.getenv("ACCESS_GITEA_TOKEN")
WEBHOOK_SECRET = os.getenv("ACCESS_GITEA_SECRET", "")
@app.get("/api/v1/health")
async def health_check() -> Dict[str, str]:
@@ -36,6 +44,153 @@ async def health_check() -> Dict[str, str]:
return {"status": "healthy", "service": "pr-reviewer"}
def verify_signature(payload: bytes, signature: str) -> bool:
if not WEBHOOK_SECRET:
return True
mac = hmac.new(WEBHOOK_SECRET.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, signature)
def fetch_pr_files(repo_full: str, pr_number: int) -> List[Dict[str, Any]]:
headers = {"Authorization": f"token {ACCESS_GITEA_TOKEN}"}
url = f"{ACCESS_GITEA_URL}/api/v1/repos/{repo_full}/pulls/{pr_number}/files"
resp = requests.get(url, headers=headers)
resp.raise_for_status()
files_data = resp.json()
files = []
for f in files_data:
filename = f["filename"]
status = f["status"]
content = None
if status in ("added", "modified"):
raw_url = f"{ACCESS_GITEA_URL}/api/v1/repos/{repo_full}/contents/{filename}?ref=pulls/{pr_number}/head"
raw_resp = requests.get(raw_url, headers=headers)
if raw_resp.ok:
raw = raw_resp.json()
if raw.get("encoding") == "base64":
content = base64.b64decode(raw["content"]).decode("utf-8")
files.append({
"filename": filename,
"status": status,
"content": content or "",
"additions": f.get("additions", 0),
"deletions": f.get("deletions", 0),
"patch": f.get("patch", ""),
})
return files
def post_pr_comment(repo_full: str, pr_number: int, comment: str) -> None:
headers = {"Authorization": f"token {ACCESS_GITEA_TOKEN}"}
url = f"{ACCESS_GITEA_URL}/api/v1/repos/{repo_full}/issues/{pr_number}/comments"
resp = requests.post(url, headers=headers, json={"body": comment})
resp.raise_for_status()
logger.info(f"Posted review comment to PR #{pr_number} in {repo_full}")
def _run_review_background(repo_full: str, pr_number: int, pr_title: str,
pr_description: str, repo_url: str, branch: str,
base_branch: str, files: List[Dict[str, Any]]) -> None:
converted_files = []
for f in files:
converted_files.append(FileInfo(
path=f["filename"],
content=f.get("content"),
status=f.get("status", "modified"),
additions=f.get("additions", 0),
deletions=f.get("deletions", 0),
patch=f.get("patch"),
))
flow_inputs = {
"pr_id": str(pr_number),
"pr_title": pr_title,
"pr_description": pr_description,
"pr_url": f"{repo_url}/pull/{pr_number}",
"repo_name": repo_full,
"repo_url": repo_url,
"branch": branch,
"base_branch": base_branch,
"files": [f.dict() for f in converted_files],
"context_overrides": None,
}
flow = CodeReviewFlow()
try:
flow_result = flow.kickoff(inputs=flow_inputs)
except Exception as e:
logger.error(f"Background review failed for PR #{pr_number}: {e}")
try:
post_pr_comment(repo_full, pr_number, f"**PR Review failed:** {e}")
except Exception:
pass
return
if flow_result.get("error"):
logger.error(f"PR review failed for PR #{pr_number}: {flow_result['error']}")
try:
summary = flow_result.get("review_summary", "")
if summary:
comment = f"## PR Review Results\n\n{summary}"
post_pr_comment(repo_full, pr_number, comment)
except Exception as e:
logger.warning(f"Failed to post review comment: {e}")
@app.post("/api/v1/gitea-webhook")
async def gitea_webhook(request: Request, background_tasks: BackgroundTasks) -> Dict[str, Any]:
body = await request.body()
sig = request.headers.get("X-Gitea-Signature", "")
if not verify_signature(body, sig):
raise HTTPException(status_code=403, detail="Invalid signature")
data = await request.json()
event = request.headers.get("X-Gitea-Event")
if event == "pull_request":
action = data.get("action", "")
pr = data["pull_request"]
pr_number = pr["number"]
repo = data["repository"]
repo_full = repo["full_name"]
repo_url = repo.get("html_url", f"{ACCESS_GITEA_URL}/{repo_full}")
if action not in ("opened", "synchronized", "reopened"):
logger.info(f"Ignoring PR action: {action}")
return {"status": "ignored", "reason": f"action '{action}' not processed"}
if not ACCESS_GITEA_TOKEN:
raise HTTPException(status_code=500, detail="ACCESS_GITEA_TOKEN not configured")
try:
files = fetch_pr_files(repo_full, pr_number)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error fetching PR files: {e}")
try:
post_pr_comment(repo_full, pr_number, "PR received — starting review, sit tight :saluting_face:")
except Exception as e:
logger.warning(f"Failed to post initial comment: {e}")
background_tasks.add_task(
_run_review_background,
repo_full=repo_full,
pr_number=pr_number,
pr_title=pr["title"],
pr_description=pr.get("body", ""),
repo_url=repo_url,
branch=pr["head"]["label"],
base_branch=pr["base"]["label"],
files=files,
)
return {"status": "accepted", "pr_number": pr_number}
return {"status": "ignored"}
@app.post("/api/v1/review")
async def review_pr(request: Request) -> Dict[str, Any]:
"""