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 TOTAL_FLOW_TIMEOUT=600
PER_CREW_TIMEOUT=300 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=LOG_LEVEL=INFO \
--from-literal=TOTAL_FLOW_TIMEOUT=600 \ --from-literal=TOTAL_FLOW_TIMEOUT=600 \
--from-literal=PER_CREW_TIMEOUT=300 \ --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 --namespace=pr-reviewer
kubectl apply -f kube/pr-reviewer_deployment.yaml && kubectl apply -f kube/pr-reviewer_service.yaml kubectl apply -f kube/pr-reviewer_deployment.yaml && kubectl apply -f kube/pr-reviewer_service.yaml

View File

@ -26,7 +26,8 @@ dependencies = [
"mcpadapt", "mcpadapt",
"pydantic>=2.5.0", "pydantic>=2.5.0",
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
"gitpython>=3.1.0" "gitpython>=3.1.0",
"requests>=2.28.0"
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@ -1,5 +1,8 @@
import logging import logging
import os import os
import hmac
import hashlib
import base64
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
import uvicorn import uvicorn
@ -8,6 +11,7 @@ import asyncio
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
import time import time
import uuid import uuid
import requests
from .flow import CodeReviewFlow from .flow import CodeReviewFlow
from .state import PRReviewState, FileInfo, ContextOverrides 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 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 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") @app.get("/api/v1/health")
async def health_check() -> Dict[str, str]: async def health_check() -> Dict[str, str]:
@ -36,6 +44,141 @@ async def health_check() -> Dict[str, str]:
return {"status": "healthy", "service": "pr-reviewer"} 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") @app.post("/api/v1/review")
async def review_pr(request: Request) -> Dict[str, Any]: async def review_pr(request: Request) -> Dict[str, Any]:
""" """