Signed-off-by: hsliu <liuhongsheng4@huawei.com> Signed-off-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
630 lines
18 KiB
Python
Executable File
630 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
PR Reviewer using GLM API for vllm-omni project.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Any, TypedDict
|
|
|
|
import requests
|
|
|
|
|
|
# Type definitions for API responses
|
|
class PRDetails(TypedDict):
|
|
"""Type definition for GitHub PR details response."""
|
|
|
|
title: str
|
|
body: str
|
|
number: int
|
|
state: str
|
|
user: dict[str, Any]
|
|
|
|
|
|
class GLMMessage(TypedDict):
|
|
"""Type definition for GLM API message."""
|
|
|
|
role: str
|
|
content: str
|
|
|
|
|
|
class GLMChoice(TypedDict):
|
|
"""Type definition for GLM API choice."""
|
|
|
|
message: GLMMessage
|
|
finish_reason: str
|
|
|
|
|
|
class GLMResponse(TypedDict):
|
|
"""Type definition for GLM API response."""
|
|
|
|
choices: list[GLMChoice]
|
|
usage: dict[str, int] | None
|
|
|
|
|
|
class GitHubComment(TypedDict):
|
|
"""Type definition for GitHub comment."""
|
|
|
|
id: int
|
|
body: str
|
|
created_at: str
|
|
user: dict[str, Any]
|
|
|
|
|
|
# Configuration
|
|
TRIGGER_PHRASE: str = "@vllm-omni-reviewer"
|
|
DEFAULT_GLM_API_URL: str = "https://open.bigmodel.cn/api/paas/v4/chat/completions" # noqa: E501
|
|
DEFAULT_GLM_MODEL: str = "glm-5"
|
|
DEFAULT_COOLDOWN_MINUTES: int = 5
|
|
DEFAULT_MAX_RETRIES: int = 3
|
|
DEFAULT_RETRY_DELAY: float = 1.0
|
|
MAX_DIFF_SIZE: int = 100_000 # Maximum diff size in characters
|
|
|
|
|
|
@dataclass
|
|
class Config:
|
|
"""Configuration for the PR reviewer."""
|
|
|
|
glm_api_url: str
|
|
glm_model: str
|
|
cooldown_minutes: int
|
|
max_retries: int
|
|
retry_delay: float
|
|
max_diff_size: int
|
|
|
|
|
|
# Setup logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="[PR Reviewer] %(asctime)s - %(levelname)s - %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
)
|
|
logger: logging.Logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_config() -> Config:
|
|
"""Load configuration from environment variables with defaults."""
|
|
return Config(
|
|
glm_api_url=os.getenv("GLM_API_URL", DEFAULT_GLM_API_URL),
|
|
glm_model=os.getenv("GLM_MODEL", DEFAULT_GLM_MODEL),
|
|
cooldown_minutes=int(
|
|
os.getenv(
|
|
"PR_REVIEWER_COOLDOWN_MINUTES",
|
|
str(DEFAULT_COOLDOWN_MINUTES),
|
|
)
|
|
),
|
|
max_retries=int(
|
|
os.getenv(
|
|
"PR_REVIEWER_MAX_RETRIES",
|
|
str(DEFAULT_MAX_RETRIES),
|
|
)
|
|
),
|
|
retry_delay=float(os.getenv("PR_REVIEWER_RETRY_DELAY", str(DEFAULT_RETRY_DELAY))),
|
|
max_diff_size=int(os.getenv("PR_REVIEWER_MAX_DIFF_SIZE", str(MAX_DIFF_SIZE))), # noqa: E501
|
|
)
|
|
|
|
|
|
def get_env_var(name: str) -> str:
|
|
"""
|
|
Get an environment variable or raise an error.
|
|
|
|
Args:
|
|
name: Name of the environment variable.
|
|
|
|
Returns:
|
|
The value of the environment variable.
|
|
|
|
Raises:
|
|
SystemExit: If the environment variable is not set.
|
|
"""
|
|
value = os.environ.get(name)
|
|
if not value:
|
|
logger.error(f"Environment variable {name} is not set")
|
|
sys.exit(1)
|
|
return value
|
|
|
|
|
|
def check_trigger(comment_body: str) -> bool:
|
|
"""
|
|
Check if the comment contains the trigger phrase.
|
|
|
|
Args:
|
|
comment_body: The body of the comment to check.
|
|
|
|
Returns:
|
|
True if the trigger phrase is found, False otherwise.
|
|
"""
|
|
return TRIGGER_PHRASE in comment_body
|
|
|
|
|
|
def fetch_pr_diff(
|
|
repo_name: str,
|
|
pr_number: int,
|
|
token: str,
|
|
max_size: int = MAX_DIFF_SIZE,
|
|
) -> str | None:
|
|
"""
|
|
Fetch the diff for a pull request.
|
|
|
|
Args:
|
|
repo_name: The repository name in format "owner/repo".
|
|
pr_number: The pull request number.
|
|
token: GitHub authentication token.
|
|
max_size: Maximum diff size in characters.
|
|
|
|
Returns:
|
|
The diff content as a string, or None if fetching failed.
|
|
Returns empty string if diff is larger than max_size.
|
|
"""
|
|
url: str = f"https://api.github.com/repos/{repo_name}/pulls/{pr_number}"
|
|
headers: dict[str, str] = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/vnd.github.v3.diff",
|
|
}
|
|
|
|
logger.info(f"Fetching PR diff from {url}")
|
|
response = requests.get(url, headers=headers, timeout=30)
|
|
|
|
if response.status_code == 200:
|
|
diff: str = response.text
|
|
if len(diff) > max_size:
|
|
logger.warning(
|
|
f"Diff size ({len(diff)} bytes) exceeds maximum "
|
|
f"({max_size} bytes), truncating to first "
|
|
f"{max_size} bytes"
|
|
)
|
|
return diff[:max_size] + "\n\n... [Diff truncated due to size] ..."
|
|
logger.info(f"Successfully fetched diff ({len(diff)} bytes)")
|
|
return diff
|
|
else:
|
|
logger.error(f"Failed to fetch PR diff: {response.status_code}")
|
|
logger.error(f"Response: {response.text}")
|
|
return None
|
|
|
|
|
|
def fetch_pr_details(
|
|
repo_name: str,
|
|
pr_number: int,
|
|
token: str,
|
|
) -> PRDetails | None:
|
|
"""
|
|
Fetch PR details including title and description.
|
|
|
|
Args:
|
|
repo_name: The repository name in format "owner/repo".
|
|
pr_number: The pull request number.
|
|
token: GitHub authentication token.
|
|
|
|
Returns:
|
|
A dictionary containing PR details, or None if fetching failed.
|
|
"""
|
|
url: str = f"https://api.github.com/repos/{repo_name}/pulls/{pr_number}"
|
|
headers: dict[str, str] = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/vnd.github.v3+json",
|
|
}
|
|
|
|
logger.info(f"Fetching PR details from {url}")
|
|
response = requests.get(url, headers=headers, timeout=30)
|
|
|
|
if response.status_code == 200:
|
|
return response.json()
|
|
else:
|
|
logger.error(f"Failed to fetch PR details: {response.status_code}")
|
|
return None
|
|
|
|
|
|
def build_review_prompt(pr_title: str, pr_description: str, diff: str) -> str:
|
|
"""
|
|
Build the prompt for the GLM-4.7 API.
|
|
|
|
Args:
|
|
pr_title: The title of the pull request.
|
|
pr_description: The description/body of the pull request.
|
|
diff: The diff content of the pull request.
|
|
|
|
Returns:
|
|
The formatted prompt string for the API.
|
|
"""
|
|
return f"""You are an expert code reviewer for the VLLM-Omni project. \
|
|
Please review the following pull request:
|
|
|
|
## Pull Request Details
|
|
**Title:** {pr_title}
|
|
|
|
**Description:**
|
|
{pr_description if pr_description else "No description provided."}
|
|
|
|
## Code Changes (Diff)
|
|
{diff}
|
|
|
|
## Review Guidelines
|
|
|
|
Please provide a comprehensive code review with the following sections:
|
|
|
|
### 1. Overview
|
|
- Brief summary of the changes
|
|
- Overall assessment (positive, neutral, or concerns)
|
|
|
|
### 2. Code Quality
|
|
- Code style and consistency
|
|
- Potential bugs or edge cases
|
|
- Performance considerations
|
|
- Error handling
|
|
|
|
### 3. Architecture & Design
|
|
- Integration with existing codebase
|
|
- Design patterns and best practices
|
|
- Potential improvements
|
|
|
|
### 4. Security & Safety
|
|
- Security concerns (if any)
|
|
- Resource management
|
|
- Input validation
|
|
|
|
### 5. Testing & Documentation
|
|
- Test coverage considerations
|
|
- Documentation completeness
|
|
- Examples and usage clarity
|
|
|
|
### 6. Specific Suggestions
|
|
- Line-by-line specific feedback (use `file:line` format)
|
|
- Concrete actionable suggestions
|
|
- Code examples for improvements (if applicable)
|
|
|
|
### 7. Approval Status
|
|
- **LGTM** (Looks Good To Me) if the PR is ready to merge
|
|
- **LGTM with suggestions** if the PR is good but has minor suggestions
|
|
- **Changes requested** if significant changes are needed
|
|
|
|
## Important Notes
|
|
- Be constructive and helpful
|
|
- Focus on objective technical feedback
|
|
- Acknowledge good practices when you see them
|
|
- Prioritize critical issues over nitpicks
|
|
- If the diff is empty or minimal, acknowledge this and provide
|
|
any relevant context-specific guidance
|
|
|
|
Please format your response in Markdown with clear section headers.
|
|
"""
|
|
|
|
|
|
def validate_glm_response(data: dict[str, Any]) -> str | None:
|
|
"""
|
|
Validate and extract content from GLM API response.
|
|
|
|
Args:
|
|
data: The response data from GLM API.
|
|
|
|
Returns:
|
|
The review content string if valid, None otherwise.
|
|
"""
|
|
# Check if choices exists and is a non-empty list
|
|
if "choices" not in data:
|
|
logger.error("GLM API response missing 'choices' field")
|
|
logger.error(f"Response structure: {json.dumps(data, indent=2)}")
|
|
return None
|
|
|
|
choices = data["choices"]
|
|
if not isinstance(choices, list):
|
|
logger.error(f"GLM API 'choices' is not a list: {type(choices)}")
|
|
return None
|
|
|
|
if len(choices) == 0:
|
|
logger.error("GLM API 'choices' is an empty list")
|
|
return None
|
|
|
|
# Check if first choice has message
|
|
try:
|
|
first_choice = choices[0]
|
|
if not isinstance(first_choice, dict):
|
|
logger.error(f"GLM API choice is not a dict: {type(first_choice)}")
|
|
return None
|
|
|
|
if "message" not in first_choice:
|
|
logger.error("GLM API choice missing 'message' field")
|
|
logger.error(f"Choice structure: {json.dumps(first_choice, indent=2)}") # noqa: E501
|
|
return None
|
|
|
|
message = first_choice["message"]
|
|
if not isinstance(message, dict):
|
|
logger.error(f"GLM API message is not a dict: {type(message)}")
|
|
return None
|
|
|
|
if "content" not in message:
|
|
logger.error("GLM API message missing 'content' field")
|
|
logger.error(f"Message structure: {json.dumps(message, indent=2)}")
|
|
return None
|
|
|
|
content = message["content"]
|
|
if not isinstance(content, str):
|
|
logger.error(f"GLM API content is not a string: {type(content)}")
|
|
return None
|
|
|
|
return content
|
|
|
|
except (KeyError, IndexError, TypeError) as e:
|
|
logger.error(f"Failed to parse GLM API response: {e}")
|
|
logger.error(f"Response: {json.dumps(data, indent=2)}")
|
|
return None
|
|
|
|
|
|
def call_glm_api(prompt: str, api_key: str, config: Config) -> str | None:
|
|
"""
|
|
Call the GLM-4.7 API to get code review with retry logic.
|
|
|
|
Args:
|
|
prompt: The prompt to send to the API.
|
|
api_key: The GLM API key.
|
|
config: Configuration object.
|
|
|
|
Returns:
|
|
The review content as a string, or None if all retries failed.
|
|
"""
|
|
headers: dict[str, str] = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
payload: dict[str, Any] = {
|
|
"model": config.glm_model,
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
"temperature": 0.3,
|
|
"max_tokens": 32000,
|
|
"top_p": 0.9,
|
|
}
|
|
|
|
last_error: str | None = None
|
|
|
|
for attempt in range(config.max_retries):
|
|
try:
|
|
logger.info(f"Calling GLM API ({config.glm_model}) - Attempt {attempt + 1}/{config.max_retries}")
|
|
response = requests.post(
|
|
config.glm_api_url,
|
|
headers=headers,
|
|
json=payload,
|
|
timeout=120,
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
review = validate_glm_response(data)
|
|
if review:
|
|
logger.info(f"Successfully received review ({len(review)} chars)") # noqa: E501
|
|
return review
|
|
else:
|
|
last_error = "Failed to validate API response structure"
|
|
logger.error(last_error)
|
|
else:
|
|
last_error = f"GLM API request failed: {response.status_code} - {response.text}"
|
|
logger.error(last_error)
|
|
|
|
except requests.exceptions.Timeout:
|
|
last_error = f"GLM API request timed out (attempt {attempt + 1})"
|
|
logger.error(last_error)
|
|
except requests.exceptions.RequestException as e:
|
|
last_error = f"GLM API request exception: {e}"
|
|
logger.error(last_error)
|
|
except json.JSONDecodeError as e:
|
|
last_error = f"Failed to decode GLM API response as JSON: {e}"
|
|
logger.error(last_error)
|
|
|
|
# Exponential backoff before retry
|
|
if attempt < config.max_retries - 1:
|
|
wait_time: float = config.retry_delay * (2**attempt)
|
|
logger.info(f"Waiting {wait_time}s before retry...") # noqa: E501
|
|
time.sleep(wait_time)
|
|
|
|
logger.error(
|
|
f"All {config.max_retries} attempts failed. Last error: {last_error}" # noqa: E501
|
|
)
|
|
return None
|
|
|
|
|
|
def check_cooldown( # noqa: E501
|
|
repo_name: str,
|
|
pr_number: int,
|
|
token: str,
|
|
cooldown_minutes: int,
|
|
) -> bool:
|
|
"""
|
|
Check if the PR is within the cooldown period.
|
|
|
|
Args:
|
|
repo_name: The repository name in format "owner/repo".
|
|
pr_number: The pull request number.
|
|
token: GitHub authentication token.
|
|
cooldown_minutes: Cooldown period in minutes.
|
|
|
|
Returns:
|
|
True if within cooldown period (should skip), False otherwise.
|
|
"""
|
|
from datetime import datetime, timedelta
|
|
|
|
url: str = (
|
|
f"https://api.github.com/repos/{repo_name}/issues/"
|
|
f"{pr_number}/comments" # noqa: E501
|
|
)
|
|
headers: dict[str, str] = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/vnd.github.v3+json",
|
|
}
|
|
|
|
logger.info(f"Checking cooldown period ({cooldown_minutes} minutes)")
|
|
response = requests.get(url, headers=headers, timeout=30)
|
|
|
|
if response.status_code != 200:
|
|
logger.warning(f"Failed to check cooldown: {response.status_code}, proceeding with review")
|
|
return False
|
|
|
|
comments: list[dict[str, Any]] = response.json()
|
|
cutoff_time: datetime = datetime.utcnow() - timedelta(minutes=cooldown_minutes) # noqa: E501
|
|
|
|
for comment in reversed(comments):
|
|
# Check if this is a bot comment
|
|
body: str = comment.get("body", "")
|
|
if "VLLM-Omni PR Review" in body or "PR Reviewer Bot" in body:
|
|
created_at_str: str = comment.get("created_at", "")
|
|
try:
|
|
# Parse GitHub timestamp format
|
|
created_at = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
|
|
created_at = created_at.replace(tzinfo=None)
|
|
if created_at > cutoff_time:
|
|
logger.info(f"PR is within cooldown period (last review: {created_at_str})")
|
|
return True
|
|
except ValueError:
|
|
logger.warning(f"Failed to parse comment timestamp: {created_at_str}") # noqa: E501
|
|
continue
|
|
|
|
logger.info("PR is outside cooldown period, proceeding with review")
|
|
return False
|
|
|
|
|
|
def post_review_comment( # noqa: E501
|
|
repo_name: str,
|
|
pr_number: int,
|
|
token: str,
|
|
review: str,
|
|
) -> bool:
|
|
"""
|
|
Post the review as a comment on the PR.
|
|
|
|
Args:
|
|
repo_name: The repository name in format "owner/repo".
|
|
pr_number: The pull request number.
|
|
token: GitHub authentication token.
|
|
review: The review content to post.
|
|
|
|
Returns:
|
|
True if posting succeeded, False otherwise.
|
|
"""
|
|
url: str = (
|
|
f"https://api.github.com/repos/{repo_name}/issues/"
|
|
f"{pr_number}/comments" # noqa: E501
|
|
)
|
|
headers: dict[str, str] = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/vnd.github.v3+json",
|
|
}
|
|
|
|
# Format the review comment
|
|
comment_body: str = f"""## 🤖 VLLM-Omni PR Review
|
|
|
|
{review}
|
|
|
|
---
|
|
*This review was generated automatically by the VLLM-Omni PR Reviewer Bot
|
|
using {os.getenv("GLM_MODEL", DEFAULT_GLM_MODEL)}.*
|
|
"""
|
|
|
|
payload: dict[str, str] = {"body": comment_body}
|
|
|
|
logger.info(f"Posting review comment to PR #{pr_number}")
|
|
response = requests.post(url, headers=headers, json=payload, timeout=30)
|
|
|
|
if response.status_code == 201:
|
|
logger.info("Successfully posted review comment")
|
|
return True
|
|
else:
|
|
logger.error(f"Failed to post comment: {response.status_code}")
|
|
logger.error(f"Response: {response.text}")
|
|
return False
|
|
|
|
|
|
def main() -> int:
|
|
"""
|
|
Main entry point for the PR reviewer bot.
|
|
|
|
Returns:
|
|
0 on success, 1 on error.
|
|
"""
|
|
logger.info("VLLM-Omni PR Reviewer Bot starting...")
|
|
|
|
# Load configuration
|
|
config: Config = get_config()
|
|
logger.info(
|
|
f"Configuration: model={config.glm_model}, "
|
|
f"cooldown={config.cooldown_minutes}min, "
|
|
f"max_retries={config.max_retries}"
|
|
)
|
|
|
|
# Get environment variables
|
|
token: str = get_env_var("GITHUB_TOKEN")
|
|
api_key: str = get_env_var("GLM_API_KEY")
|
|
repo_name: str = get_env_var("REPO_NAME")
|
|
pr_number_str: str = get_env_var("PR_NUMBER")
|
|
comment_body: str = get_env_var("COMMENT_BODY")
|
|
|
|
try:
|
|
pr_number: int = int(pr_number_str)
|
|
except ValueError:
|
|
logger.error(f"Invalid PR number: {pr_number_str}")
|
|
return 1
|
|
|
|
logger.info(f"Repository: {repo_name}")
|
|
logger.info(f"PR Number: {pr_number}")
|
|
|
|
# Check if the comment contains the trigger phrase
|
|
if not check_trigger(comment_body):
|
|
logger.info(
|
|
f"Comment does not contain trigger phrase '{TRIGGER_PHRASE}', exiting" # noqa: E501
|
|
)
|
|
return 0
|
|
|
|
logger.info("Trigger phrase detected! Starting review process...")
|
|
|
|
# Check cooldown period
|
|
if check_cooldown(repo_name, pr_number, token, config.cooldown_minutes):
|
|
logger.info("Skipping review due to cooldown period")
|
|
return 0
|
|
|
|
# Fetch PR details
|
|
logger.info("Step 1/4: Fetching PR details...")
|
|
pr_details: PRDetails | None = fetch_pr_details(repo_name, pr_number, token) # noqa: E501
|
|
if not pr_details:
|
|
logger.error("Failed to fetch PR details")
|
|
return 1
|
|
|
|
pr_title: str = pr_details.get("title", "Unknown")
|
|
pr_description: str = pr_details.get("body", "")
|
|
|
|
logger.info(f"PR Title: {pr_title}")
|
|
|
|
# Fetch PR diff
|
|
logger.info("Step 2/4: Fetching PR diff...")
|
|
diff: str | None = fetch_pr_diff(repo_name, pr_number, token, config.max_diff_size)
|
|
if diff is None:
|
|
logger.error("Failed to fetch PR diff")
|
|
return 1
|
|
|
|
if not diff:
|
|
logger.warning("Warning: Empty diff - this might be a draft PR or no code changes")
|
|
|
|
# Build prompt
|
|
logger.info("Step 3/4: Building review prompt...")
|
|
prompt: str = build_review_prompt(pr_title, pr_description, diff)
|
|
|
|
# Call GLM API
|
|
logger.info("Step 4/4: Calling GLM API...")
|
|
review: str | None = call_glm_api(prompt, api_key, config)
|
|
if not review:
|
|
logger.error("Failed to get review from GLM API")
|
|
return 1
|
|
|
|
# Post review comment
|
|
logger.info("Posting review comment...")
|
|
if not post_review_comment(repo_name, pr_number, token, review):
|
|
logger.error("Failed to post review comment")
|
|
return 1
|
|
|
|
logger.info("PR review completed successfully!")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|