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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- 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
|