Coverage for src/alprina_cli/api/services/github_service.py: 17%

133 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2025-11-14 11:27 +0100

1""" 

2GitHub API Service 

3Handles GitHub API interactions, authentication, and PR comments. 

4""" 

5 

6import os 

7import jwt 

8import time 

9import httpx 

10from typing import Optional, List, Dict 

11from loguru import logger 

12from datetime import datetime, timedelta 

13 

14 

15class GitHubService: 

16 """Service for interacting with GitHub API.""" 

17 

18 def __init__(self): 

19 self.app_id = os.getenv("GITHUB_APP_ID") 

20 self.private_key = os.getenv("GITHUB_PRIVATE_KEY", "").replace("\\n", "\n") 

21 self.base_url = "https://api.github.com" 

22 self._installation_tokens = {} # Cache tokens 

23 

24 def _generate_jwt(self) -> str: 

25 """Generate JWT for GitHub App authentication.""" 

26 now = int(time.time()) 

27 payload = { 

28 "iat": now, 

29 "exp": now + (10 * 60), # 10 minutes 

30 "iss": self.app_id 

31 } 

32 

33 return jwt.encode(payload, self.private_key, algorithm="RS256") 

34 

35 async def get_installation_token(self, installation_id: int) -> str: 

36 """ 

37 Get access token for a GitHub App installation. 

38 Caches tokens and refreshes when expired. 

39 """ 

40 # Check cache 

41 if installation_id in self._installation_tokens: 

42 token_data = self._installation_tokens[installation_id] 

43 if datetime.utcnow() < token_data["expires_at"]: 

44 return token_data["token"] 

45 

46 # Generate new token 

47 jwt_token = self._generate_jwt() 

48 

49 async with httpx.AsyncClient() as client: 

50 response = await client.post( 

51 f"{self.base_url}/app/installations/{installation_id}/access_tokens", 

52 headers={ 

53 "Authorization": f"Bearer {jwt_token}", 

54 "Accept": "application/vnd.github+json" 

55 } 

56 ) 

57 response.raise_for_status() 

58 data = response.json() 

59 

60 # Cache token 

61 self._installation_tokens[installation_id] = { 

62 "token": data["token"], 

63 "expires_at": datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) 

64 } 

65 

66 return data["token"] 

67 

68 async def get_pr_changed_files( 

69 self, 

70 repo_full_name: str, 

71 pr_number: int, 

72 access_token: str 

73 ) -> List[Dict]: 

74 """Get list of files changed in a pull request.""" 

75 async with httpx.AsyncClient() as client: 

76 response = await client.get( 

77 f"{self.base_url}/repos/{repo_full_name}/pulls/{pr_number}/files", 

78 headers={ 

79 "Authorization": f"Bearer {access_token}", 

80 "Accept": "application/vnd.github+json" 

81 } 

82 ) 

83 response.raise_for_status() 

84 return response.json() 

85 

86 async def get_file_content( 

87 self, 

88 repo_full_name: str, 

89 file_path: str, 

90 ref: str, 

91 access_token: str 

92 ) -> Optional[str]: 

93 """Get content of a file from repository.""" 

94 try: 

95 async with httpx.AsyncClient() as client: 

96 response = await client.get( 

97 f"{self.base_url}/repos/{repo_full_name}/contents/{file_path}", 

98 params={"ref": ref}, 

99 headers={ 

100 "Authorization": f"Bearer {access_token}", 

101 "Accept": "application/vnd.github.raw" 

102 } 

103 ) 

104 response.raise_for_status() 

105 return response.text 

106 except Exception as e: 

107 logger.error(f"Error fetching file {file_path}: {e}") 

108 return None 

109 

110 async def post_pr_comment( 

111 self, 

112 repo_full_name: str, 

113 pr_number: int, 

114 scan_results: Dict, 

115 access_token: str 

116 ): 

117 """Post security scan results as PR comment.""" 

118 comment_body = self._format_pr_comment(scan_results) 

119 

120 # Check if we already posted a comment (to update instead of duplicate) 

121 existing_comment_id = await self._find_existing_comment( 

122 repo_full_name, pr_number, access_token 

123 ) 

