diff --git a/.github/workflows/pytest_and_coverage.yml b/.github/workflows/pytest_and_coverage.yml index b6f9487..5ff1f5f 100644 --- a/.github/workflows/pytest_and_coverage.yml +++ b/.github/workflows/pytest_and_coverage.yml @@ -52,6 +52,13 @@ jobs: # Tavily API key PROMETHEUS_TAVILY_API_KEY: tavily_api_key + # GitHub App settings + GITHUB_APP_ID: your_github_app_id + GITHUB_WEBHOOK_SECRET: your_webhook_secret + GITHUB_PRIVATE_KEY: your_github_private_key_content + GITHUB_BOT_HANDLE=: euni-bot + GITHUB_ORG_NAME: your_org_name + # DATABASE settings PROMETHEUS_DATABASE_URL: postgresql://postgres:password@localhost:5432/postgres?sslmode=disable diff --git a/docker-compose.yml b/docker-compose.yml index 041cc68..4dc7269 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -90,6 +90,13 @@ services: # Tavily API key - PROMETHEUS_TAVILY_API_KEY=${PROMETHEUS_TAVILY_API_KEY} + # GitHub App settings + - GITHUB_APP_ID=${GITHUB_APP_ID} + - GITHUB_WEBHOOK_SECRET=${GITHUB_WEBHOOK_SECRET} + - GITHUB_PRIVATE_KEY=${GITHUB_PRIVATE_KEY} + - GITHUB_BOT_HANDLE=${GITHUB_BOT_HANDLE} + - GITHUB_ORG_NAME=${GITHUB_ORG_NAME} + # Database settings - PROMETHEUS_DATABASE_URL=${PROMETHEUS_DATABASE_URL} diff --git a/example.env b/example.env index d746b77..4f23f5f 100644 --- a/example.env +++ b/example.env @@ -37,6 +37,13 @@ PROMETHEUS_BASE_MODEL_TEMPERATURE=0.5 # Tavily API settings PROMETHEUS_TAVILY_API_KEY=your_tavily_api_key +# GitHub App settings +GITHUB_APP_ID=your_github_app_id +GITHUB_WEBHOOK_SECRET=your_webhook_secret +GITHUB_PRIVATE_KEY=your_github_private_key_content +GITHUB_BOT_HANDLE=euni-bot +GITHUB_ORG_NAME=your_org_name + # Database settings PROMETHEUS_DATABASE_URL=postgresql+asyncpg://postgres:password@postgres:5432/postgres diff --git a/prometheus/app/api/main.py b/prometheus/app/api/main.py index 26ac459..27bd9fc 100644 --- a/prometheus/app/api/main.py +++ b/prometheus/app/api/main.py @@ -1,12 +1,21 @@ from fastapi import APIRouter -from prometheus.app.api.routes import auth, github, invitation_code, issue, repository, user +from prometheus.app.api.routes import ( + auth, + github, + github_webhook, + invitation_code, + issue, + repository, + user, +) from prometheus.configuration.config import settings api_router = APIRouter() api_router.include_router(repository.router, prefix="/repository", tags=["repository"]) api_router.include_router(issue.router, prefix="/issue", tags=["issue"]) api_router.include_router(github.router, prefix="/github", tags=["github"]) +api_router.include_router(github_webhook.router, prefix="/github", tags=["github_webhook"]) if settings.ENABLE_AUTHENTICATION: api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) diff --git a/prometheus/app/api/routes/github_webhook.py b/prometheus/app/api/routes/github_webhook.py new file mode 100644 index 0000000..10106a0 --- /dev/null +++ b/prometheus/app/api/routes/github_webhook.py @@ -0,0 +1,292 @@ +import json +import uuid +from typing import Dict + +from fastapi import APIRouter, BackgroundTasks, HTTPException, Request + +from prometheus.app.services.euni_fix import ( + clone_repository, + commit_changes, + push_to_branch, + run_euni_fix, +) +from prometheus.configuration.github import github_settings +from prometheus.git.github_service import GitHubService +from prometheus.utils.github_sec import parse_fix_command, verify_webhook_signature +from prometheus.utils.logger_manager import get_logger + +router = APIRouter() +logger = get_logger(__name__) + + +@router.post("/webhook") +async def github_webhook(request: Request, background_tasks: BackgroundTasks): + """ + Handle GitHub webhook events. + + Listens for issue_comment events and processes /fix commands. + """ + # Get raw request body for signature verification + body = await request.body() + + # Verify webhook signature + signature = request.headers.get("X-Hub-Signature-256") + if not verify_webhook_signature(body, signature): + logger.warning("Invalid webhook signature") + raise HTTPException(status_code=401, detail="Invalid signature") + + # Parse JSON payload + try: + payload = json.loads(body.decode("utf-8")) + except json.JSONDecodeError: + logger.error("Invalid JSON payload") + raise HTTPException(status_code=400, detail="Invalid JSON") + + # Check event type + event_type = request.headers.get("X-GitHub-Event") + if event_type != "issue_comment": + logger.info(f"Ignoring event type: {event_type}") + return {"message": "Event type not supported"} + + # Check if it's a comment creation event + action = payload.get("action") + if action != "created": + logger.info(f"Ignoring comment action: {action}") + return {"message": "Action not supported"} + + # Extract comment data + comment = payload.get("comment", {}) + comment_body = comment.get("body", "") + comment_author = comment.get("user", {}).get("login", "") + + # Check for /fix command + fix_command = parse_fix_command(comment_body, github_settings.BOT_HANDLE) + if not fix_command: + logger.info("No /fix command found in comment") + return {"message": "No fix command found"} + + command, args = fix_command + logger.info(f"Fix command detected: {command} with args: {args}") + + # Extract repository and issue information + repository = payload.get("repository", {}) + issue = payload.get("issue", {}) + + repo_owner = repository.get("owner", {}).get("login", "") + repo_name = repository.get("name", "") + repository.get("full_name", "") + repo_clone_url = repository.get("clone_url", "") + installation_id = payload.get("installation", {}).get("id") + + issue_number = issue.get("number") + issue_title = issue.get("title", "") + is_pull_request = "pull_request" in issue + + if not all([repo_owner, repo_name, installation_id, issue_number]): + logger.error("Missing required webhook data") + raise HTTPException(status_code=400, detail="Missing required data") + + # Start background task to process the fix + background_tasks.add_task( + process_fix_request, + installation_id=installation_id, + repo_owner=repo_owner, + repo_name=repo_name, + repo_clone_url=repo_clone_url, + issue_number=issue_number, + issue_title=issue_title, + is_pull_request=is_pull_request, + fix_args=args, + comment_author=comment_author, + issue_context=issue, + ) + + return {"message": "Fix request received and processing"} + + +async def process_fix_request( + installation_id: int, + repo_owner: str, + repo_name: str, + repo_clone_url: str, + issue_number: int, + issue_title: str, + is_pull_request: bool, + fix_args: str, + comment_author: str, + issue_context: Dict, +): + """ + Background task to process the fix request. + """ + github_service = GitHubService() + temp_repo_dir = None + + try: + logger.info(f"Processing fix request for {repo_owner}/{repo_name}#{issue_number}") + + # Get installation token + token = await github_service.get_installation_token(installation_id) + + # Check organization membership if ORG_NAME is set + if github_settings.ORG_NAME: + is_member = await github_service.check_org_membership( + comment_author, github_settings.ORG_NAME, token + ) + if not is_member: + await github_service.post_comment( + repo_owner, + repo_name, + issue_number, + f"āŒ @{comment_author} is not a member of the {github_settings.ORG_NAME} organization.", + token, + ) + return + + # Post placeholder comment + placeholder_comment = await github_service.post_comment( + repo_owner, + repo_name, + issue_number, + f"šŸ¤– EuniBot is analyzing the issue and preparing fixes...\n\n" + f"Requested by: @{comment_author}\n" + f"Arguments: `{fix_args if fix_args else 'None'}`\n\n" + f"ā³ This may take a few minutes.", + token, + ) + + comment_id = placeholder_comment["id"] + + # Get default branch + default_branch = await github_service.get_repository_default_branch( + repo_owner, repo_name, token + ) + + # Clone repository + temp_repo_dir = await clone_repository(repo_clone_url, default_branch) + + # Run EuniFix + fix_result = await run_euni_fix(temp_repo_dir, fix_args, issue_context) + + if fix_result.success and fix_result.files_changed: + # Generate unique branch name + branch_name = f"euni-fix-{issue_number}-{uuid.uuid4().hex[:8]}" + + # Commit changes + commit_message = f"šŸ¤– EuniFix: {issue_title}\n\nFixes #{issue_number}\nRequested by: @{comment_author}" + if fix_args: + commit_message += f"\nArguments: {fix_args}" + + commit_sha = await commit_changes( + temp_repo_dir, fix_result.files_changed, commit_message + ) + + if commit_sha: + # Get latest commit SHA from default branch + base_sha = await github_service.get_latest_commit_sha( + repo_owner, repo_name, default_branch, token + ) + + # Create new branch + await github_service.create_branch( + repo_owner, repo_name, branch_name, base_sha, token + ) + + # Push changes to the new branch + authenticated_clone_url = repo_clone_url.replace( + "https://", f"https://x-access-token:{token}@" + ) + push_success = await push_to_branch( + temp_repo_dir, branch_name, authenticated_clone_url + ) + + if push_success: + # Create pull request + pr_title = f"šŸ¤– EuniFix: {issue_title}" + pr_body = ( + f"This PR was automatically generated by EuniBot to fix issue #{issue_number}.\n\n" + f"## Changes Made\n" + f"- {fix_result.message}\n\n" + f"## Files Modified\n" + ) + for file_path in fix_result.files_changed: + pr_body += f"- `{file_path}`\n" + + pr_body += f"\n## Requested by\n@{comment_author}" + if fix_args: + pr_body += f"\n\n## Arguments\n`{fix_args}`" + + pr_body += f"\n\nCloses #{issue_number}" + + pr = await github_service.create_pull_request( + repo_owner, repo_name, pr_title, pr_body, branch_name, default_branch, token + ) + + # Update placeholder comment with success + success_message = ( + f"āœ… **EuniFix completed successfully!**\n\n" + f"šŸ“‹ **Summary**: {fix_result.message}\n" + f"šŸ”§ **Files modified**: {len(fix_result.files_changed)}\n" + f"🌿 **Branch**: `{branch_name}`\n" + f"šŸ”— **Pull Request**: #{pr['number']} - {pr['html_url']}\n\n" + f"**Modified files:**\n" + ) + for file_path in fix_result.files_changed: + success_message += f"- `{file_path}`\n" + + await github_service.update_comment( + repo_owner, repo_name, comment_id, success_message, token + ) + + logger.info(f"Successfully created PR #{pr['number']} for fix request") + else: + raise Exception("Failed to push changes to branch") + else: + raise Exception("Failed to commit changes") + else: + # Update placeholder comment with failure + error_message = f"āŒ **EuniFix failed**\n\nšŸ“‹ **Message**: {fix_result.message}\n" + if fix_result.error: + error_message += f"🚨 **Error**: {fix_result.error}\n" + + error_message += f"\nRequested by: @{comment_author}" + + await github_service.update_comment( + repo_owner, repo_name, comment_id, error_message, token + ) + + logger.error(f"EuniFix failed: {fix_result.message}") + + except Exception as e: + logger.error(f"Error processing fix request: {e}") + + try: + # Try to update the placeholder comment with error + error_message = ( + f"āŒ **EuniFix encountered an error**\n\n" + f"🚨 **Error**: {str(e)}\n" + f"Requested by: @{comment_author}\n\n" + f"Please try again or contact support if the issue persists." + ) + + # Get token again if needed + if "token" not in locals(): + token = await github_service.get_installation_token(installation_id) + + if "comment_id" in locals(): + await github_service.update_comment( + repo_owner, repo_name, comment_id, error_message, token + ) + except Exception as update_error: + logger.error(f"Failed to update error comment: {update_error}") + + finally: + # Clean up temporary directory + if temp_repo_dir: + try: + import shutil + + shutil.rmtree(temp_repo_dir) + logger.info(f"Cleaned up temporary directory: {temp_repo_dir}") + except Exception as cleanup_error: + logger.error(f"Failed to cleanup temp directory: {cleanup_error}") diff --git a/prometheus/app/services/euni_fix.py b/prometheus/app/services/euni_fix.py new file mode 100644 index 0000000..66cb391 --- /dev/null +++ b/prometheus/app/services/euni_fix.py @@ -0,0 +1,239 @@ +import asyncio +import os +import tempfile +from pathlib import Path +from typing import Dict, List, Optional + +from prometheus.utils.logger_manager import get_logger + +logger = get_logger(__name__) + + +class EuniFixResult: + """Result of the EuniFix operation.""" + + def __init__( + self, + success: bool, + message: str, + files_changed: Optional[List[str]] = None, + commit_sha: Optional[str] = None, + error: Optional[str] = None, + ): + self.success = success + self.message = message + self.files_changed = files_changed or [] + self.commit_sha = commit_sha + self.error = error + + +async def run_euni_fix( + repo_dir: str, args: str, issue_context: Optional[Dict] = None +) -> EuniFixResult: + """ + Placeholder for EuniFix auto-fix logic. + + This function will be replaced with actual AI agent integration. + Currently returns a mock result for testing purposes. + + Args: + repo_dir: Path to the cloned repository + args: Arguments passed with the /fix command + issue_context: Optional context about the issue/PR + + Returns: + EuniFixResult: Result of the fix operation + """ + logger.info(f"Running EuniFix in directory: {repo_dir}") + logger.info(f"Fix arguments: {args}") + + try: + # Simulate some processing time + await asyncio.sleep(2) + + # Mock implementation - replace with actual AI agent logic + + # Example: Check if there are any Python files to fix + repo_path = Path(repo_dir) + python_files = list(repo_path.rglob("*.py")) + + if not python_files: + return EuniFixResult( + success=False, + message="No Python files found to fix", + error="Repository does not contain Python files", + ) + + # Mock: Simulate fixing some files + files_to_fix = python_files[: min(3, len(python_files))] # Fix up to 3 files + files_changed = [] + + for file_path in files_to_fix: + # Mock: Add a comment to the file + try: + content = file_path.read_text() + if "# Fixed by EuniBot" not in content: + # Add a comment at the top + fixed_content = ( + f"# Fixed by EuniBot - {args if args else 'General fix'}\n" + content + ) + file_path.write_text(fixed_content) + files_changed.append(str(file_path.relative_to(repo_path))) + logger.info(f"Fixed file: {file_path}") + except Exception as e: + logger.error(f"Error fixing file {file_path}: {e}") + + if files_changed: + return EuniFixResult( + success=True, + message=f"Successfully applied fixes to {len(files_changed)} files", + files_changed=files_changed, + ) + else: + return EuniFixResult( + success=False, + message="No changes were needed or could be applied", + error="All files were already up to date", + ) + + except Exception as e: + logger.error(f"Error running EuniFix: {e}") + return EuniFixResult(success=False, message="Failed to run EuniFix", error=str(e)) + + +async def commit_changes( + repo_dir: str, files_changed: List[str], commit_message: str +) -> Optional[str]: + """ + Commit changes to the repository. + + Args: + repo_dir: Path to the repository + files_changed: List of files that were changed + commit_message: Commit message + + Returns: + Optional[str]: Commit SHA if successful, None otherwise + """ + try: + # Use git commands to commit changes + import subprocess + + # Change to repo directory + original_cwd = os.getcwd() + os.chdir(repo_dir) + + try: + # Add changed files + for file_path in files_changed: + result = subprocess.run(["git", "add", file_path], capture_output=True, text=True) + if result.returncode != 0: + logger.error(f"Failed to add file {file_path}: {result.stderr}") + return None + + # Commit changes + result = subprocess.run( + ["git", "commit", "-m", commit_message], capture_output=True, text=True + ) + + if result.returncode != 0: + logger.error(f"Failed to commit changes: {result.stderr}") + return None + + # Get the commit SHA + result = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True) + + if result.returncode == 0: + return result.stdout.strip() + else: + logger.error(f"Failed to get commit SHA: {result.stderr}") + return None + + finally: + os.chdir(original_cwd) + + except Exception as e: + logger.error(f"Error committing changes: {e}") + return None + + +async def clone_repository(repo_url: str, branch: str = "main") -> str: + """ + Clone a repository to a temporary directory. + + Args: + repo_url: Repository URL + branch: Branch to clone + + Returns: + str: Path to the cloned repository + """ + try: + import subprocess + + # Create temporary directory + temp_dir = tempfile.mkdtemp(prefix="euni_fix_") + + # Clone repository + result = subprocess.run( + ["git", "clone", "-b", branch, repo_url, temp_dir], capture_output=True, text=True + ) + + if result.returncode != 0: + logger.error(f"Failed to clone repository: {result.stderr}") + raise Exception(f"Git clone failed: {result.stderr}") + + logger.info(f"Repository cloned to: {temp_dir}") + return temp_dir + + except Exception as e: + logger.error(f"Error cloning repository: {e}") + raise + + +async def push_to_branch(repo_dir: str, branch_name: str, remote_url: str) -> bool: + """ + Push changes to a remote branch. + + Args: + repo_dir: Path to the repository + branch_name: Branch name to push to + remote_url: Remote repository URL + + Returns: + bool: True if successful, False otherwise + """ + try: + import subprocess + + original_cwd = os.getcwd() + os.chdir(repo_dir) + + try: + # Create and checkout new branch + result = subprocess.run( + ["git", "checkout", "-b", branch_name], capture_output=True, text=True + ) + + if result.returncode != 0: + logger.error(f"Failed to create branch {branch_name}: {result.stderr}") + return False + + # Push to remote + result = subprocess.run( + ["git", "push", "-u", "origin", branch_name], capture_output=True, text=True + ) + + if result.returncode != 0: + logger.error(f"Failed to push branch {branch_name}: {result.stderr}") + return False + + logger.info(f"Successfully pushed branch: {branch_name}") + return True + + finally: + os.chdir(original_cwd) + + except Exception as e: + logger.error(f"Error pushing to branch: {e}") + return False diff --git a/prometheus/configuration/config.py b/prometheus/configuration/config.py index c776cc2..469165f 100644 --- a/prometheus/configuration/config.py +++ b/prometheus/configuration/config.py @@ -5,7 +5,7 @@ class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=".env", env_file_encoding="utf-8", env_prefix="PROMETHEUS_" + env_file=".env", env_file_encoding="utf-8", env_prefix="PROMETHEUS_", extra="ignore" ) # General settings version: str = "1.3" diff --git a/prometheus/configuration/github.py b/prometheus/configuration/github.py new file mode 100644 index 0000000..a5c1809 --- /dev/null +++ b/prometheus/configuration/github.py @@ -0,0 +1,26 @@ +from typing import Optional + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class GitHubSettings(BaseSettings): + """GitHub App configuration settings.""" + + model_config = SettingsConfigDict( + env_file=".env", env_file_encoding="utf-8", env_prefix="GITHUB_", extra="ignore" + ) + + # GitHub App credentials + APP_ID: str + WEBHOOK_SECRET: str + PRIVATE_KEY: str + + # Bot configuration + BOT_HANDLE: str # The GitHub username of the bot + ORG_NAME: Optional[str] = None # Organization name for membership validation + + # Installation settings + INSTALLATION_ID: Optional[int] = None # Optional: specific installation ID + + +github_settings = GitHubSettings() diff --git a/prometheus/git/github_service.py b/prometheus/git/github_service.py new file mode 100644 index 0000000..236b35b --- /dev/null +++ b/prometheus/git/github_service.py @@ -0,0 +1,283 @@ +from typing import Dict, Optional + +import aiohttp + +from prometheus.configuration.github import github_settings +from prometheus.exceptions.github_exception import GithubException +from prometheus.utils.github_sec import create_github_jwt +from prometheus.utils.logger_manager import get_logger + +logger = get_logger(__name__) + + +class GitHubService: + """Service for GitHub API operations.""" + + def __init__(self): + self.base_url = "https://api.github.com" + self._installation_token: Optional[str] = None + self._token_expires_at: Optional[float] = None + + async def get_installation_token(self, installation_id: int) -> str: + """ + Get GitHub App installation token. + + Args: + installation_id: GitHub App installation ID + + Returns: + str: Installation access token + """ + jwt_token = create_github_jwt() + + url = f"{self.base_url}/app/installations/{installation_id}/access_tokens" + headers = { + "Authorization": f"Bearer {jwt_token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": f"{github_settings.BOT_HANDLE}-bot", + } + + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers) as response: + if response.status != 201: + error_text = await response.text() + raise GithubException( + f"Failed to get installation token: {response.status} - {error_text}" + ) + + data = await response.json() + return data["token"] + + async def check_org_membership(self, username: str, org_name: str, token: str) -> bool: + """ + Check if user is a member of the organization. + + Args: + username: GitHub username + org_name: Organization name + token: GitHub token + + Returns: + bool: True if user is a member, False otherwise + """ + url = f"{self.base_url}/orgs/{org_name}/members/{username}" + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": f"{github_settings.BOT_HANDLE}-bot", + } + + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as response: + # 204 = public member, 302 = private member, 404 = not a member + return response.status in [204, 302] + + async def post_comment( + self, owner: str, repo: str, issue_number: int, body: str, token: str + ) -> Dict: + """ + Post a comment on an issue or PR. + + Args: + owner: Repository owner + repo: Repository name + issue_number: Issue or PR number + body: Comment body + token: GitHub token + + Returns: + Dict: Comment data from GitHub API + """ + url = f"{self.base_url}/repos/{owner}/{repo}/issues/{issue_number}/comments" + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": f"{github_settings.BOT_HANDLE}-bot", + } + + payload = {"body": body} + + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=payload) as response: + if response.status != 201: + error_text = await response.text() + raise GithubException( + f"Failed to post comment: {response.status} - {error_text}" + ) + + return await response.json() + + async def update_comment( + self, owner: str, repo: str, comment_id: int, body: str, token: str + ) -> Dict: + """ + Update an existing comment. + + Args: + owner: Repository owner + repo: Repository name + comment_id: Comment ID + body: New comment body + token: GitHub token + + Returns: + Dict: Updated comment data from GitHub API + """ + url = f"{self.base_url}/repos/{owner}/{repo}/issues/comments/{comment_id}" + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": f"{github_settings.BOT_HANDLE}-bot", + } + + payload = {"body": body} + + async with aiohttp.ClientSession() as session: + async with session.patch(url, headers=headers, json=payload) as response: + if response.status != 200: + error_text = await response.text() + raise GithubException( + f"Failed to update comment: {response.status} - {error_text}" + ) + + return await response.json() + + async def create_branch( + self, owner: str, repo: str, branch_name: str, base_sha: str, token: str + ) -> Dict: + """ + Create a new branch. + + Args: + owner: Repository owner + repo: Repository name + branch_name: Name of the new branch + base_sha: SHA of the base commit + token: GitHub token + + Returns: + Dict: Branch creation response + """ + url = f"{self.base_url}/repos/{owner}/{repo}/git/refs" + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": f"{github_settings.BOT_HANDLE}-bot", + } + + payload = {"ref": f"refs/heads/{branch_name}", "sha": base_sha} + + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=payload) as response: + if response.status != 201: + error_text = await response.text() + raise GithubException( + f"Failed to create branch: {response.status} - {error_text}" + ) + + return await response.json() + + async def create_pull_request( + self, + owner: str, + repo: str, + title: str, + body: str, + head_branch: str, + base_branch: str, + token: str, + ) -> Dict: + """ + Create a pull request. + + Args: + owner: Repository owner + repo: Repository name + title: PR title + body: PR body + head_branch: Source branch + base_branch: Target branch + token: GitHub token + + Returns: + Dict: Pull request data from GitHub API + """ + url = f"{self.base_url}/repos/{owner}/{repo}/pulls" + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": f"{github_settings.BOT_HANDLE}-bot", + } + + payload = {"title": title, "body": body, "head": head_branch, "base": base_branch} + + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=payload) as response: + if response.status != 201: + error_text = await response.text() + raise GithubException( + f"Failed to create pull request: {response.status} - {error_text}" + ) + + return await response.json() + + async def get_repository_default_branch(self, owner: str, repo: str, token: str) -> str: + """ + Get the default branch of a repository. + + Args: + owner: Repository owner + repo: Repository name + token: GitHub token + + Returns: + str: Default branch name + """ + url = f"{self.base_url}/repos/{owner}/{repo}" + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": f"{github_settings.BOT_HANDLE}-bot", + } + + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as response: + if response.status != 200: + error_text = await response.text() + raise GithubException( + f"Failed to get repository info: {response.status} - {error_text}" + ) + + data = await response.json() + return data["default_branch"] + + async def get_latest_commit_sha(self, owner: str, repo: str, branch: str, token: str) -> str: + """ + Get the latest commit SHA for a branch. + + Args: + owner: Repository owner + repo: Repository name + branch: Branch name + token: GitHub token + + Returns: + str: Latest commit SHA + """ + url = f"{self.base_url}/repos/{owner}/{repo}/git/refs/heads/{branch}" + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": f"{github_settings.BOT_HANDLE}-bot", + } + + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as response: + if response.status != 200: + error_text = await response.text() + raise GithubException( + f"Failed to get latest commit: {response.status} - {error_text}" + ) + + data = await response.json() + return data["object"]["sha"] diff --git a/prometheus/utils/github_sec.py b/prometheus/utils/github_sec.py new file mode 100644 index 0000000..63da7d3 --- /dev/null +++ b/prometheus/utils/github_sec.py @@ -0,0 +1,120 @@ +import hashlib +import hmac +import re +from datetime import datetime, timedelta, timezone +from typing import Optional, Tuple + +import jwt + +from prometheus.configuration.github import github_settings +from prometheus.exceptions.github_exception import GithubException + + +def verify_webhook_signature(payload_body: bytes, signature_header: str) -> bool: + """ + Verify GitHub webhook signature. + + Args: + payload_body: Raw request body bytes + signature_header: X-Hub-Signature-256 header value + + Returns: + bool: True if signature is valid, False otherwise + """ + if not signature_header: + return False + + # Extract the signature from the header (format: sha256=) + try: + algorithm, signature = signature_header.split("=", 1) + if algorithm != "sha256": + return False + except ValueError: + return False + + # Calculate expected signature + expected_signature = hmac.new( + github_settings.WEBHOOK_SECRET.encode("utf-8"), payload_body, hashlib.sha256 + ).hexdigest() + + # Compare signatures using secure comparison + return hmac.compare_digest(expected_signature, signature) + + +def create_github_jwt() -> str: + """ + Create a JWT token for GitHub App authentication. + + Returns: + str: JWT token for GitHub App + """ + # JWT expires in 10 minutes (GitHub's maximum) + now = datetime.now(timezone.utc) + expiration = now + timedelta(minutes=10) + + payload = { + "iat": int(now.timestamp()), + "exp": int(expiration.timestamp()), + "iss": github_settings.APP_ID, + } + + # GitHub expects RS256 algorithm + try: + token = jwt.encode(payload, github_settings.PRIVATE_KEY, algorithm="RS256") + return token + except Exception as e: + raise GithubException(f"Failed to create GitHub JWT: {str(e)}") + + +def parse_fix_command(comment_body: str, bot_handle: str) -> Optional[Tuple[str, str]]: + """ + Parse `/fix` command from comment body. + + Args: + comment_body: The comment body text + bot_handle: The GitHub bot handle (username) + + Returns: + Optional[Tuple[str, str]]: (command, arguments) if found, None otherwise + """ + if not comment_body: + return None + + # Pattern to match @bot-handle /fix [optional args] + # Case insensitive matching + pattern = rf"@{re.escape(bot_handle)}\s+/fix(?:\s+(.+))?" + + match = re.search(pattern, comment_body, re.IGNORECASE | re.MULTILINE) + if match: + args = match.group(1) or "" # Get arguments or empty string + return ("fix", args.strip()) + + return None + + +def extract_repository_info(repository_url: str) -> Tuple[str, str]: + """ + Extract owner and repo name from repository URL. + + Args: + repository_url: GitHub repository URL + + Returns: + Tuple[str, str]: (owner, repo_name) + """ + # Handle both https and git URLs + # Examples: + # https://github.com/owner/repo + # git@github.com:owner/repo.git + + if repository_url.startswith("https://github.com/"): + parts = repository_url.replace("https://github.com/", "").split("/") + if len(parts) >= 2: + return parts[0], parts[1] + elif repository_url.startswith("git@github.com:"): + repo_part = repository_url.replace("git@github.com:", "").replace(".git", "") + parts = repo_part.split("/") + if len(parts) >= 2: + return parts[0], parts[1] + + raise GithubException(f"Invalid repository URL format: {repository_url}") diff --git a/pyproject.toml b/pyproject.toml index 4eaca48..f819445 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,8 @@ dependencies = [ "argon2-cffi>=23.1.0", "sqlmodel==0.0.24", "asyncpg", - "pyjwt==2.6.0", + "pyjwt[crypto]==2.6.0", + "aiohttp>=3.8.0", "mcp>=1.4.1", "tavily-python>=0.5.1", "langchain-mcp-adapters>=0.1.9", diff --git a/tests/app/test_github_webhook.py b/tests/app/test_github_webhook.py new file mode 100644 index 0000000..86a52c0 --- /dev/null +++ b/tests/app/test_github_webhook.py @@ -0,0 +1,330 @@ +import json +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from fastapi.testclient import TestClient + +from prometheus.app.api.routes.github_webhook import router +from prometheus.utils.github_sec import parse_fix_command, verify_webhook_signature + +# Mock webhook payload +MOCK_ISSUE_COMMENT_PAYLOAD = { + "action": "created", + "issue": {"number": 123, "title": "Test issue", "state": "open"}, + "comment": { + "id": 456, + "body": "@euni-bot /fix Please fix the formatting issues", + "user": {"login": "test-user"}, + }, + "repository": { + "name": "test-repo", + "full_name": "test-owner/test-repo", + "clone_url": "https://github.com/test-owner/test-repo.git", + "owner": {"login": "test-owner"}, + }, + "installation": {"id": 123456}, +} + + +class TestGitHubWebhook: + def test_parse_fix_command_valid(self): + """Test parsing valid /fix commands.""" + # Basic /fix command + comment_body = "@euni-bot /fix" + result = parse_fix_command(comment_body, "euni-bot") + assert result == ("fix", "") + + # /fix command with arguments + comment_body = "@euni-bot /fix --strict --format" + result = parse_fix_command(comment_body, "euni-bot") + assert result == ("fix", "--strict --format") + + # Case insensitive + comment_body = "@EUNI-BOT /FIX test args" + result = parse_fix_command(comment_body, "euni-bot") + assert result == ("fix", "test args") + + # With other text around + comment_body = "Some text before\n@euni-bot /fix urgent\nSome text after" + result = parse_fix_command(comment_body, "euni-bot") + assert result == ("fix", "urgent") + + def test_parse_fix_command_invalid(self): + """Test parsing invalid /fix commands.""" + # Wrong bot handle + comment_body = "@other-bot /fix" + result = parse_fix_command(comment_body, "euni-bot") + assert result is None + + # No @ mention + comment_body = "euni-bot /fix" + result = parse_fix_command(comment_body, "euni-bot") + assert result is None + + # Different command + comment_body = "@euni-bot /help" + result = parse_fix_command(comment_body, "euni-bot") + assert result is None + + # Empty comment + result = parse_fix_command("", "euni-bot") + assert result is None + + # None comment + result = parse_fix_command(None, "euni-bot") + assert result is None + + def test_verify_webhook_signature_valid(self): + """Test webhook signature verification with valid signature.""" + payload = b'{"test": "data"}' + secret = "test-secret" + + # Mock the github_settings + with patch("prometheus.utils.github_sec.github_settings") as mock_settings: + mock_settings.WEBHOOK_SECRET = secret + + import hashlib + import hmac + + # Create valid signature + expected_signature = hmac.new( + secret.encode("utf-8"), payload, hashlib.sha256 + ).hexdigest() + + signature_header = f"sha256={expected_signature}" + result = verify_webhook_signature(payload, signature_header) + assert result is True + + def test_verify_webhook_signature_invalid(self): + """Test webhook signature verification with invalid signature.""" + payload = b'{"test": "data"}' + + with patch("prometheus.utils.github_sec.github_settings") as mock_settings: + mock_settings.WEBHOOK_SECRET = "test-secret" + + # Test with wrong signature + result = verify_webhook_signature(payload, "sha256=wrong_signature") + assert result is False + + # Test with no signature + result = verify_webhook_signature(payload, "") + assert result is False + + # Test with malformed signature + result = verify_webhook_signature(payload, "malformed") + assert result is False + + @pytest.mark.asyncio + async def test_github_webhook_valid_fix_command(self): + """Test webhook handling with valid /fix command.""" + from fastapi import FastAPI + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + payload = json.dumps(MOCK_ISSUE_COMMENT_PAYLOAD).encode("utf-8") + + with patch("prometheus.utils.github_sec.verify_webhook_signature", return_value=True): + with patch("prometheus.app.api.routes.github_webhook.process_fix_request"): + response = client.post( + "/webhook", + content=payload, + headers={ + "X-GitHub-Event": "issue_comment", + "X-Hub-Signature-256": "sha256=test_signature", + "Content-Type": "application/json", + }, + ) + + assert response.status_code == 200 + assert response.json()["message"] == "Fix request received and processing" + + @pytest.mark.asyncio + async def test_github_webhook_invalid_signature(self): + """Test webhook rejection with invalid signature.""" + from fastapi import FastAPI + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + payload = json.dumps(MOCK_ISSUE_COMMENT_PAYLOAD).encode("utf-8") + + with patch("prometheus.utils.github_sec.verify_webhook_signature", return_value=False): + response = client.post( + "/webhook", + content=payload, + headers={ + "X-GitHub-Event": "issue_comment", + "X-Hub-Signature-256": "sha256=invalid_signature", + "Content-Type": "application/json", + }, + ) + + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid signature" + + @pytest.mark.asyncio + async def test_github_webhook_wrong_event_type(self): + """Test webhook ignoring wrong event types.""" + from fastapi import FastAPI + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + payload = json.dumps(MOCK_ISSUE_COMMENT_PAYLOAD).encode("utf-8") + + with patch("prometheus.utils.github_sec.verify_webhook_signature", return_value=True): + response = client.post( + "/webhook", + content=payload, + headers={ + "X-GitHub-Event": "push", # Wrong event type + "X-Hub-Signature-256": "sha256=test_signature", + "Content-Type": "application/json", + }, + ) + + assert response.status_code == 200 + assert response.json()["message"] == "Event type not supported" + + @pytest.mark.asyncio + async def test_github_webhook_no_fix_command(self): + """Test webhook ignoring comments without /fix command.""" + from fastapi import FastAPI + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + # Modify payload to have no /fix command + modified_payload = MOCK_ISSUE_COMMENT_PAYLOAD.copy() + modified_payload["comment"]["body"] = "Just a regular comment" + + payload = json.dumps(modified_payload).encode("utf-8") + + with patch("prometheus.utils.github_sec.verify_webhook_signature", return_value=True): + response = client.post( + "/webhook", + content=payload, + headers={ + "X-GitHub-Event": "issue_comment", + "X-Hub-Signature-256": "sha256=test_signature", + "Content-Type": "application/json", + }, + ) + + assert response.status_code == 200 + assert response.json()["message"] == "No fix command found" + + +class TestGitHubService: + @pytest.mark.asyncio + async def test_get_installation_token(self): + """Test getting GitHub installation token.""" + from prometheus.git.github_service import GitHubService + + service = GitHubService() + + mock_response_data = {"token": "test_token_123", "expires_at": "2023-12-31T23:59:59Z"} + + with patch("prometheus.utils.github_sec.create_github_jwt", return_value="test_jwt"): + with patch("aiohttp.ClientSession.post") as mock_post: + mock_response = AsyncMock() + mock_response.status = 201 + mock_response.json = AsyncMock(return_value=mock_response_data) + mock_post.return_value.__aenter__.return_value = mock_response + + token = await service.get_installation_token(123456) + assert token == "test_token_123" + + @pytest.mark.asyncio + async def test_check_org_membership_member(self): + """Test organization membership check for valid member.""" + from prometheus.git.github_service import GitHubService + + service = GitHubService() + + with patch("aiohttp.ClientSession.get") as mock_get: + mock_response = AsyncMock() + mock_response.status = 204 # Public member + mock_get.return_value.__aenter__.return_value = mock_response + + is_member = await service.check_org_membership("test-user", "test-org", "token") + assert is_member is True + + @pytest.mark.asyncio + async def test_check_org_membership_not_member(self): + """Test organization membership check for non-member.""" + from prometheus.git.github_service import GitHubService + + service = GitHubService() + + with patch("aiohttp.ClientSession.get") as mock_get: + mock_response = AsyncMock() + mock_response.status = 404 # Not a member + mock_get.return_value.__aenter__.return_value = mock_response + + is_member = await service.check_org_membership("test-user", "test-org", "token") + assert is_member is False + + @pytest.mark.asyncio + async def test_post_comment(self): + """Test posting a comment.""" + from prometheus.git.github_service import GitHubService + + service = GitHubService() + + mock_response_data = {"id": 789, "body": "Test comment", "user": {"login": "euni-bot"}} + + with patch("aiohttp.ClientSession.post") as mock_post: + mock_response = AsyncMock() + mock_response.status = 201 + mock_response.json = AsyncMock(return_value=mock_response_data) + mock_post.return_value.__aenter__.return_value = mock_response + + comment = await service.post_comment("owner", "repo", 123, "Test comment", "token") + assert comment["id"] == 789 + assert comment["body"] == "Test comment" + + +class TestEuniFix: + @pytest.mark.asyncio + async def test_run_euni_fix_success(self): + """Test successful EuniFix execution.""" + from prometheus.app.services.euni_fix import run_euni_fix + + with patch("prometheus.app.services.euni_fix.Path") as mock_path: + # Mock some Python files in the repo + mock_repo_path = Mock() + mock_file1 = Mock() + mock_file1.read_text.return_value = "print('hello')" + mock_file1.relative_to.return_value = "test1.py" + mock_file1.write_text = Mock() + + mock_path.return_value = mock_repo_path + mock_repo_path.rglob.return_value = [mock_file1] + + result = await run_euni_fix("/tmp/test_repo", "test_args") + + assert result.success is True + assert len(result.files_changed) == 1 + assert "test1.py" in result.files_changed + + @pytest.mark.asyncio + async def test_run_euni_fix_no_python_files(self): + """Test EuniFix with no Python files.""" + from prometheus.app.services.euni_fix import run_euni_fix + + with patch("prometheus.app.services.euni_fix.Path") as mock_path: + mock_repo_path = Mock() + mock_path.return_value = mock_repo_path + mock_repo_path.rglob.return_value = [] # No Python files + + result = await run_euni_fix("/tmp/test_repo", "test_args") + + assert result.success is False + assert "No Python files found" in result.message + assert len(result.files_changed) == 0