Skip to content

Commit dcd7eb8

Browse files
committed
feat: Add GitHub webhook integration for issue comments with /fix command
- Introduced GitHub App settings in the environment configuration. - Implemented GitHub webhook route to handle issue_comment events. - Added logic to parse and process /fix commands from comments. - Created background task to clone repositories, apply fixes, and create pull requests. - Developed EuniFix service to simulate code fixes on Python files. - Implemented GitHub service for API interactions, including token management and comment posting. - Added utility functions for verifying webhook signatures and creating JWT tokens. - Enhanced testing suite with comprehensive tests for GitHub webhook handling and EuniFix functionality.
1 parent cb30fb0 commit dcd7eb8

10 files changed

Lines changed: 1354 additions & 2 deletions

File tree

docker-compose.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ services:
8787
# Tavily API key
8888
- PROMETHEUS_TAVILY_API_KEY=${PROMETHEUS_TAVILY_API_KEY}
8989

90+
# GitHub App settings
91+
- GITHUB_APP_ID=${GITHUB_APP_ID}
92+
- GITHUB_WEBHOOK_SECRET=${GITHUB_WEBHOOK_SECRET}
93+
- GITHUB_PRIVATE_KEY=${GITHUB_PRIVATE_KEY}
94+
- GITHUB_BOT_HANDLE=${GITHUB_BOT_HANDLE}
95+
- GITHUB_ORG_NAME=${GITHUB_ORG_NAME}
96+
9097
# Database settings
9198
- PROMETHEUS_DATABASE_URL=${PROMETHEUS_DATABASE_URL}
9299

example.env

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ PROMETHEUS_BASE_MODEL_TEMPERATURE=0.5
3737
# Tavily API settings
3838
PROMETHEUS_TAVILY_API_KEY=your_tavily_api_key
3939

40+
# GitHub App settings
41+
GITHUB_APP_ID=your_github_app_id
42+
GITHUB_WEBHOOK_SECRET=your_webhook_secret
43+
GITHUB_PRIVATE_KEY=your_github_private_key_content
44+
GITHUB_BOT_HANDLE=euni-bot
45+
GITHUB_ORG_NAME=your_org_name
46+
4047
# Database settings
4148
PROMETHEUS_DATABASE_URL=postgresql+asyncpg://postgres:password@postgres:5432/postgres
4249

prometheus/app/api/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from fastapi import APIRouter
22

3-
from prometheus.app.api.routes import auth, github, invitation_code, issue, repository, user
3+
from prometheus.app.api.routes import auth, github, github_webhook, invitation_code, issue, repository, user
44
from prometheus.configuration.config import settings
55

66
api_router = APIRouter()
77
api_router.include_router(repository.router, prefix="/repository", tags=["repository"])
88
api_router.include_router(issue.router, prefix="/issue", tags=["issue"])
99
api_router.include_router(github.router, prefix="/github", tags=["github"])
10+
api_router.include_router(github_webhook.router, prefix="/github", tags=["github_webhook"])
1011

