initial
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user