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

1""" 

2GitHub Webhooks Handler for Auto-Scan Integration 

3Handles PR events and triggers security scans automatically. 

4""" 

5 

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 

13 

14from ..services.github_service import GitHubService 

15from ..services.github_scanner import GitHubScanner 

16from ..services.neon_service import neon_service 

17 

18router = APIRouter() 

19github_service = GitHubService() 

20github_scanner = GitHubScanner() 

21 

22# GitHub App credentials 

23GITHUB_WEBHOOK_SECRET = os.getenv("GITHUB_WEBHOOK_SECRET", "") 

24 

25 

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 

31 

32 expected_signature = "sha256=" + hmac.new( 

33 GITHUB_WEBHOOK_SECRET.encode(), 

34 payload, 

35 hashlib.sha256 

36 ).hexdigest() 

37 

38 return hmac.compare_digest(expected_signature, signature) 

39 

40 

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. 

51  

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() 

61 

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") 

67 

68 # Parse JSON payload 

69 payload = await request.json() 

70 

71 logger.info(f"📥 GitHub webhook received: {x_github_event} (delivery: {x_github_delivery})") 

72 

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 ) 

80 

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}") 

92 

93 return {"status": "accepted", "event": x_github_event} 

94 

95 except Exception as e: 

96 logger.error(f"Error handling GitHub webhook: {e}") 

97 raise HTTPException(status_code=500, detail=str(e)) 

98 

99 

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", {}) 

107 

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 

112 

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") 

118 

119 logger.info(f"🔍 Scanning PR #{pr_number} in {repo_full_name} (action: {action})") 

120 

121 # Get access token for installation 

122 access_token = await github_service.get_installation_token(installation_id) 

123 

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 ) 

130 

131 logger.info(f"📄 Found {len(changed_files)} changed files") 

132 

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 ) 

142 

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 ) 

150 

151 logger.info(f"✅ PR scan complete for #{pr_number}") 

152 

153 except Exception as e: 

154 logger.error(f"Error handling pull_request webhook: {e}") 

155 raise 

156 

157 

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", []) 

165 

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 

171 

172 installation_id = installation.get("id") 

173 repo_full_name = repository.get("full_name") 

174 

175 logger.info(f"🔍 Scanning push to {repo_full_name}/{default_branch}") 

176 

177 # Get access token 

178 access_token = await github_service.get_installation_token(installation_id) 

179 

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", [])) 

185 

186 # Remove duplicates 

187 changed_files = list(set(changed_files)) 

188 

189 logger.info(f"📄 Found {len(changed_files)} changed files across {len(commits)} commits") 

190 

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 ) 

197 

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 ) 

205 

206 logger.info(f"✅ Push scan complete for {repo_full_name}") 

207 

208 except Exception as e: 

209 logger.error(f"Error handling push webhook: {e}") 

210 raise 

211 

212 

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", []) 

219 

220 installation_id = installation.get("id") 

221 account = installation.get("account", {}) 

222 account_login = account.get("login") 

223 

224 logger.info(f"🔧 Installation {action}: {account_login} (ID: {installation_id})") 

225 

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 ) 

235 

236 logger.info(f"✅ Installation saved for {account_login}") 

237 

238 elif action == "deleted": 

239 # Remove installation from database 

240 if neon_service.is_enabled(): 

241 await _delete_installation(installation_id) 

242 

243 logger.info(f"✅ Installation removed for {account_login}") 

244 

245 except Exception as e: 

246 logger.error(f"Error handling installation webhook: {e}") 

247 raise 

248 

249 

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", []) 

257 

258 installation_id = installation.get("id") 

259 

260 logger.info(f"📦 Repositories {action} for installation {installation_id}") 

261 

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 ) 

269 

270 logger.info(f"✅ Added {len(repositories_added)} repositories") 

271 

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 ) 

279 

280 logger.info(f"✅ Removed {len(repositories_removed)} repositories") 

281 

282 except Exception as e: 

283 logger.error(f"Error handling installation_repositories webhook: {e}") 

284 raise 

285 

286 

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}") 

304 

305 

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 ) 

330 

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}") 

347 

348 

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}") 

359 

360 

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}") 

380 

381 

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}")