1112
if settings.ENABLE_AUTHENTICATION:
1213
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import asyncio
2+
import json
3+
import tempfile
4+
import uuid
5+
from datetime import datetime
6+
from typing import Dict
7+
8+
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
9+
10+
from prometheus.app.services.euni_fix import (
11+
EuniFixResult,
12+
clone_repository,
13+
commit_changes,
14+
push_to_branch,
15+
run_euni_fix,
16+
)
17+
from prometheus.configuration.github import github_settings
18+
from prometheus.git.github_service import GitHubService
19+
from prometheus.utils.github_sec import parse_fix_command, verify_webhook_signature
20+
from prometheus.utils.logger_manager import get_logger
21+
22+
router = APIRouter()
23+
logger = get_logger(__name__)
24+
25+
26+
@router.post("/webhook")
27+
async def github_webhook(request: Request, background_tasks: BackgroundTasks):
28+
"""
29+
Handle GitHub webhook events.
30+
31+
Listens for issue_comment events and processes /fix commands.
32+
"""
33+
# Get raw request body for signature verification
34+
body = await request.body()
35+
36+
# Verify webhook signature
37+
signature = request.headers.get("X-Hub-Signature-256")
38+
if not verify_webhook_signature(body, signature):
39+
logger.warning("Invalid webhook signature")
40+
raise HTTPException(status_code=401, detail="Invalid signature")
41+
42+
# Parse JSON payload
43+
try:
44+
payload = json.loads(body.decode('utf-8'))
45+
except json.JSONDecodeError:
46+
logger.error("Invalid JSON payload")
47+
raise HTTPException(status_code=400, detail="Invalid JSON")
48+
49+
# Check event type
50+
event_type = request.headers.get("X-GitHub-Event")
51+
if event_type != "issue_comment":
52+
logger.info(f"Ignoring event type: {event_type}")
53+
return {"message": "Event type not supported"}
54+
55+
# Check if it's a comment creation event
56+
action = payload.get("action")
57+
if action != "created":
58+
logger.info(f"Ignoring comment action: {action}")
59+
return {"message": "Action not supported"}
60+
61+
# Extract comment data
62+
comment = payload.get("comment", {})
63+
comment_body = comment.get("body", "")
64+
comment_author = comment.get("user", {}).get("login", "")
65+
66+
# Check for /fix command
67+
fix_command = parse_fix_command(comment_body, github_settings.BOT_HANDLE)
68+
if not fix_command:
69+
logger.info("No /fix command found in comment")
70+
return {"message": "No fix command found"}
71+
72+
command, args = fix_command
73+
logger.info(f"Fix command detected: {command} with args: {args}")
74+
75+
# Extract repository and issue information
76+
repository = payload.get("repository", {})
77+
issue = payload.get("issue", {})
78+
79+
repo_owner = repository.get("owner", {}).get("login", "")
80+
repo_name = repository.get("name", "")
81+
repo_full_name = repository.get("full_name", "")
82+
repo_clone_url = repository.get("clone_url", "")
83+
installation_id = payload.get("installation", {}).get("id")
84+
85+
issue_number = issue.get("number")
86+
issue_title = issue.get("title", "")
87+
is_pull_request = "pull_request" in issue
88+
89+
if not all([repo_owner, repo_name, installation_id, issue_number]):
90+
logger.error("Missing required webhook data")
91+
raise HTTPException(status_code=400, detail="Missing required data")
92+
93+
# Start background task to process the fix
94+
background_tasks.add_task(
95+
process_fix_request,
96+
installation_id=installation_id,
97+
repo_owner=repo_owner,
98+
repo_name=repo_name,
99+
repo_clone_url=repo_clone_url,
100+
issue_number=issue_number,
101+
issue_title=issue_title,
102+
is_pull_request=is_pull_request,
103+
fix_args=args,
104+
comment_author=comment_author,
105+
issue_context=issue
106+
)
107+
108+
return {"message": "Fix request received and processing"}
109+
110+
111+
async def process_fix_request(
112+
installation_id: int,
113+
repo_owner: str,
114+
repo_name: str,
115+
repo_clone_url: str,
116+
issue_number: int,
117+
issue_title: str,
118+
is_pull_request: bool,
119+
fix_args: str,
120+
comment_author: str,
121+
issue_context: Dict
122+
):
123+
"""
124+
Background task to process the fix request.
125+
"""
126+
github_service = GitHubService()
127+
temp_repo_dir = None
128+
129+
try:
130+
logger.info(f"Processing fix request for {repo_owner}/{repo_name}#{issue_number}")
131+
132+
# Get installation token
133+
token = await github_service.get_installation_token(installation_id)
134+
135+
# Check organization membership if ORG_NAME is set
136+
if github_settings.ORG_NAME:
137+
is_member = await github_service.check_org_membership(
138+
comment_author, github_settings.ORG_NAME, token
139+
)
140+
if not is_member:
141+
await github_service.post_comment(
142+
repo_owner, repo_name, issue_number,
143+
f"❌ @{comment_author} is not a member of the {github_settings.ORG_NAME} organization.",
144+
token
145+
)
146+
return
147+
148+
# Post placeholder comment
149+
placeholder_comment = await github_service.post_comment(
150+
repo_owner, repo_name, issue_number,
151+
f"🤖 EuniBot is analyzing the issue and preparing fixes...\n\n"
152+
f"Requested by: @{comment_author}\n"
153+
f"Arguments: `{fix_args if fix_args else 'None'}`\n\n"
154+
f"⏳ This may take a few minutes.",
155+
token
156+
)
157+
158+
comment_id = placeholder_comment["id"]
159+
160+
# Get default branch
161+
default_branch = await github_service.get_repository_default_branch(
162+
repo_owner, repo_name, token
163+
)
164+
165+
# Clone repository
166+
temp_repo_dir = await clone_repository(repo_clone_url, default_branch)
167+
168+
# Run EuniFix
169+
fix_result = await run_euni_fix(temp_repo_dir, fix_args, issue_context)
170+
171+
if fix_result.success and fix_result.files_changed:
172+
# Generate unique branch name
173+
branch_name = f"euni-fix-{issue_number}-{uuid.uuid4().hex[:8]}"
174+
175+
# Commit changes
176+
commit_message = f"🤖 EuniFix: {issue_title}\n\nFixes #{issue_number}\nRequested by: @{comment_author}"
177+
if fix_args:
178+
commit_message += f"\nArguments: {fix_args}"
179+
180+
commit_sha = await commit_changes(temp_repo_dir, fix_result.files_changed, commit_message)
181+
182+
if commit_sha:
183+
# Get latest commit SHA from default branch
184+
base_sha = await github_service.get_latest_commit_sha(
185+
repo_owner, repo_name, default_branch, token
186+
)
187+
188+
# Create new branch
189+
await github_service.create_branch(
190+
repo_owner, repo_name, branch_name, base_sha, token
191+
)
192+
193+
# Push changes to the new branch
194+
authenticated_clone_url = repo_clone_url.replace(
195+
"https://", f"https://x-access-token:{token}@"
196+
)
197+
push_success = await push_to_branch(
198+
temp_repo_dir, branch_name, authenticated_clone_url
199+
)
200+
201+
if push_success:
202+
# Create pull request
203+
pr_title = f"🤖 EuniFix: {issue_title}"
204+
pr_body = (
205+
f"This PR was automatically generated by EuniBot to fix issue #{issue_number}.\n\n"
206+
f"## Changes Made\n"
207+
f"- {fix_result.message}\n\n"
208+
f"## Files Modified\n"
209+
)
210+
for file_path in fix_result.files_changed:
211+
pr_body += f"- `{file_path}`\n"
212+
213+
pr_body += f"\n## Requested by\n@{comment_author}"
214+
if fix_args:
215+
pr_body += f"\n\n## Arguments\n`{fix_args}`"
216+
217+
pr_body += f"\n\nCloses #{issue_number}"
218+
219+
pr = await github_service.create_pull_request(
220+
repo_owner, repo_name, pr_title, pr_body,
221+
branch_name, default_branch, token
222+
)
223+
224+
# Update placeholder comment with success
225+
success_message = (
226+
f"✅ **EuniFix completed successfully!**\n\n"
227+
f"📋 **Summary**: {fix_result.message}\n"
228+
f"🔧 **Files modified**: {len(fix_result.files_changed)}\n"
229+
f"🌿 **Branch**: `{branch_name}`\n"
230+
f"🔗 **Pull Request**: #{pr['number']} - {pr['html_url']}\n\n"
231+
f"**Modified files:**\n"
232+
)
233+
for file_path in fix_result.files_changed:
234+
success_message += f"- `{file_path}`\n"
235+
236+
await github_service.update_comment(
237+
repo_owner, repo_name, comment_id, success_message, token
238+
)
239+
240+
logger.info(f"Successfully created PR #{pr['number']} for fix request")
241+
else:
242+
raise Exception("Failed to push changes to branch")
243+
else:
244+
raise Exception("Failed to commit changes")
245+
else:
246+
# Update placeholder comment with failure
247+
error_message = (
248+
f"❌ **EuniFix failed**\n\n"
249+
f"📋 **Message**: {fix_result.message}\n"
250+
)
251+
if fix_result.error:
252+
error_message += f"🚨 **Error**: {fix_result.error}\n"
253+
254+
error_message += f"\nRequested by: @{comment_author}"
255+
256+
await github_service.update_comment(
257+
repo_owner, repo_name, comment_id, error_message, token
258+
)
259+
260+
logger.error(f"EuniFix failed: {fix_result.message}")
261+
262+
except Exception as e:
263+
logger.error(f"Error processing fix request: {e}")
264+
265+
try:
266+
# Try to update the placeholder comment with error
267+
error_message = (
268+
f"❌ **EuniFix encountered an error**\n\n"
269+
f"🚨 **Error**: {str(e)}\n"
270+
f"Requested by: @{comment_author}\n\n"
271+
f"Please try again or contact support if the issue persists."
272+
)
273+
274+
# Get token again if needed
275+
if 'token' not in locals():
276+
token = await github_service.get_installation_token(installation_id)
277+
278+
if 'comment_id' in locals():
279+
await github_service.update_comment(
280+
repo_owner, repo_name, comment_id, error_message, token
281+
)
282+
except Exception as update_error:
283+
logger.error(f"Failed to update error comment: {update_error}")
284+
285+
finally:
286+
# Clean up temporary directory
287+
if temp_repo_dir:
288+
try:
289+
import shutil
290+
shutil.rmtree(temp_repo_dir)
291+
logger.info(f"Cleaned up temporary directory: {temp_repo_dir}")
292+
except Exception as cleanup_error:
293+
logger.error(f"Failed to cleanup temp directory: {cleanup_error}")

0 commit comments

Comments
 (0)