codeframe-ai 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,505 @@
1
+ """GitHub API Integration for CodeFRAME.
2
+
3
+ Handles GitHub API operations for Pull Request management.
4
+ Part of Sprint 11 - GitHub PR Integration.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
10
+ import logging
11
+
12
+ import httpx
13
+
14
+ if TYPE_CHECKING:
15
+ from codeframe.core.credentials import CredentialManager
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class GitHubAPIError(Exception):
21
+ """Exception raised when GitHub API returns an error."""
22
+
23
+ def __init__(
24
+ self,
25
+ status_code: int,
26
+ message: str,
27
+ details: Optional[Dict[str, Any]] = None,
28
+ ):
29
+ self.status_code = status_code
30
+ self.message = message
31
+ self.details = details
32
+ super().__init__(f"GitHub API Error ({status_code}): {message}")
33
+
34
+
35
+ @dataclass
36
+ class CICheck:
37
+ """A single CI check run result."""
38
+
39
+ name: str
40
+ status: str # "queued" | "in_progress" | "completed"
41
+ conclusion: Optional[str] # "success" | "failure" | "neutral" | "cancelled" | etc.
42
+
43
+
44
+ @dataclass
45
+ class PRDetails:
46
+ """Pull Request details from GitHub API."""
47
+
48
+ number: int
49
+ url: str
50
+ state: str
51
+ title: str
52
+ body: Optional[str]
53
+ created_at: datetime
54
+ merged_at: Optional[datetime]
55
+ head_branch: str
56
+ base_branch: str
57
+ author: Optional[str] = None
58
+
59
+
60
+ @dataclass
61
+ class MergeResult:
62
+ """Result of a PR merge operation."""
63
+
64
+ sha: Optional[str]
65
+ merged: bool
66
+ message: str
67
+
68
+
69
+ class GitHubIntegration:
70
+ """GitHub API client for PR operations.
71
+
72
+ Provides methods for creating, listing, merging, and closing
73
+ pull requests via the GitHub REST API.
74
+ """
75
+
76
+ BASE_URL = "https://api.github.com"
77
+
78
+ def __init__(
79
+ self,
80
+ token: Optional[str] = None,
81
+ repo: Optional[str] = None,
82
+ credential_manager: Optional["CredentialManager"] = None,
83
+ ):
84
+ """Initialize GitHub integration.
85
+
86
+ Args:
87
+ token: GitHub Personal Access Token with repo scope
88
+ repo: Repository in format "owner/repo"
89
+ credential_manager: Optional credential manager for secure token retrieval
90
+
91
+ Raises:
92
+ ValueError: If repo format is invalid or no token available
93
+ """
94
+ # Try to get token from multiple sources in order:
95
+ # 1. Direct token parameter
96
+ # 2. CredentialManager (if provided)
97
+ # 3. Environment variable (handled by CredentialManager)
98
+ resolved_token = token
99
+
100
+ if not resolved_token and credential_manager:
101
+ from codeframe.core.credentials import CredentialProvider
102
+ resolved_token = credential_manager.get_credential(CredentialProvider.GIT_GITHUB)
103
+
104
+ if not resolved_token:
105
+ import os
106
+ resolved_token = os.getenv("GITHUB_TOKEN")
107
+
108
+ if not resolved_token:
109
+ raise ValueError(
110
+ "GITHUB_TOKEN not set. "
111
+ "Set the environment variable, pass token parameter, "
112
+ "or configure via 'codeframe auth setup --provider github'."
113
+ )
114
+
115
+ if not repo:
116
+ import os
117
+ repo = os.getenv("GITHUB_REPO")
118
+
119
+ if not repo:
120
+ raise ValueError(
121
+ "Repository not specified. "
122
+ "Pass repo parameter or set GITHUB_REPO environment variable."
123
+ )
124
+
125
+ parts = repo.split("/", 1)
126
+ if len(parts) != 2 or not parts[0].strip() or not parts[1].strip():
127
+ raise ValueError(
128
+ f"Invalid repo format: '{repo}'. Expected 'owner/repo'"
129
+ )
130
+
131
+ self.token = resolved_token
132
+ self.repo = repo
133
+ self.owner, self.repo_name = parts[0].strip(), parts[1].strip()
134
+
135
+ self._client = httpx.AsyncClient(
136
+ headers={
137
+ "Authorization": f"Bearer {resolved_token}",
138
+ "Accept": "application/vnd.github.v3+json",
139
+ "X-GitHub-Api-Version": "2022-11-28",
140
+ },
141
+ timeout=30.0,
142
+ )
143
+
144
+ async def _make_request(
145
+ self,
146
+ method: str,
147
+ endpoint: str,
148
+ json_data: Optional[Dict[str, Any]] = None,
149
+ ) -> Any:
150
+ """Make an authenticated request to GitHub API.
151
+
152
+ Args:
153
+ method: HTTP method (GET, POST, PATCH, PUT, DELETE)
154
+ endpoint: API endpoint path
155
+ json_data: Optional JSON body data
156
+
157
+ Returns:
158
+ Parsed JSON response
159
+
160
+ Raises:
161
+ GitHubAPIError: If API returns an error status
162
+ """
163
+ url = f"{self.BASE_URL}{endpoint}"
164
+
165
+ try:
166
+ response = await self._client.request(
167
+ method=method,
168
+ url=url,
169
+ json=json_data,
170
+ )
171
+
172
+ if response.status_code >= 400:
173
+ try:
174
+ error_data = response.json()
175
+ message = error_data.get("message", response.text)
176
+ details = error_data.get("errors")
177
+ except Exception:
178
+ message = response.text
179
+ details = None
180
+
181
+ raise GitHubAPIError(
182
+ status_code=response.status_code,
183
+ message=message,
184
+ details={"errors": details} if details else None,
185
+ )
186
+
187
+ # Handle empty responses (204 No Content)
188
+ if response.status_code == 204:
189
+ return None
190
+
191
+ return response.json()
192
+
193
+ except httpx.TimeoutException as e:
194
+ logger.error(f"GitHub API timeout: {e}")
195
+ raise GitHubAPIError(
196
+ status_code=408,
197
+ message="Request timed out",
198
+ )
199
+ except httpx.RequestError as e:
200
+ logger.error(f"GitHub API request error: {e}")
201
+ raise GitHubAPIError(
202
+ status_code=500,
203
+ message=f"Request failed: {str(e)}",
204
+ )
205
+
206
+ def _parse_pr_response(self, data: Dict[str, Any]) -> PRDetails:
207
+ """Parse GitHub PR response into PRDetails object.
208
+
209
+ Args:
210
+ data: Raw GitHub API response
211
+
212
+ Returns:
213
+ Parsed PRDetails object
214
+ """
215
+ created_at = datetime.fromisoformat(
216
+ data["created_at"].replace("Z", "+00:00")
217
+ )
218
+ merged_at = None
219
+ if data.get("merged_at"):
220
+ merged_at = datetime.fromisoformat(
221
+ data["merged_at"].replace("Z", "+00:00")
222
+ )
223
+
224
+ user = data.get("user")
225
+ author = user.get("login") if isinstance(user, dict) else None
226
+
227
+ return PRDetails(
228
+ number=data["number"],
229
+ url=data["html_url"],
230
+ state=data["state"],
231
+ title=data["title"],
232
+ body=data.get("body"),
233
+ created_at=created_at,
234
+ merged_at=merged_at,
235
+ head_branch=data["head"]["ref"],
236
+ base_branch=data["base"]["ref"],
237
+ author=author,
238
+ )
239
+
240
+ async def create_pull_request(
241
+ self,
242
+ branch: str,
243
+ title: str,
244
+ body: str,
245
+ base: str = "main",
246
+ ) -> PRDetails:
247
+ """Create a new pull request.
248
+
249
+ Args:
250
+ branch: Head branch with changes
251
+ title: PR title
252
+ body: PR description
253
+ base: Base branch to merge into (default: main)
254
+
255
+ Returns:
256
+ PRDetails with the created PR info
257
+
258
+ Raises:
259
+ GitHubAPIError: If PR creation fails
260
+ """
261
+ endpoint = f"/repos/{self.owner}/{self.repo_name}/pulls"
262
+
263
+ data = await self._make_request(
264
+ method="POST",
265
+ endpoint=endpoint,
266
+ json_data={
267
+ "title": title,
268
+ "body": body,
269
+ "head": branch,
270
+ "base": base,
271
+ },
272
+ )
273
+
274
+ logger.info(f"Created PR #{data['number']}: {title}")
275
+ return self._parse_pr_response(data)
276
+
277
+ async def get_pull_request(self, pr_number: int) -> PRDetails:
278
+ """Get pull request details.
279
+
280
+ Args:
281
+ pr_number: PR number
282
+
283
+ Returns:
284
+ PRDetails with the PR info
285
+
286
+ Raises:
287
+ GitHubAPIError: If PR not found or API error
288
+ """
289
+ endpoint = f"/repos/{self.owner}/{self.repo_name}/pulls/{pr_number}"
290
+
291
+ data = await self._make_request(
292
+ method="GET",
293
+ endpoint=endpoint,
294
+ )
295
+
296
+ return self._parse_pr_response(data)
297
+
298
+ async def list_pull_requests(
299
+ self,
300
+ state: str = "open",
301
+ ) -> List[PRDetails]:
302
+ """List pull requests for the repository.
303
+
304
+ Args:
305
+ state: Filter by state (open, closed, all)
306
+
307
+ Returns:
308
+ List of PRDetails
309
+
310
+ Raises:
311
+ GitHubAPIError: If API error occurs
312
+ """
313
+ endpoint = f"/repos/{self.owner}/{self.repo_name}/pulls"
314
+
315
+ data = await self._make_request(
316
+ method="GET",
317
+ endpoint=f"{endpoint}?state={state}",
318
+ )
319
+
320
+ return [self._parse_pr_response(pr) for pr in data]
321
+
322
+ async def merge_pull_request(
323
+ self,
324
+ pr_number: int,
325
+ method: str = "squash",
326
+ ) -> MergeResult:
327
+ """Merge a pull request.
328
+
329
+ Args:
330
+ pr_number: PR number to merge
331
+ method: Merge method (merge, squash, rebase)
332
+
333
+ Returns:
334
+ MergeResult with merge outcome
335
+
336
+ Raises:
337
+ GitHubAPIError: If merge fails
338
+ """
339
+ endpoint = f"/repos/{self.owner}/{self.repo_name}/pulls/{pr_number}/merge"
340
+
341
+ data = await self._make_request(
342
+ method="PUT",
343
+ endpoint=endpoint,
344
+ json_data={
345
+ "merge_method": method,
346
+ },
347
+ )
348
+
349
+ logger.info(f"Merged PR #{pr_number} with method '{method}'")
350
+ return MergeResult(
351
+ sha=data.get("sha"),
352
+ merged=data.get("merged", False),
353
+ message=data.get("message", ""),
354
+ )
355
+
356
+ async def close_pull_request(self, pr_number: int) -> bool:
357
+ """Close a pull request without merging.
358
+
359
+ Args:
360
+ pr_number: PR number to close
361
+
362
+ Returns:
363
+ True if successfully closed
364
+
365
+ Raises:
366
+ GitHubAPIError: If close fails
367
+ """
368
+ endpoint = f"/repos/{self.owner}/{self.repo_name}/pulls/{pr_number}"
369
+
370
+ data = await self._make_request(
371
+ method="PATCH",
372
+ endpoint=endpoint,
373
+ json_data={
374
+ "state": "closed",
375
+ },
376
+ )
377
+
378
+ logger.info(f"Closed PR #{pr_number}")
379
+ return data.get("state") == "closed"
380
+
381
+ async def get_pr_files(self, pr_number: int) -> List[str]:
382
+ """Get the list of files changed in a pull request.
383
+
384
+ Paginates through all pages (100 per page) to ensure completeness.
385
+
386
+ Args:
387
+ pr_number: PR number
388
+
389
+ Returns:
390
+ List of filenames changed in the PR
391
+
392
+ Raises:
393
+ GitHubAPIError: If API error occurs
394
+ """
395
+ files: List[str] = []
396
+ page = 1
397
+ while True:
398
+ endpoint = (
399
+ f"/repos/{self.owner}/{self.repo_name}/pulls/{pr_number}/files"
400
+ f"?per_page=100&page={page}"
401
+ )
402
+ data = await self._make_request(method="GET", endpoint=endpoint)
403
+ if not isinstance(data, list) or not data:
404
+ break
405
+ files.extend(f["filename"] for f in data)
406
+ if len(data) < 100:
407
+ break
408
+ page += 1
409
+ return files
410
+
411
+ async def get_pr_ci_checks(
412
+ self,
413
+ pr_number: int,
414
+ head_sha: Optional[str] = None,
415
+ ) -> List[CICheck]:
416
+ """Get CI check runs for a pull request.
417
+
418
+ Args:
419
+ pr_number: PR number
420
+ head_sha: Head commit SHA (fetched from the PR if not provided)
421
+
422
+ Returns:
423
+ List of CICheck results
424
+ """
425
+ if head_sha is None:
426
+ pr_data = await self._make_request(
427
+ "GET",
428
+ f"/repos/{self.owner}/{self.repo_name}/pulls/{pr_number}",
429
+ )
430
+ head_sha = pr_data["head"]["sha"]
431
+
432
+ data = await self._make_request(
433
+ "GET",
434
+ f"/repos/{self.owner}/{self.repo_name}/commits/{head_sha}/check-runs",
435
+ )
436
+ check_runs = data.get("check_runs", []) if isinstance(data, dict) else []
437
+ normalized: list[CICheck] = []
438
+ for run in check_runs:
439
+ if not isinstance(run, dict):
440
+ continue
441
+ name = run.get("name")
442
+ status = run.get("status")
443
+ if not name or not status:
444
+ logger.warning("Skipping malformed check-run entry: %s", run)
445
+ continue
446
+ normalized.append(
447
+ CICheck(name=name, status=status, conclusion=run.get("conclusion"))
448
+ )
449
+ return normalized
450
+
451
+ async def get_pr_review_status(self, pr_number: int) -> str:
452
+ """Get the aggregate review status for a pull request.
453
+
454
+ Returns "changes_requested" if any reviewer requested changes,
455
+ "approved" if any reviewer approved (and none requested changes),
456
+ or "pending" if there are no actionable reviews.
457
+
458
+ Args:
459
+ pr_number: PR number
460
+
461
+ Returns:
462
+ "approved" | "changes_requested" | "pending"
463
+ """
464
+ reviews = await self._make_request(
465
+ "GET",
466
+ f"/repos/{self.owner}/{self.repo_name}/pulls/{pr_number}/reviews",
467
+ )
468
+
469
+ # Guard against unexpected response shapes.
470
+ if not isinstance(reviews, list):
471
+ reviews = []
472
+
473
+ # Only count actionable states — exclude DISMISSED, COMMENTED, and non-dicts.
474
+ ACTIONABLE = {"APPROVED", "CHANGES_REQUESTED"}
475
+
476
+ # Collapse per-reviewer: keep only each reviewer's latest actionable review.
477
+ # A reviewer who requested changes and later approved should be counted as
478
+ # approved, not as both.
479
+ latest_per_reviewer: dict[str, dict] = {}
480
+ for r in reviews:
481
+ if not isinstance(r, dict):
482
+ continue
483
+ if r.get("state") not in ACTIONABLE:
484
+ continue
485
+ reviewer = r.get("user", {}).get("login") if isinstance(r.get("user"), dict) else None
486
+ if reviewer is None:
487
+ reviewer = str(r.get("id", id(r)))
488
+ existing = latest_per_reviewer.get(reviewer)
489
+ if existing is None or (r.get("submitted_at") or "") >= (existing.get("submitted_at") or ""):
490
+ latest_per_reviewer[reviewer] = r
491
+
492
+ active = list(latest_per_reviewer.values())
493
+
494
+ has_changes_requested = any(r.get("state") == "CHANGES_REQUESTED" for r in active)
495
+ has_approved = any(r.get("state") == "APPROVED" for r in active)
496
+
497
+ if has_changes_requested:
498
+ return "changes_requested"
499
+ if has_approved:
500
+ return "approved"
501
+ return "pending"
502
+
503
+ async def close(self) -> None:
504
+ """Close the HTTP client."""
505
+ await self._client.aclose()
File without changes