124 

125 async with httpx.AsyncClient() as client: 

126 if existing_comment_id: 

127 # Update existing comment 

128 response = await client.patch( 

129 f"{self.base_url}/repos/{repo_full_name}/issues/comments/{existing_comment_id}", 

130 headers={ 

131 "Authorization": f"Bearer {access_token}", 

132 "Accept": "application/vnd.github+json" 

133 }, 

134 json={"body": comment_body} 

135 ) 

136 else: 

137 # Create new comment 

138 response = await client.post( 

139 f"{self.base_url}/repos/{repo_full_name}/issues/{pr_number}/comments", 

140 headers={ 

141 "Authorization": f"Bearer {access_token}", 

142 "Accept": "application/vnd.github+json" 

143 }, 

144 json={"body": comment_body} 

145 ) 

146 

147 response.raise_for_status() 

148 logger.info(f"✅ Posted comment on PR #{pr_number}") 

149 

150 async def _find_existing_comment( 

151 self, 

152 repo_full_name: str, 

153 pr_number: int, 

154 access_token: str 

155 ) -> Optional[int]: 

156 """Find existing Alprina comment on PR.""" 

157 try: 

158 async with httpx.AsyncClient() as client: 

159 response = await client.get( 

160 f"{self.base_url}/repos/{repo_full_name}/issues/{pr_number}/comments", 

161 headers={ 

162 "Authorization": f"Bearer {access_token}", 

163 "Accept": "application/vnd.github+json" 

164 } 

165 ) 

166 response.raise_for_status() 

167 comments = response.json() 

168 

169 # Look for comment containing our marker 

170 for comment in comments: 

171 if "<!-- alprina-security-scan -->" in comment["body"]: 

172 return comment["id"] 

173 

174 return None 

175 except Exception as e: 

176 logger.error(f"Error finding existing comment: {e}") 

177 return None 

178 

179 def _format_pr_comment(self, scan_results: Dict) -> str: 

180 """Format scan results as beautiful GitHub markdown comment.""" 

181 findings = scan_results.get("findings", []) 

182 new_findings = [f for f in findings if f.get("is_new", True)] 

183 critical = [f for f in new_findings if f["severity"] == "critical"] 

184 high = [f for f in new_findings if f["severity"] == "high"] 

185 medium = [f for f in new_findings if f["severity"] == "medium"] 

186 

187 # Determine overall status 

188 if critical: 

189 status_emoji = "🚨" 

190 status_text = f"{len(critical)} critical issue{'s' if len(critical) != 1 else ''} found" 

191 status_color = "🔴" 

192 elif high: 

193 status_emoji = "⚠️" 

194 status_text = f"{len(high)} high severity issue{'s' if len(high) != 1 else ''} found" 

195 status_color = "🟠" 

196 elif medium: 

197 status_emoji = "ℹ️" 

198 status_text = f"{len(medium)} medium severity issue{'s' if len(medium) != 1 else ''} found" 

199 status_color = "🟡" 

200 else: 

201 status_emoji = "✅" 

202 status_text = "No new vulnerabilities introduced" 

203 status_color = "🟢" 

204 

205 # Build comment 

206 comment = f"""<!-- alprina-security-scan --> 

207## {status_emoji} Alprina Security Scan 

208 

209{status_color} **{status_text}** 

210 

211""" 

212 

213 # Add summary if there are findings 

214 if new_findings: 

215 total_findings = scan_results.get("summary", {}) 

216 comment += f""" 

217### 📊 Security Impact 

218- 🚨 Critical: {total_findings.get('critical', 0)}  

219- ⚠️ High: {total_findings.get('high', 0)} 

220- ℹ️ Medium: {total_findings.get('medium', 0)} 

221- ✓ Low: {total_findings.get('low', 0)} 

222 

223""" 

224 

225 # Show top 3 critical/high findings 

226 top_findings = (critical + high)[:3] 

227 if top_findings: 

228 comment += "### 🔍 Top Issues\n\n" 

229 

230 for i, finding in enumerate(top_findings, 1): 

231 severity_emoji = "🚨" if finding["severity"] == "critical" else "⚠️" 

