Spaces:
Sleeping
Sleeping
import asyncio | |
import json | |
import os | |
import logging | |
from typing import Dict, List, Optional | |
from datetime import datetime | |
import sys | |
import discord | |
from discord.ext import commands | |
import aiohttp | |
import anthropic | |
from github import Github | |
from flask import Flask, request, jsonify | |
import threading | |
import time | |
# Configure logging | |
logging.basicConfig(level=logging.INFO) | |
logger = logging.getLogger(__name__) | |
class GitHubCodeReviewBot: | |
def __init__(self): | |
# Configuration from environment variables | |
self.discord_token = os.getenv('DISCORD_TOKEN') | |
self.github_token = os.getenv('GITHUB_TOKEN') | |
self.anthropic_api_key = os.getenv('ANTHROPIC_API_KEY') | |
self.target_repo = os.getenv('TARGET_REPO') # format: "owner/repo" | |
self.target_user = os.getenv('TARGET_USER') # GitHub username to monitor | |
self.discord_channel_id = int(os.getenv('DISCORD_CHANNEL_ID', 0)) | |
self.webhook_secret = os.getenv('WEBHOOK_SECRET', '') | |
# Validate required environment variables | |
self._validate_config() | |
# Initialize clients with error handling | |
self._initialize_clients() | |
# Discord bot setup | |
intents = discord.Intents.default() | |
intents.message_content = True | |
self.bot = commands.Bot(command_prefix='!', intents=intents) | |
# Flask app for webhook | |
self.app = Flask(__name__) | |
self.setup_webhook_routes() | |
self.setup_discord_events() | |
def _validate_config(self): | |
"""Validate required configuration""" | |
required_vars = { | |
'DISCORD_TOKEN': self.discord_token, | |
'GITHUB_TOKEN': self.github_token, | |
'ANTHROPIC_API_KEY': self.anthropic_api_key, | |
'TARGET_REPO': self.target_repo, | |
'TARGET_USER': self.target_user, | |
'DISCORD_CHANNEL_ID': self.discord_channel_id | |
} | |
missing_vars = [var for var, value in required_vars.items() if not value] | |
if missing_vars: | |
logger.error(f"Missing required environment variables: {missing_vars}") | |
sys.exit(1) | |
def _initialize_clients(self): | |
"""Initialize API clients with error handling""" | |
try: | |
# For classic tokens, no special configuration needed | |
self.github_client = Github(self.github_token) | |
# Test the connection | |
user = self.github_client.get_user() | |
logger.info(f"GitHub client initialized successfully as user: {user.login}") | |
except Exception as e: | |
logger.error(f"Failed to initialize GitHub client: {e}") | |
sys.exit(1) | |
try: | |
# Try different initialization methods for Anthropic | |
self.anthropic_client = anthropic.Anthropic(api_key=self.anthropic_api_key) | |
logger.info("Anthropic client initialized successfully") | |
except Exception as e: | |
logger.error(f"Failed to initialize Anthropic client: {e}") | |
logger.info("Attempting alternative initialization...") | |
try: | |
# Alternative initialization | |
import anthropic | |
self.anthropic_client = anthropic.Client(api_key=self.anthropic_api_key) | |
logger.info("Anthropic client initialized with alternative method") | |
except Exception as e2: | |
logger.error(f"Alternative initialization also failed: {e2}") | |
logger.warning("Anthropic client not available - code review will be disabled") | |
self.anthropic_client = None | |
def setup_webhook_routes(self): | |
"""Setup Flask routes for GitHub webhook""" | |
def handle_webhook(): | |
try: | |
payload = request.get_json() | |
if not payload: | |
return jsonify({'error': 'No payload received'}), 400 | |
# Verify webhook (basic security) | |
if self.webhook_secret: | |
signature = request.headers.get('X-Hub-Signature-256') | |
if not self.verify_webhook_signature(request.data, signature): | |
logger.warning("Invalid webhook signature") | |
return jsonify({'error': 'Invalid signature'}), 401 | |
# Handle push events | |
if 'commits' in payload and payload.get('commits'): | |
logger.info(f"Received push event for {payload['repository']['full_name']}") | |
asyncio.run_coroutine_threadsafe( | |
self.handle_push_event(payload), | |
self.bot.loop | |
) | |
return jsonify({'status': 'ok'}), 200 | |
except Exception as e: | |
logger.error(f"Webhook error: {e}") | |
return jsonify({'error': str(e)}), 500 | |
def health_check(): | |
return jsonify({ | |
'status': 'healthy', | |
'timestamp': datetime.now().isoformat(), | |
'monitoring': f"{self.target_repo} for user {self.target_user}" | |
}), 200 | |
def verify_webhook_signature(self, payload_body, signature_header): | |
"""Verify GitHub webhook signature""" | |
import hmac | |
import hashlib | |
if not signature_header: | |
return False | |
try: | |
hash_object = hmac.new( | |
self.webhook_secret.encode('utf-8'), | |
payload_body, | |
hashlib.sha256 | |
) | |
expected_signature = "sha256=" + hash_object.hexdigest() | |
return hmac.compare_digest(expected_signature, signature_header) | |
except Exception as e: | |
logger.error(f"Signature verification error: {e}") | |
return False | |
def setup_discord_events(self): | |
"""Setup Discord bot events""" | |
async def on_ready(): | |
logger.info(f'{self.bot.user} has connected to Discord!') | |
# Test channel access | |
try: | |
channel = self.bot.get_channel(self.discord_channel_id) | |
if channel: | |
logger.info(f"Successfully connected to Discord channel: {channel.name}") | |
await channel.send("π€ Code Review Bot is now online and monitoring commits!") | |
else: | |
logger.error(f"Could not find Discord channel with ID: {self.discord_channel_id}") | |
except Exception as e: | |
logger.error(f"Error sending startup message: {e}") | |
async def on_error(event, *args, **kwargs): | |
logger.error(f"Discord error in {event}: {args}, {kwargs}") | |
async def status(ctx): | |
"""Check bot status""" | |
status_msg = f""" | |
π€ **Bot Status** | |
π Monitoring: `{self.target_repo}` | |
π€ Target User: `{self.target_user}` | |
π§ Anthropic API: {'β Connected' if self.anthropic_client else 'β Disconnected'} | |
π GitHub API: {'β Connected' if self.github_client else 'β Disconnected'} | |
""" | |
await ctx.send(status_msg) | |
async def test(ctx): | |
"""Test bot functionality""" | |
await ctx.send("π§ͺ Running test...") | |
# Test GitHub API | |
try: | |
repo = self.github_client.get_repo(self.target_repo) | |
await ctx.send(f"β GitHub API working - Repository: {repo.full_name}") | |
except Exception as e: | |
await ctx.send(f"β GitHub API error: {str(e)}") | |
# Test Anthropic API | |
if self.anthropic_client: | |
try: | |
response = self.anthropic_client.messages.create( | |
model="claude-3-5-sonnet-20241022", # β FIXED MODEL NAME | |
max_tokens=50, | |
messages=[{"role": "user", "content": "Hello, please respond with 'API test successful'"}] | |
) | |
await ctx.send(f"β Anthropic API working: {response.content[0].text}") | |
except Exception as e: | |
await ctx.send(f"β Anthropic API error: {str(e)}") | |
else: | |
await ctx.send("β Anthropic API not initialized") | |
async def handle_push_event(self, payload): | |
"""Handle GitHub push event""" | |
try: | |
# Check if the push is from our target repository | |
if payload['repository']['full_name'] != self.target_repo: | |
logger.info(f"Ignoring push from {payload['repository']['full_name']} (not target repo)") | |
return | |
# Check commits for our target user | |
target_commits = [] | |
for commit in payload.get('commits', []): | |
author = commit.get('author', {}) | |
if author.get('username') == self.target_user: | |
target_commits.append(commit) | |
if not target_commits: | |
logger.info(f"No commits from target user {self.target_user}") | |
return | |
logger.info(f"Found {len(target_commits)} commits from {self.target_user}") | |
for commit in target_commits: | |
await self.process_commit(commit, payload['repository']) | |
# Add delay between commits to avoid rate limiting | |
await asyncio.sleep(2) | |
except Exception as e: | |
logger.error(f"Error handling push event: {e}") | |
await self.send_error_notification(f"Error processing push event: {str(e)}") | |
async def process_commit(self, commit, repo_info): | |
"""Process a commit from the target user""" | |
try: | |
logger.info(f"Processing commit: {commit['id']}") | |
# Get repository object | |
repo = self.github_client.get_repo(self.target_repo) | |
commit_obj = repo.get_commit(commit['id']) | |
# Get modified files | |
modified_files = [] | |
for file in commit_obj.files: | |
if file.status in ['modified', 'added'] and self.is_code_file(file.filename): | |
try: | |
file_content = repo.get_contents(file.filename, ref=commit['id']) | |
modified_files.append({ | |
'filename': file.filename, | |
'content': file_content.decoded_content.decode('utf-8'), | |
'patch': file.patch, | |
'status': file.status | |
}) | |
except Exception as e: | |
logger.warning(f"Could not read file {file.filename}: {e}") | |
if not modified_files: | |
logger.info(f"No code files to review in commit {commit['id']}") | |
return | |
logger.info(f"Found {len(modified_files)} files to review") | |
# Send to Claude for review | |
review_result = await self.review_with_claude(modified_files, commit) | |
# Send to Discord | |
await self.send_discord_notification(commit, review_result, modified_files) | |
# NEW: Create issue for problems | |
issue = await self.create_issue_for_problems(commit, review_result, modified_files) | |
# NEW: Generate improvements and create PR | |
if issue: # Only create PR if there are issues to fix | |
improvements = await self.generate_code_improvements(modified_files, review_result) | |
if improvements: | |
pr = await self.create_improvement_pull_request(commit, review_result, modified_files, improvements) | |
# Update Discord notification with issue and PR info | |
await self.send_followup_notification(issue, pr) | |
except Exception as e: | |
logger.error(f"Error processing commit: {e}") | |
await self.send_error_notification(f"Error processing commit {commit['id']}: {str(e)}") | |
async def send_followup_notification(self, issue, pr): | |
"""Send follow-up notification about created issue/PR""" | |
try: | |
channel = self.bot.get_channel(self.discord_channel_id) | |
if not channel: | |
return | |
embed = discord.Embed( | |
title="π§ Automated Actions Taken", | |
color=0x00ff00, | |
timestamp=datetime.now() | |
) | |
if issue: | |
embed.add_field( | |
name="π Issue Created", | |
value=f"[#{issue.number}]({issue.html_url})", | |
inline=True | |
) | |
if pr: | |
embed.add_field( | |
name="π Pull Request Created", | |
value=f"[#{pr.number}]({pr.html_url})", | |
inline=True | |
) | |
await channel.send(embed=embed) | |
except Exception as e: | |
logger.error(f"Error sending followup notification: {e}") | |
def is_code_file(self, filename): | |
"""Check if file is a code file we should review""" | |
code_extensions = [ | |
'.py', '.js', '.ts', '.jsx', '.tsx', '.java', '.cpp', '.c', '.cs', | |
'.go', '.rs', '.php', '.rb', '.swift', '.kt', '.scala', '.r', '.m', | |
'.h', '.hpp', '.css', '.scss', '.sass', '.less', '.html', '.xml', | |
'.json', '.yml', '.yaml', '.sql', '.sh', '.bash', '.ps1' | |
] | |
return any(filename.endswith(ext) for ext in code_extensions) | |
async def review_with_claude(self, files, commit): | |
"""Send code to Claude for review""" | |
if not self.anthropic_client: | |
return { | |
'review': "Anthropic API not available - code review disabled", | |
'files_reviewed': len(files), | |
'commit_id': commit['id'], | |
'error': 'Anthropic API not initialized' | |
} | |
try: | |
# Prepare the prompt | |
files_content = "" | |
for file in files: | |
files_content += f"\n\n--- {file['filename']} ({file['status']}) ---\n" | |
files_content += file['content'][:5000] # Limit file content length | |
if len(file['content']) > 5000: | |
files_content += "\n... (content truncated) ..." | |
if file['patch']: | |
files_content += f"\n\n--- Changes in {file['filename']} ---\n" | |
files_content += file['patch'][:2000] # Limit patch length | |
prompt = f""" | |
Please review the following code files from a recent commit and provide a concise analysis: | |
**Commit Information:** | |
- Message: {commit['message']} | |
- ID: {commit['id']} | |
- Author: {commit['author']['name']} | |
**Files to Review:** | |
{files_content} | |
**Please provide:** | |
1. **Critical Issues**: Any bugs, errors, or security vulnerabilities | |
2. **Code Quality**: Key improvements for readability and maintainability | |
3. **Best Practices**: Important violations to address | |
4. **Overall Assessment**: Brief summary of code quality | |
Keep the response concise and focused on the most important issues. | |
""" | |
# Call Claude API with retry logic | |
max_retries = 3 | |
for attempt in range(max_retries): | |
try: | |
response = self.anthropic_client.messages.create( | |
model="claude-3-5-sonnet-20241022", # β FIXED MODEL NAME | |
max_tokens=2000, | |
messages=[{"role": "user", "content": prompt}] | |
) | |
return { | |
'review': response.content[0].text, | |
'files_reviewed': len(files), | |
'commit_id': commit['id'] | |
} | |
except Exception as api_error: | |
if attempt < max_retries - 1: | |
logger.warning(f"Claude API attempt {attempt + 1} failed: {api_error}") | |
await asyncio.sleep(2 ** attempt) # Exponential backoff | |
continue | |
else: | |
raise api_error | |
except Exception as e: | |
logger.error(f"Error calling Claude API: {e}") | |
return { | |
'review': f"Unable to complete code review due to API error: {str(e)[:200]}...", | |
'files_reviewed': len(files), | |
'commit_id': commit['id'], | |
'error': str(e) | |
} | |
async def create_issue_for_problems(self, commit, review_result, files): | |
"""Create GitHub issue for identified problems""" | |
try: | |
repo = self.github_client.get_repo(self.target_repo) | |
# Extract problems from Claude's review | |
problems = self.extract_problems_from_review(review_result['review']) | |
if not problems: | |
logger.info("No significant problems found, skipping issue creation") | |
return None | |
# Create issue title and body | |
issue_title = f"Code Review Issues - Commit {commit['id'][:7]}" | |
issue_body = f""" | |
## Code Review Issues Found | |
**Commit:** [{commit['id'][:7]}]({commit['url']}) | |
**Author:** {commit['author']['name']} | |
**Message:** {commit['message']} | |
### Files Reviewed: | |
{', '.join([f'`{f["filename"]}`' for f in files])} | |
### Issues Identified: | |
{problems} | |
### Full Review: | |
{review_result['review'][:2000]} | |
**Auto-generated by Code Review Bot** | |
""" | |
# Create the issue | |
issue = repo.create_issue( | |
title=issue_title, | |
body=issue_body, | |
labels=['code-review', 'auto-generated'] | |
) | |
logger.info(f"Created issue #{issue.number}: {issue.title}") | |
return issue | |
except Exception as e: | |
logger.error(f"Error creating issue: {e}") | |
return None | |
def extract_problems_from_review(self, review_text): | |
"""Extract actionable problems from Claude's review""" | |
problems = [] | |
# Simple parsing - look for sections with issues | |
lines = review_text.split('\n') | |
current_section = "" | |
for line in lines: | |
line = line.strip() | |
if any(keyword in line.lower() for keyword in ['critical', 'bug', 'error', 'security', 'issue']): | |
problems.append(f"- {line}") | |
elif line.startswith('β’') or line.startswith('-') or line.startswith('*'): | |
if any(keyword in line.lower() for keyword in ['should', 'missing', 'incorrect', 'vulnerable']): | |
problems.append(f"- {line}") | |
return '\n'.join(problems) if problems else "No critical issues identified." | |
async def generate_code_improvements(self, files, review_result): | |
"""Generate improved code using Claude""" | |
if not self.anthropic_client: | |
return None | |
try: | |
# Focus on the most critical file or first file | |
primary_file = files[0] | |
prompt = f""" | |
Based on the code review, please provide improved versions of the problematic code sections. | |
**Original File:** {primary_file['filename']} | |
**Review Findings:** {review_result['review'][:1000]} | |
**Current Code:** | |
{primary_file['content'][:3000]} | |
Please provide: | |
1. **Specific code improvements** - Show the exact code sections that need changes | |
2. **Explanation** - Brief explanation of each improvement | |
3. **Fixed code** - The corrected version | |
Format your response as: | |
## Issue 1: [Brief description] | |
**Problem:** [What's wrong] | |
**Solution:** [How to fix] | |
**Code:** | |
```python | |
[improved code] | |
Keep improvements focused and practical. | |
""" | |
response = self.anthropic_client.messages.create( | |
model="claude-3-5-sonnet-20241022", | |
max_tokens=3000, | |
messages=[{"role": "user", "content": prompt}] | |
) | |
return response.content[0].text | |
except Exception as e: | |
logger.error(f"Error generating code improvements: {e}") | |
return None | |
async def create_improvement_pull_request(self, commit, review_result, files, improvements): | |
"""Create a pull request with code improvements""" | |
try: | |
repo = self.github_client.get_repo(self.target_repo) | |
# Create a new branch for improvements | |
base_branch = repo.default_branch | |
new_branch = f"code-review-improvements-{commit['id'][:7]}" | |
# Get the base branch reference | |
base_ref = repo.get_git_ref(f"heads/{base_branch}") | |
# Create new branch | |
repo.create_git_ref( | |
ref=f"refs/heads/{new_branch}", | |
sha=base_ref.object.sha | |
) | |
# Apply improvements to files | |
files_updated = [] | |
for file_info in files: | |
if self.should_update_file(file_info, improvements): | |
improved_content = self.apply_improvements_to_file( | |
file_info['content'], | |
improvements | |
) | |
if improved_content and improved_content != file_info['content']: | |
# Update file in the new branch | |
file_obj = repo.get_contents(file_info['filename'], ref=new_branch) | |
repo.update_file( | |
file_info['filename'], | |
f"Code review improvements for {file_info['filename']}", | |
improved_content, | |
file_obj.sha, | |
branch=new_branch | |
) | |
files_updated.append(file_info['filename']) | |
if not files_updated: | |
logger.info("No files were updated, skipping PR creation") | |
# Clean up branch | |
repo.get_git_ref(f"heads/{new_branch}").delete() | |
return None | |
# Create pull request | |
pr_title = f"Code Review Improvements - Commit {commit['id'][:7]}" | |
pr_body = f""" | |
Automated Code Improvements | |
This PR contains improvements based on automated code review of commit {commit['id'][:7]}. | |
Original Commit: {commit['id'][:7]} | |
Author: {commit['author']['name']} | |
Files Updated: | |
{', '.join([f'{f}' for f in files_updated])} | |
Improvements Applied: | |
{improvements[:1500]} | |
Review Summary: | |
{review_result['review'][:1000]} | |
β οΈ Please review these changes carefully before merging | |
Auto-generated by Code Review Bot | |
""" | |
pr = repo.create_pull( | |
title=pr_title, | |
body=pr_body, | |
head=new_branch, | |
base=base_branch | |
) | |
logger.info(f"Created PR #{pr.number}: {pr.title}") | |
return pr | |
except Exception as e: | |
logger.error(f"Error creating pull request: {e}") | |
return None | |
def should_update_file(self, file_info, improvements): | |
"""Check if file should be updated based on improvements""" | |
# Simple check - if improvements mention the file | |
return file_info['filename'] in improvements | |
def apply_improvements_to_file(self, original_content, improvements): | |
"""Apply improvements to file content""" | |
# This is a simplified approach - in practice, you'd want more sophisticated parsing | |
# For now, we'll just return the original content as this requires careful implementation | |
# to avoid breaking the code | |
# You could implement more sophisticated code replacement here | |
# using AST parsing for Python files, etc. | |
logger.info("Code improvement application needs manual implementation") | |
return original_content | |
async def send_discord_notification(self, commit, review_result, files): | |
"""Send review results to Discord""" | |
try: | |
channel = self.bot.get_channel(self.discord_channel_id) | |
if not channel: | |
logger.error("Discord channel not found") | |
return | |
# Create embed | |
embed = discord.Embed( | |
title="π Code Review Complete", | |
description=f"Reviewed commit by **{self.target_user}**", | |
color=0x00ff00 if not review_result.get('error') else 0xff9900, | |
timestamp=datetime.now() | |
) | |
# Add commit info | |
embed.add_field( | |
name="π Commit", | |
value=f"[`{commit['id'][:7]}`]({commit['url']})", | |
inline=True | |
) | |
embed.add_field( | |
name="π Files", | |
value=str(len(files)), | |
inline=True | |
) | |
embed.add_field( | |
name="π€ Author", | |
value=commit['author']['name'], | |
inline=True | |
) | |
# Add commit message | |
commit_msg = commit['message'] | |
if len(commit_msg) > 100: | |
commit_msg = commit_msg[:100] + "..." | |
embed.add_field( | |
name="π¬ Message", | |
value=f"```{commit_msg}```", | |
inline=False | |
) | |
# Add file list | |
file_list = ", ".join([f"`{f['filename']}`" for f in files[:5]]) | |
if len(files) > 5: | |
file_list += f" and {len(files) - 5} more..." | |
embed.add_field( | |
name="π Modified Files", | |
value=file_list, | |
inline=False | |
) | |
await channel.send(embed=embed) | |
# Send review content | |
if review_result.get('error'): | |
error_embed = discord.Embed( | |
title="β οΈ Review Error", | |
description=review_result['error'], | |
color=0xff0000 | |
) | |
await channel.send(embed=error_embed) | |
else: | |
review_text = review_result['review'] | |
# Split review into chunks if too long | |
if len(review_text) > 2000: | |
chunks = [review_text[i:i+2000] for i in range(0, len(review_text), 2000)] | |
for i, chunk in enumerate(chunks): | |
if i == 0: | |
await channel.send(f"**π Code Review Results:**\n```\n{chunk}\n```") | |
else: | |
await channel.send(f"```\n{chunk}\n```") | |
else: | |
await channel.send(f"**π Code Review Results:**\n```\n{review_text}\n```") | |
except Exception as e: | |
logger.error(f"Error sending Discord notification: {e}") | |
async def send_error_notification(self, error_message): | |
"""Send error notification to Discord""" | |
try: | |
channel = self.bot.get_channel(self.discord_channel_id) | |
if channel: | |
embed = discord.Embed( | |
title="β Bot Error", | |
description=error_message, | |
color=0xff0000, | |
timestamp=datetime.now() | |
) | |
await channel.send(embed=embed) | |
except Exception as e: | |
logger.error(f"Error sending error notification: {e}") | |
def run_webhook_server(self): | |
"""Run the Flask webhook server""" | |
port = int(os.getenv('PORT', 7860)) | |
logger.info(f"Starting webhook server on port {port}") | |
self.app.run(host='0.0.0.0', port=port, debug=False) | |
def run_discord_bot(self): | |
"""Run the Discord bot""" | |
logger.info("Starting Discord bot...") | |
self.bot.run(self.discord_token) | |
def start(self): | |
"""Start both the webhook server and Discord bot""" | |
logger.info("Starting GitHub Code Review Bot...") | |
logger.info(f"Monitoring: {self.target_repo} for user: {self.target_user}") | |
# Run webhook server in a separate thread | |
webhook_thread = threading.Thread(target=self.run_webhook_server) | |
webhook_thread.daemon = True | |
webhook_thread.start() | |
# Small delay to ensure webhook server starts | |
time.sleep(2) | |
# Run Discord bot in main thread | |
self.run_discord_bot() | |
if __name__ == "__main__": | |
try: | |
bot = GitHubCodeReviewBot() | |
bot.start() | |
except KeyboardInterrupt: | |
logger.info("Bot stopped by user") | |
except Exception as e: | |
logger.error(f"Fatal error: {e}") | |
sys.exit(1) |