add webhook capability

This commit is contained in:
Andrew Ridgway 2026-05-20 22:48:03 +10:00
parent 1a4bb3634b
commit 7ab856727a
Signed by: armistace
GPG Key ID: C8D9EAC514B47EF1
4 changed files with 154 additions and 2 deletions

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
GITEA_URL=http://192.168.178.160:3000
GITEA_TOKEN=your_gitea_personal_access_token_here
GITEA_SECRET=your_webhook_secret_here

View File

@ -73,5 +73,8 @@ jobs:
--from-literal=LOG_LEVEL=INFO \
--from-literal=TOTAL_FLOW_TIMEOUT=600 \
--from-literal=PER_CREW_TIMEOUT=300 \
--from-literal=GITEA_URL=${{ vars.GITEA_URL }} \
--from-literal=GITEA_TOKEN=${{ secrets.GITEA_TOKEN }} \
--from-literal=GITEA_SECRET=${{ secrets.GITEA_SECRET }} \
--namespace=pr-reviewer
kubectl apply -f kube/pr-reviewer_deployment.yaml && kubectl apply -f kube/pr-reviewer_service.yaml

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]

View File

@ -1,5 +1,8 @@
import logging
import os
import hmac
import hashlib
import base64
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
import uvicorn
@ -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
GITEA_URL = os.getenv("GITEA_URL", "http://192.168.178.160:3000")
GITEA_TOKEN = os.getenv("GITEA_TOKEN")
WEBHOOK_SECRET = os.getenv("GITEA_SECRET", "")
@app.get("/api/v1/health")
async def health_check() -> Dict[str, str]:
@ -36,6 +44,141 @@ 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 {GITEA_TOKEN}"}
url = f"{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"{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 {GITEA_TOKEN}"}
url = f"{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}")
@app.post("/api/v1/gitea-webhook")
async def gitea_webhook(request: Request) -> 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"{GITEA_URL}/{repo_full}")
if action not in ("opened", "synchronize", "reopened"):
logger.info(f"Ignoring PR action: {action}")
return {"status": "ignored", "reason": f"action '{action}' not processed"}
if not GITEA_TOKEN:
raise HTTPException(status_code=500, detail="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}")
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.get("body", ""),
"pr_url": f"{repo_url}/pull/{pr_number}",
"repo_name": repo_full,
"repo_url": repo_url,
"branch": pr["head"]["label"],
"base_branch": pr["base"]["label"],
"files": [f.dict() for f in converted_files],
"context_overrides": None,
}
flow = CodeReviewFlow()
loop = asyncio.get_event_loop()
with ThreadPoolExecutor() as pool:
try:
flow_result = await asyncio.wait_for(
loop.run_in_executor(pool, lambda: flow.kickoff(inputs=flow_inputs)),
timeout=TOTAL_FLOW_TIMEOUT,
)
except asyncio.TimeoutError:
logger.error(f"PR review timed out for PR #{pr_number}")
raise HTTPException(
status_code=504,
detail=f"PR review timed out after {TOTAL_FLOW_TIMEOUT} seconds",
)
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}")
return {
"status": "completed" if not flow_result.get("error") else "failed",
"pr_number": pr_number,
"review_summary": flow_result.get("review_summary"),
"error": flow_result.get("error"),
}
return {"status": "ignored"}
@app.post("/api/v1/review")
async def review_pr(request: Request) -> Dict[str, Any]:
"""