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
« 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"""
6import os
7import jwt
8import time
9import httpx
10from typing import Optional, List, Dict
11from loguru import logger
12from datetime import datetime, timedelta
15class GitHubService:
16 """Service for interacting with GitHub API."""
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
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 }
33 return jwt.encode(payload, self.private_key, algorithm="RS256")
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"]
46 # Generate new token
47 jwt_token = self._generate_jwt()
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()
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 }
66 return data["token"]
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()
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
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)
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 )
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 )
147 response.raise_for_status()
148 logger.info(f"✅ Posted comment on PR #{pr_number}")
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()
169 # Look for comment containing our marker
170 for comment in comments:
171 if "<!-- alprina-security-scan -->" in comment["body"]:
172 return comment["id"]
174 return None
175 except Exception as e:
176 logger.error(f"Error finding existing comment: {e}")
177 return None
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"]
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 = "🟢"
205 # Build comment
206 comment = f"""<!-- alprina-security-scan -->
207## {status_emoji} Alprina Security Scan
209{status_color} **{status_text}**
211"""
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)}
223"""
225 # Show top 3 critical/high findings
226 top_findings = (critical + high)[:3]
227 if top_findings:
228 comment += "### 🔍 Top Issues\n\n"
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>
236**Severity:** {finding['severity'].upper()}
237**Risk:** {finding.get('risk', 'High')}
239**Vulnerable Code:**
240```{finding.get('language', 'python')}
241{finding.get('code_snippet', 'N/A')}
242```
244**Why This is Dangerous:**
245{finding.get('description', 'Security vulnerability detected')}
247**How to Fix:**
248{finding.get('fix_recommendation', 'Review and remediate this vulnerability')}
250[📖 Learn More]({finding.get('learn_more_url', '#')}) | [🔧 View Full Report](https://alprina.com/dashboard/findings/{finding.get('id', '')})
252</details>
254"""
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"
267 comment += f"<sub>Powered by [Alprina](https://alprina.com) • [Configure](https://alprina.com/dashboard/settings/github)</sub>"
269 return comment
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"]
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"
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}")
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! 🎉"
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"
326 if len(findings) > 10:
327 output += f"\n... and {len(findings) - 10} more\n"
329 output += f"\n[View full report](https://alprina.com/dashboard/scans/{scan_results.get('scan_id', '')})"
331 return output