Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/pytest_and_coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
7 changes: 7 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion prometheus/app/api/main.py
Original file line number Diff line number Diff line change
@@ -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"])
Expand Down
292 changes: 292 additions & 0 deletions prometheus/app/api/routes/github_webhook.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +196 to +200

Copilot AI Oct 2, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GitHub token is embedded directly in the clone URL string. Consider using environment variables or git credential helpers to avoid exposing the token in command line arguments or process lists.

Suggested change
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
# Use the regular clone URL and pass the token separately for secure authentication
push_success = await push_to_branch(
temp_repo_dir, branch_name, repo_clone_url, token

Copilot uses AI. Check for mistakes.
)

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)
Comment on lines +286 to +289

Copilot AI Oct 2, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shutil import should be moved to the top of the file rather than inside the cleanup block. This follows Python best practices and makes dependencies clearer.

Copilot uses AI. Check for mistakes.
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}")
Loading
Loading