Coverage for src/alprina_cli/api/routes/github_webhooks.py: 16%
167 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
1"""
2GitHub Webhooks Handler for Auto-Scan Integration
3Handles PR events and triggers security scans automatically.
4"""
6from fastapi import APIRouter, Request, HTTPException, BackgroundTasks, Header
7from typing import Optional
8import hmac
9import hashlib
10import os
11from loguru import logger
12from datetime import datetime
14from ..services.github_service import GitHubService
15from ..services.github_scanner import GitHubScanner
16from ..services.neon_service import neon_service
18router = APIRouter()
19github_service = GitHubService()
20github_scanner = GitHubScanner()
22# GitHub App credentials
23GITHUB_WEBHOOK_SECRET = os.getenv("GITHUB_WEBHOOK_SECRET", "")
26def verify_github_signature(payload: bytes, signature: str) -> bool:
27 """Verify that webhook came from GitHub."""
28 if not GITHUB_WEBHOOK_SECRET:
29 logger.warning("GITHUB_WEBHOOK_SECRET not set, skipping signature verification")
30 return True # Allow in development
32 expected_signature = "sha256=" + hmac.new(
33 GITHUB_WEBHOOK_SECRET.encode(),
34 payload,
35 hashlib.sha256
36 ).hexdigest()
38 return hmac.compare_digest(expected_signature, signature)
41@router.post("/webhooks/github")
42async def github_webhook(
43 request: Request,
44 background_tasks: BackgroundTasks,
45 x_github_event: Optional[str] = Header(None),
46 x_hub_signature_256: Optional[str] = Header(None),
47 x_github_delivery: Optional[str] = Header(None),
48):
49 """
50 Handle GitHub webhooks for auto-scan integration.
52 Supported events:
53 - pull_request (opened, synchronize, reopened)
54 - push (to main branch)
55 - installation (created, deleted)
56 - installation_repositories (added, removed)
57 """
58 try:
59 # Read raw body for signature verification
60 body = await request.body()
62 # Verify signature
63 if x_hub_signature_256:
64 if not verify_github_signature(body, x_hub_signature_256):
65 logger.warning(f"Invalid GitHub webhook signature")
66 raise HTTPException(status_code=401, detail="Invalid signature")
68 # Parse JSON payload
69 payload = await request.json()
71 logger.info(f"📥 GitHub webhook received: {x_github_event} (delivery: {x_github_delivery})")
73 # Log webhook event to database
74 if neon_service.is_enabled():
75 await _log_webhook_event(
76 event_type=x_github_event or "unknown",
77 delivery_id=x_github_delivery or "",
78 payload=payload
79 )
81 # Handle different event types
82 if x_github_event == "pull_request":
83 background_tasks.add_task(handle_pull_request, payload)
84 elif x_github_event == "push":
85 background_tasks.add_task(handle_push, payload)
86 elif x_github_event == "installation":
87 background_tasks.add_task(handle_installation, payload)
88 elif x_github_event == "installation_repositories":
89 background_tasks.add_task(handle_installation_repositories, payload)
90 else:
91 logger.info(f"Ignoring event type: {x_github_event}")
93 return {"status": "accepted", "event": x_github_event}
95 except Exception as e:
96 logger.error(f"Error handling GitHub webhook: {e}")
97 raise HTTPException(status_code=500, detail=str(e))
100async def handle_pull_request(payload: dict):
101 """Handle pull request events (opened, synchronize, reopened)."""
102 try:
103 action = payload.get("action")
104 pr = payload.get("pull_request", {})
105 repository = payload.get("repository", {})
106 installation = payload.get("installation", {})
108 # Only scan on: opened, synchronize (new commits), reopened
109 if action not in ["opened", "synchronize", "reopened"]:
110 logger.info(f"Ignoring PR action: {action}")
111 return
113 installation_id = installation.get("id")
114 repo_full_name = repository.get("full_name")
115 pr_number = pr.get("number")
116 pr_head_sha = pr.get("head", {}).get("sha")
117 pr_base_sha = pr.get("base", {}).get("sha")
119 logger.info(f"🔍 Scanning PR #{pr_number} in {repo_full_name} (action: {action})")
121 # Get access token for installation
122 access_token = await github_service.get_installation_token(installation_id)
124 # Get list of changed files
125 changed_files = await github_service.get_pr_changed_files(
126 repo_full_name,
127 pr_number,
128 access_token
129 )
131 logger.info(f"📄 Found {len(changed_files)} changed files")
133 # Scan changed files
134 scan_results = await github_scanner.scan_pr_changes(
135 repo_full_name=repo_full_name,
136 pr_number=pr_number,
137 changed_files=changed_files,
138 base_sha=pr_base_sha,
139 head_sha=pr_head_sha,
140 access_token=access_token
141 )
143 # Post comment on PR with results
144 await github_service.post_pr_comment(
145 repo_full_name=repo_full_name,
146 pr_number=pr_number,
147 scan_results=scan_results,
148 access_token=access_token
149 )
151 logger.info(f"✅ PR scan complete for #{pr_number}")
153 except Exception as e:
154 logger.error(f"Error handling pull_request webhook: {e}")
155 raise
158async def handle_push(payload: dict):
159 """Handle push events to main branch."""
160 try:
161 ref = payload.get("ref")
162 repository = payload.get("repository", {})
163 installation = payload.get("installation", {})
164 commits = payload.get("commits", [])
166 # Only scan pushes to default branch (main/master)
167 default_branch = repository.get("default_branch", "main")
168 if ref != f"refs/heads/{default_branch}":
169 logger.info(f"Ignoring push to non-default branch: {ref}")
170 return
172 installation_id = installation.get("id")
173 repo_full_name = repository.get("full_name")
175 logger.info(f"🔍 Scanning push to {repo_full_name}/{default_branch}")
177 # Get access token
178 access_token = await github_service.get_installation_token(installation_id)
180 # Get changed files from commits
181 changed_files = []
182 for commit in commits:
183 changed_files.extend(commit.get("added", []))
184 changed_files.extend(commit.get("modified", []))
186 # Remove duplicates
187 changed_files = list(set(changed_files))
189 logger.info(f"📄 Found {len(changed_files)} changed files across {len(commits)} commits")
191 # Scan changed files
192 scan_results = await github_scanner.scan_push_changes(
193 repo_full_name=repo_full_name,
194 changed_files=changed_files,
195 access_token=access_token
196 )
198 # Create GitHub check run with results
199 await github_service.create_check_run(
200 repo_full_name=repo_full_name,
201 head_sha=payload.get("after"),
202 scan_results=scan_results,
203 access_token=access_token
204 )
206 logger.info(f"✅ Push scan complete for {repo_full_name}")
208 except Exception as e:
209 logger.error(f"Error handling push webhook: {e}")
210 raise
213async def handle_installation(payload: dict):
214 """Handle GitHub App installation events."""
215 try:
216 action = payload.get("action")
217 installation = payload.get("installation", {})
218 repositories = payload.get("repositories", [])
220 installation_id = installation.get("id")
221 account = installation.get("account", {})
222 account_login = account.get("login")
224 logger.info(f"🔧 Installation {action}: {account_login} (ID: {installation_id})")
226 if action == "created":
227 # Store installation in database
228 if neon_service.is_enabled():
229 await _save_installation(
230 installation_id=installation_id,
231 account_login=account_login,
232 account_type=account.get("type"),
233 repositories=[repo.get("full_name") for repo in repositories]
234 )
236 logger.info(f"✅ Installation saved for {account_login}")
238 elif action == "deleted":
239 # Remove installation from database
240 if neon_service.is_enabled():
241 await _delete_installation(installation_id)
243 logger.info(f"✅ Installation removed for {account_login}")
245 except Exception as e:
246 logger.error(f"Error handling installation webhook: {e}")
247 raise
250async def handle_installation_repositories(payload: dict):
251 """Handle repository access changes."""
252 try:
253 action = payload.get("action")
254 installation = payload.get("installation", {})
255 repositories_added = payload.get("repositories_added", [])
256 repositories_removed = payload.get("repositories_removed", [])
258 installation_id = installation.get("id")
260 logger.info(f"📦 Repositories {action} for installation {installation_id}")
262 if action == "added" and repositories_added:
263 # Add repositories to database
264 if neon_service.is_enabled():
265 await _add_repositories(
266 installation_id=installation_id,
267 repositories=[repo.get("full_name") for repo in repositories_added]
268 )
270 logger.info(f"✅ Added {len(repositories_added)} repositories")
272 elif action == "removed" and repositories_removed:
273 # Remove repositories from database
274 if neon_service.is_enabled():
275 await _remove_repositories(
276 installation_id=installation_id,
277 repositories=[repo.get("full_name") for repo in repositories_removed]
278 )
280 logger.info(f"✅ Removed {len(repositories_removed)} repositories")
282 except Exception as e:
283 logger.error(f"Error handling installation_repositories webhook: {e}")
284 raise
287# Database helper functions
288async def _log_webhook_event(event_type: str, delivery_id: str, payload: dict):
289 """Log webhook event to database."""
290 try:
291 async with neon_service.get_pool() as pool:
292 await pool.execute(
293 """
294 INSERT INTO github_webhook_events (event_type, delivery_id, payload, received_at)
295 VALUES ($1, $2, $3, $4)
296 """,
297 event_type,
298 delivery_id,
299 payload,
300 datetime.utcnow()
301 )
302 except Exception as e:
303 logger.error(f"Error logging webhook event: {e}")
306async def _save_installation(
307 installation_id: int,
308 account_login: str,
309 account_type: str,
310 repositories: list
311):
312 """Save GitHub installation to database."""
313 try:
314 async with neon_service.get_pool() as pool:
315 # Insert installation
316 await pool.execute(
317 """
318 INSERT INTO github_installations (
319 installation_id, account_login, account_type, installed_at
320 )
321 VALUES ($1, $2, $3, $4)
322 ON CONFLICT (installation_id) DO UPDATE
323 SET account_login = $2, account_type = $3, installed_at = $4
324 """,
325 installation_id,
326 account_login,
327 account_type,
328 datetime.utcnow()
329 )
331 # Insert repositories
332 for repo_full_name in repositories:
333 await pool.execute(
334 """
335 INSERT INTO github_repositories (
336 installation_id, full_name, added_at
337 )
338 VALUES ($1, $2, $3)
339 ON CONFLICT (installation_id, full_name) DO NOTHING
340 """,
341 installation_id,
342 repo_full_name,
343 datetime.utcnow()
344 )
345 except Exception as e:
346 logger.error(f"Error saving installation: {e}")
349async def _delete_installation(installation_id: int):
350 """Remove installation from database."""
351 try:
352 async with neon_service.get_pool() as pool:
353 await pool.execute(
354 "DELETE FROM github_installations WHERE installation_id = $1",
355 installation_id
356 )
357 except Exception as e:
358 logger.error(f"Error deleting installation: {e}")
361async def _add_repositories(installation_id: int, repositories: list):
362 """Add repositories to installation."""
363 try:
364 async with neon_service.get_pool() as pool:
365 for repo_full_name in repositories:
366 await pool.execute(
367 """
368 INSERT INTO github_repositories (
369 installation_id, full_name, added_at
370 )
371 VALUES ($1, $2, $3)
372 ON CONFLICT (installation_id, full_name) DO NOTHING
373 """,
374 installation_id,
375 repo_full_name,
376 datetime.utcnow()
377 )
378 except Exception as e:
379 logger.error(f"Error adding repositories: {e}")
382async def _remove_repositories(installation_id: int, repositories: list):
383 """Remove repositories from installation."""
384 try:
385 async with neon_service.get_pool() as pool:
386 await pool.execute(
387 """
388 DELETE FROM github_repositories
389 WHERE installation_id = $1 AND full_name = ANY($2)
390 """,
391 installation_id,
392 repositories
393 )
394 except Exception as e:
395 logger.error(f"Error removing repositories: {e}")