This commit is contained in:
2026-05-08 23:46:17 +10:00
commit 10c8cfaa0f
71 changed files with 2139 additions and 0 deletions
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
import os
from pathlib import Path
from typing import Dict, Optional
from .state import PRReviewState, ContextOverrides
def resolve_context(state: PRReviewState) -> Dict[str, str]:
"""
Resolve the context for each review type based on overrides and default files.
Args:
state: The PR review state containing potential context overrides
Returns:
A dictionary with keys 'code_review', 'security_review', 'infra_review'
and their resolved context strings.
"""
# Define the mapping of context types to their default file paths
context_mapping = {
'code_review': 'contexts/defaults/code_review.md',
'security_review': 'contexts/defaults/security_review.md',
'infra_review': 'contexts/defaults/infra_review.md'
}
resolved = {}
for context_type, default_path in context_mapping.items():
# Check if there's an override in the state
override_value = None
if state.context_overrides:
override_value = getattr(state.context_overrides, context_type, None)
if override_value is not None and override_value.strip() != '':
# Use the override if provided and not empty
resolved[context_type] = override_value.strip()
else:
# Use the default file
try:
with open(default_path, 'r') as f:
resolved[context_type] = f.read().strip()
except FileNotFoundError:
# If the default file doesn't exist, use an empty string
resolved[context_type] = ''
return resolved
+150
View File
@@ -0,0 +1,150 @@
from crewai.flow import Flow, listen, start, and_
from crewai import Crew
from .state import PRReviewState
from .llm import get_llm
from .context import resolve_context
import os
from datetime import datetime
# Import the crews
from crews.code_review_crew.code_review_crew import CodeReviewCrew
from crews.security_review_crew.security_review_crew import SecurityReviewCrew
from crews.infra_review_crew.infra_review_crew import InfraReviewCrew
from crews.summariser_crew.summariser_crew import SummariserCrew
class CodeReviewFlow(Flow[PRReviewState]):
@start()
def receive_pr(self, inputs):
"""Initialize the PR review state with input data."""
print(f"Received PR review request for PR #{inputs.get('pr_id')}")
# Initialize the state
self.state.pr_id = inputs.get("pr_id", "")
self.state.pr_title = inputs.get("pr_title", "")
self.state.pr_description = inputs.get("pr_description", "")
self.state.pr_url = inputs.get("pr_url", "")
self.state.repo_name = inputs.get("repo_name", "")
self.state.repo_url = inputs.get("repo_url", "")
self.state.branch = inputs.get("branch", "")
self.state.base_branch = inputs.get("base_branch", "")
# Convert files from list of dicts to list of FileInfo objects if needed
files_input = inputs.get("files", [])
if files_input and isinstance(files_input[0], dict):
# Convert dicts to FileInfo objects
from .state import FileInfo
self.state.files = [FileInfo(**file_dict) for file_dict in files_input]
else:
self.state.files = files_input
# Handle context_overrides
context_overrides_input = inputs.get("context_overrides")
if context_overrides_input and isinstance(context_overrides_input, dict):
from .state import ContextOverrides
self.state.context_overrides = ContextOverrides(**context_overrides_input)
else:
self.state.context_overrides = context_overrides_input
self.state.started_at = datetime.now()
# Resolve context
self.state.resolved_context = resolve_context(self.state)
return self.state
@listen(receive_pr)
def run_code_review(self):
"""Run the code review crew."""
print("Starting code review...")
# Instantiate and run the code review crew
code_crew = CodeReviewCrew()
# The crew's kickoff method expects inputs matching the task template variables
inputs = {
"pr_title": self.state.pr_title,
"pr_description": self.state.pr_description,
"files": [file.dict() if hasattr(file, 'dict') else file for file in self.state.files],
"context": self.state.resolved_context.get("code_review", "")
}
result = code_crew.crew().kickoff(inputs=inputs)
self.state.code_review_results = str(result)
print("Code review completed.")
return self.state
@listen(receive_pr)
def run_security_review(self):
"""Run the security review crew."""
print("Starting security review...")
# Instantiate and run the security review crew
security_crew = SecurityReviewCrew()
inputs = {
"pr_title": self.state.pr_title,
"pr_description": self.state.pr_description,
"files": [file.dict() if hasattr(file, 'dict') else file for file in self.state.files],
"context": self.state.resolved_context.get("security_review", "")
}
result = security_crew.crew().kickoff(inputs=inputs)
self.state.security_review_results = str(result)
print("Security review completed.")
return self.state
@listen(receive_pr)
def run_infra_review(self):
"""Run the infrastructure review crew."""
print("Starting infrastructure review...")
# Instantiate and run the infrastructure review crew
infra_crew = InfraReviewCrew()
inputs = {
"pr_title": self.state.pr_title,
"pr_description": self.state.pr_description,
"files": [file.dict() if hasattr(file, 'dict') else file for file in self.state.files],
"context": self.state.resolved_context.get("infra_review", "")
}
result = infra_crew.crew().kickoff(inputs=inputs)
self.state.infra_review_results = str(result)
print("Infrastructure review completed.")
return self.state
@listen(and_(run_code_review, run_security_review, run_infra_review))
def summarise(self):
"""Summarize the review results."""
print("Starting summarisation...")
# Instantiate and run the summariser crew
summariser_crew = SummariserCrew()
inputs = {
"code_review_results": self.state.code_review_results,
"security_review_results": self.state.security_review_results,
"infra_review_results": self.state.infra_review_results,
"context": self.state.resolved_context
}
result = summariser_crew.crew().kickoff(inputs=inputs)
self.state.review_summary = str(result)
self.state.completed_at = datetime.now()
print("Summarisation completed.")
return self.state
@listen(summarise)
def format_response(self):
"""Format the final response."""
print("Formatting final response...")
# Return the final state as the response
return {
"pr_id": self.state.pr_id,
"pr_title": self.state.pr_title,
"review_summary": self.state.review_summary,
"code_review_results": self.state.code_review_results,
"security_review_results": self.state.security_review_results,
"infra_review_results": self.state.infra_review_results,
"started_at": self.state.started_at.isoformat() if self.state.started_at else None,
"completed_at": self.state.completed_at.isoformat() if self.state.completed_at else None,
"error": self.state.error
}
+56
View File
@@ -0,0 +1,56 @@
import os
from crewai import LLM
from typing import Optional
def create_llm() -> LLM:
"""
Create an LLM instance based on environment variables.
Expected environment variables:
- LLM_MODEL: The model name to use (required)
- LLM_BASE_URL: The base URL for the LLM API (optional)
- LLM_API_KEY: The API key for the LLM service (optional)
- LLM_PROVIDER: The provider (e.g., 'openai', 'anthropic', 'ollama') (optional)
Returns:
LLM: A CrewAI LLM instance
"""
model = os.getenv("LLM_MODEL")
if not model:
raise ValueError("LLM_MODEL environment variable is required")
base_url = os.getenv("LLM_BASE_URL")
api_key = os.getenv("LLM_API_KEY")
provider = os.getenv("LLM_PROVIDER")
# Prepare LLM configuration
llm_config = {
"model": model,
}
if base_url:
llm_config["base_url"] = base_url
if api_key:
llm_config["api_key"] = api_key
if provider:
llm_config["provider"] = provider
return LLM(**llm_config)
# Shared LLM singleton
_shared_llm: Optional[LLM] = None
def get_llm() -> LLM:
"""
Get the shared LLM singleton instance.
Returns:
LLM: The shared LLM instance
"""
global _shared_llm
if _shared_llm is None:
_shared_llm = create_llm()
return _shared_llm
+297
View File
@@ -0,0 +1,297 @@
import logging
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
import uvicorn
from typing import Dict, Any, List, Optional
import asyncio
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
import time
import uuid
from .flow import CodeReviewFlow
from .state import PRReviewState, FileInfo, ContextOverrides
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(
title="PR Reviewer API",
description="API for conducting automated pull request reviews",
version="0.1.0"
)
# Configuration for timeouts
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
@app.get("/api/v1/health")
async def health_check() -> Dict[str, str]:
"""
Health check endpoint to verify the service is running.
"""
return {"status": "healthy", "service": "pr-reviewer"}
@app.post("/api/v1/review")
async def review_pr(request: Request) -> Dict[str, Any]:
"""
Endpoint to trigger a PR review.
Implements the full request/response schema as specified in Section 2.2.1.
"""
start_time = time.time()
review_id = str(uuid.uuid4())
# Log the incoming request (excluding sensitive data)
logger.info(f"Received PR review request: {review_id}")
try:
# Parse the JSON payload
try:
payload = await request.json()
except Exception:
raise HTTPException(status_code=422, detail="Invalid JSON payload")
# Validate and extract required fields according to the API specification
# Request schema:
# {
# "pr_id": "string (required)",
# "title": "string (required)",
# "description": "string (optional)",
# "repo": {
# "name": "string (required)",
# "url": "string (required)"
# },
# "source": {
# "branch": "string (required)",
# "commit": "string (optional)"
# },
# "target": {
# "branch": "string (required)",
# "commit": "string (optional)"
# },
# "files": [
# {
# "path": "string (required)",
# "content": "string (optional)",
# "status": "string (required)",
# "additions": "integer (optional, default 0)",
# "deletions": "integer (optional, default 0)",
# "patch": "string (optional)"
# }
# ],
# "context": {
# "code_review": "string (optional)",
# "security_review": "string (optional)",
# "infra_review": "string (optional)"
# }
# }
# Extract top-level fields
pr_id = payload.get("pr_id")
title = payload.get("title")
description = payload.get("description")
# Extract repo information
repo_data = payload.get("repo", {})
repo_name = repo_data.get("name")
repo_url = repo_data.get("url")
# Extract source information
source_data = payload.get("source", {})
source_branch = source_data.get("branch")
source_commit = source_data.get("commit")
# Extract target information
target_data = payload.get("target", {})
target_branch = target_data.get("branch")
target_commit = target_data.get("commit")
# Extract files
files_data = payload.get("files", [])
# Extract context overrides
context_data = payload.get("context", {})
# Validate required fields
if not pr_id:
raise HTTPException(status_code=422, detail="Missing required field: pr_id")
if not title:
raise HTTPException(status_code=422, detail="Missing required field: title")
if not repo_name:
raise HTTPException(status_code=422, detail="Missing required field: repo.name")
if not repo_url:
raise HTTPException(status_code=422, detail="Missing required field: repo.url")
if not source_branch:
raise HTTPException(status_code=422, detail="Missing required field: source.branch")
if not target_branch:
raise HTTPException(status_code=422, detail="Missing required field: target.branch")
# Convert files data to FileInfo objects
files = []
for file_data in files_data:
if not file_data.get("path"):
raise HTTPException(status_code=422, detail="Missing required field: files[].path")
if not file_data.get("status"):
raise HTTPException(status_code=422, detail="Missing required field: files[].status")
file_info = FileInfo(
path=file_data.get("path", ""),
content=file_data.get("content"),
status=file_data.get("status", "modified"),
additions=file_data.get("additions", 0),
deletions=file_data.get("deletions", 0),
patch=file_data.get("patch")
)
files.append(file_info)
# Create context overrides object (only if at least one field is provided)
context_overrides = None
if any([context_data.get("code_review"),
context_data.get("security_review"),
context_data.get("infra_review")]):
context_overrides = ContextOverrides(
code_review=context_data.get("code_review"),
security_review=context_data.get("security_review"),
infra_review=context_data.get("infra_review")
)
# Initialize and run the flow with timeout
flow = CodeReviewFlow()
# Run the flow in a thread pool with timeout to avoid blocking the event loop
loop = asyncio.get_event_loop()
with ThreadPoolExecutor() as pool:
try:
# Wait for the flow to complete with a timeout
flow_result = await asyncio.wait_for(
loop.run_in_executor(
pool,
lambda: flow.kickoff(inputs={
"pr_id": pr_id,
"pr_title": title,
"pr_description": description,
"pr_url": f"{repo_url}/pull/{pr_id}", # Construct PR URL
"repo_name": repo_name,
"repo_url": repo_url,
"branch": source_branch, # Using source branch as the active branch
"base_branch": target_branch, # Using target branch as base
"files": [file.dict() for file in files], # Convert to dict for flow
"context_overrides": context_overrides.dict() if context_overrides else None
})
),
timeout=TOTAL_FLOW_TIMEOUT
)
except asyncio.TimeoutError:
logger.error(f"PR review timed out: {review_id}")
raise HTTPException(
status_code=504,
detail=f"PR review timed out after {TOTAL_FLOW_TIMEOUT} seconds"
)
# Calculate processing time
processing_time = time.time() - start_time
# Prepare response according to the API specification:
# {
# "review_id": "string (unique identifier for this review)",
# "status": "string (\"completed\", \"failed\", etc.)",
# "timestamp": "string (ISO 8601 timestamp)",
# "results": {
# "code_review": "string (results from code review)",
# "security_review": "string (results from security review)",
# "infra_review": "string (results from infrastructure review)",
# "summary": "string (synthesized review summary)"
# },
# "metadata": {
# "processing_time_seconds": "number",
# "pr_id": "string",
# "repo": {
# "name": "string",
# "url": "string"
# }
# }
# }
response = {
"review_id": review_id,
"status": "completed" if not flow_result.get("error") else "failed",
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"results": {
"code_review": flow_result.get("code_review_results"),
"security_review": flow_result.get("security_review_results"),
"infra_review": flow_result.get("infra_review_results"),
"summary": flow_result.get("review_summary")
},
"metadata": {
"processing_time_seconds": round(processing_time, 2),
"pr_id": pr_id,
"repo": {
"name": repo_name,
"url": repo_url
}
}
}
# Include error information if present
if flow_result.get("error"):
response["metadata"]["error"] = flow_result["error"]
logger.error(f"PR review failed: {review_id} - {flow_result['error']}")
else:
logger.info(f"PR review completed successfully: {review_id} in {processing_time:.2f}s")
return response
except HTTPException:
# Re-raise HTTP exceptions as they are already properly formatted
raise
except asyncio.TimeoutError:
# This should be caught by the wait_for above, but just in case
logger.error(f"PR review timed out: {review_id}")
raise HTTPException(
status_code=504,
detail=f"PR review timed out after {TOTAL_FLOW_TIMEOUT} seconds"
)
except Exception as e:
# Log the error for debugging
logger.error(f"Error in PR review: {review_id} - {str(e)}")
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
# Error handlers
@app.exception_handler(404)
async def not_found_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=404,
content={"message": "Endpoint not found"}
)
@app.exception_handler(422)
async def request_validation_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=422,
content={"message": "Invalid request payload", "details": exc.detail}
)
@app.exception_handler(500)
async def internal_error_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=500,
content={"message": "Internal server error"}
)
@app.exception_handler(504)
async def timeout_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=504,
content={"message": "Request timeout", "details": exc.detail}
)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
+45
View File
@@ -0,0 +1,45 @@
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from datetime import datetime
class FileInfo(BaseModel):
"""Information about a file in the PR."""
path: str
content: Optional[str] = None
status: str # added, modified, removed, etc.
additions: int = 0
deletions: int = 0
patch: Optional[str] = None
class ContextOverrides(BaseModel):
"""Overrides for the default context."""
code_review: Optional[str] = None
security_review: Optional[str] = None
infra_review: Optional[str] = None
class PRReviewState(BaseModel):
"""State of the PR review process."""
# Input fields
pr_id: str
pr_title: str
pr_description: Optional[str] = None
pr_url: Optional[str] = None
repo_name: str
repo_url: str
branch: str
base_branch: str
files: List[FileInfo] = Field(default_factory=list)
context_overrides: Optional[ContextOverrides] = None
# Internal fields
resolved_context: Optional[Dict[str, str]] = None
code_review_results: Optional[str] = None
security_review_results: Optional[str] = None
infra_review_results: Optional[str] = None
review_summary: Optional[str] = None
# Metadata
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
error: Optional[str] = None