232 comment += f""" 

233<details> 

234<summary>{severity_emoji} <strong>{finding['title']}</strong> in <code>{finding['file']}</code>:{finding['line']}</summary> 

235 

236**Severity:** {finding['severity'].upper()}  

237**Risk:** {finding.get('risk', 'High')} 

238 

239**Vulnerable Code:** 

240```{finding.get('language', 'python')} 

241{finding.get('code_snippet', 'N/A')} 

242``` 

243 

244**Why This is Dangerous:**  

245{finding.get('description', 'Security vulnerability detected')} 

246 

247**How to Fix:**  

248{finding.get('fix_recommendation', 'Review and remediate this vulnerability')} 

249 

250[📖 Learn More]({finding.get('learn_more_url', '#')}) | [🔧 View Full Report](https://alprina.com/dashboard/findings/{finding.get('id', '')}) 

251 

252</details> 

253 

254""" 

255 

256 # Add footer 

257 if new_findings: 

258 comment += f"\n---\n" 

259 comment += f"**{len(new_findings)} new issue{'s' if len(new_findings) != 1 else ''} found in this PR** \n" 

260 comment += f"[📊 View Full Report](https://alprina.com/dashboard/scans/{scan_results.get('scan_id', '')}) | " 

261 comment += f"[🛡️ Dashboard](https://alprina.com/dashboard) | " 

262 comment += f"[📚 Docs](https://docs.alprina.com)\n\n" 

263 else: 

264 comment += f"\n---\n" 

265 comment += f"✅ **Great work!** No new security issues detected in this PR.\n\n" 

266 

267 comment += f"<sub>Powered by [Alprina](https://alprina.com) • [Configure](https://alprina.com/dashboard/settings/github)</sub>" 

268 

269 return comment 

270 

271 async def create_check_run( 

272 self, 

273 repo_full_name: str, 

274 head_sha: str, 

275 scan_results: Dict, 

276 access_token: str 

277 ): 

278 """Create GitHub check run with scan results.""" 

279 findings = scan_results.get("findings", []) 

280 critical = [f for f in findings if f["severity"] == "critical"] 

281 high = [f for f in findings if f["severity"] == "high"] 

282 

283 # Determine check status 

284 if critical: 

285 conclusion = "failure" 

286 title = f"🚨 {len(critical)} critical security issue{'s' if len(critical) != 1 else ''} found" 

287 elif high: 

288 conclusion = "neutral" # Warning but don't fail 

289 title = f"⚠️ {len(high)} high severity issue{'s' if len(high) != 1 else ''} found" 

290 else: 

291 conclusion = "success" 

292 title = "✅ No critical security issues found" 

293 

294 async with httpx.AsyncClient() as client: 

295 response = await client.post( 

296 f"{self.base_url}/repos/{repo_full_name}/check-runs", 

297 headers={ 

298 "Authorization": f"Bearer {access_token}", 

299 "Accept": "application/vnd.github+json" 

300 }, 

301 json={ 

302 "name": "Alprina Security Scan", 

303 "head_sha": head_sha, 

304 "status": "completed", 

305 "conclusion": conclusion, 

306 "output": { 

307 "title": title, 

308 "summary": f"Scanned {scan_results.get('files_scanned', 0)} files", 

309 "text": self._format_check_output(scan_results) 

310 } 

311 } 

312 ) 

313 response.raise_for_status() 

314 logger.info(f"✅ Created check run for {head_sha}") 

315 

316 def _format_check_output(self, scan_results: Dict) -> str: 

317 """Format scan results for check run output.""" 

318 findings = scan_results.get("findings", []) 

319 if not findings: 

320 return "No security vulnerabilities detected. Great job! 🎉" 

321 

322 output = "## Security Issues Found\n\n" 

323 for finding in findings[:10]: # Show max 10 

324 output += f"- **{finding['title']}** in `{finding['file']}`:{finding['line']}\n" 

325 

326 if len(findings) > 10: 

327 output += f"\n... and {len(findings) - 10} more\n" 

328 

329 output += f"\n[View full report](https://alprina.com/dashboard/scans/{scan_results.get('scan_id', '')})" 

330 

331 return output