Skip to content

Commit 36c101d

Browse files
frankbriaTest User
andauthored
feat(api): add GitHub PR integration for pull request management (#284)
* feat(api): add GitHub PR integration for pull request management Sprint 11 implementation - adds complete GitHub PR integration: Database: - Added pull_requests table with status tracking (draft/open/merged/closed) - Added indexes for project, issue, and branch lookups Backend: - PRRepository: Database operations for PR CRUD - GitHubIntegration: Async httpx client for GitHub REST API - PR Router: POST/GET/MERGE/CLOSE endpoints at /api/projects/{id}/prs - WebSocket broadcasts for pr_created, pr_merged, pr_closed events Configuration: - GITHUB_TOKEN and GITHUB_REPO environment variables - Updated .env.example with GitHub integration docs Tests (45 new tests): - PRRepository: 14 unit tests for database operations - GitHubIntegration: 17 unit tests with mocked API - PR Router: 14 API endpoint tests This enables GitHub-based PR workflows when GITHUB_TOKEN is configured, while maintaining backward compatibility for local-only operations. Refs: #272 * fix(pr-integration): address code review feedback - Fix NameError in finally blocks by initializing gh = None before try - Fix timezone inconsistency: use datetime.now(UTC) consistently - Add pull_requests to async connection propagation in database.py - Only update merge status when result.merged is True - Improve repo format validation to reject empty owner/repo - Constrain merge method to Literal["squash", "merge", "rebase"] - Use BaseRepository helpers with proper cursor.lastrowid handling - Add rowcount checks for update operations to raise on missing pr_id * fix(pr-integration): make close_pull_request updates conditional Only update database status and broadcast WebSocket event when gh.close_pull_request returns True. This prevents data inconsistency between GitHub's actual state and the local database when close fails. Matches the pattern already used in merge_pull_request. --------- Co-authored-by: Test User <test@example.com>
1 parent bc32826 commit 36c101d

13 files changed

Lines changed: 2132 additions & 1 deletion

File tree

.env.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,16 @@ AUTH_SECRET=CHANGE-ME-IN-PRODUCTION
8888
# Enable authentication requirement (default: false for migration)
8989
# Set to true in production to enforce authentication
9090
# AUTH_REQUIRED=false
91+
92+
# ============================================================================
93+
# GitHub Integration (Optional - for PR creation)
94+
# ============================================================================
95+
96+
# GitHub Personal Access Token with repo scope
97+
# Get yours at: https://github.com/settings/tokens
98+
# Required for: creating PRs, merging PRs, GitHub integration
99+
# GITHUB_TOKEN=ghp_...
100+
101+
# Target repository in format "owner/repo"
102+
# Example: frankbria/codeframe
103+
# GITHUB_REPO=owner/repo

codeframe/core/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ class GlobalConfig(BaseSettings):
115115
default_provider: str = "claude"
116116
default_model: str = "claude-sonnet-4"
117117

118+
# GitHub Integration (Sprint 11 - PR Management)
119+
github_token: Optional[str] = Field(None, alias="GITHUB_TOKEN")
120+
github_repo: Optional[str] = Field(None, alias="GITHUB_REPO") # Format: "owner/repo"
121+
118122
model_config = SettingsConfigDict(
119123
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
120124
)
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
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 Any, Dict, List, Optional
10+
import logging
11+
12+
import httpx
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
class GitHubAPIError(Exception):
18+
"""Exception raised when GitHub API returns an error."""
19+
20+
def __init__(
21+
self,
22+
status_code: int,
23+
message: str,
24+
details: Optional[Dict[str, Any]] = None,
25+
):
26+
self.status_code = status_code
27+
self.message = message
28+
self.details = details
29+
super().__init__(f"GitHub API Error ({status_code}): {message}")
30+
31+
32+
@dataclass
33+
class PRDetails:
34+
"""Pull Request details from GitHub API."""
35+
36+
number: int
37+
url: str
38+
state: str
39+
title: str
40+
body: Optional[str]
41+
created_at: datetime
42+
merged_at: Optional[datetime]
43+
head_branch: str
44+
base_branch: str
45+
46+
47+
@dataclass
48+
class MergeResult:
49+
"""Result of a PR merge operation."""
50+
51+
sha: Optional[str]
52+
merged: bool
53+
message: str
54+
55+
56+
class GitHubIntegration:
57+
"""GitHub API client for PR operations.
58+
59+
Provides methods for creating, listing, merging, and closing
60+
pull requests via the GitHub REST API.
61+
"""
62+
63+
BASE_URL = "https://api.github.com"
64+
65+
def __init__(self, token: str, repo: str):
66+
"""Initialize GitHub integration.
67+
68+
Args:
69+
token: GitHub Personal Access Token with repo scope
70+
repo: Repository in format "owner/repo"
71+
72+
Raises:
73+
ValueError: If repo format is invalid
74+
"""
75+
parts = repo.split("/", 1)
76+
if len(parts) != 2 or not parts[0].strip() or not parts[1].strip():
77+
raise ValueError(
78+
f"Invalid repo format: '{repo}'. Expected 'owner/repo'"
79+
)
80+
81+
self.token = token
82+
self.repo = repo
83+
self.owner, self.repo_name = parts[0].strip(), parts[1].strip()
84+
85+
self._client = httpx.AsyncClient(
86+
headers={
87+
"Authorization": f"Bearer {token}",
88+
"Accept": "application/vnd.github.v3+json",
89+
"X-GitHub-Api-Version": "2022-11-28",
90+
},
91+
timeout=30.0,
92+
)
93+
94+
async def _make_request(
95+
self,
96+
method: str,
97+
endpoint: str,
98+
json_data: Optional[Dict[str, Any]] = None,
99+
) -> Any:
100+
"""Make an authenticated request to GitHub API.
101+
102+
Args:
103+
method: HTTP method (GET, POST, PATCH, PUT, DELETE)
104+
endpoint: API endpoint path
105+
json_data: Optional JSON body data
106+
107+
Returns:
108+
Parsed JSON response
109+
110+
Raises:
111+
GitHubAPIError: If API returns an error status
112+
"""
113+
url = f"{self.BASE_URL}{endpoint}"
114+
115+
try:
116+
response = await self._client.request(
117+
method=method,
118+
url=url,
119+
json=json_data,
120+
)
121+
122+
if response.status_code >= 400:
123+
try:
124+
error_data = response.json()
125+
message = error_data.get("message", response.text)
126+
details = error_data.get("errors")
127+
except Exception:
128+
message = response.text
129+
details = None
130+
131+
raise GitHubAPIError(
132+
status_code=response.status_code,
133+
message=message,
134+
details={"errors": details} if details else None,
135+
)
136+
137+
# Handle empty responses (204 No Content)
138+
if response.status_code == 204:
139+
return None
140+
141+
return response.json()
142+
143+
except httpx.TimeoutException as e:
144+
logger.error(f"GitHub API timeout: {e}")
145+
raise GitHubAPIError(
146+
status_code=408,
147+
message="Request timed out",
148+
)
149+
except httpx.RequestError as e:
150+
logger.error(f"GitHub API request error: {e}")
151+
raise GitHubAPIError(
152+
status_code=500,
153+
message=f"Request failed: {str(e)}",
154+
)
155+
156+
def _parse_pr_response(self, data: Dict[str, Any]) -> PRDetails:
157+
"""Parse GitHub PR response into PRDetails object.
158+
159+
Args:
160+
data: Raw GitHub API response
161+
162+
Returns:
163+
Parsed PRDetails object
164+
"""
165+
created_at = datetime.fromisoformat(
166+
data["created_at"].replace("Z", "+00:00")
167+
)
168+
merged_at = None
169+
if data.get("merged_at"):
170+
merged_at = datetime.fromisoformat(
171+
data["merged_at"].replace("Z", "+00:00")
172+
)
173+
174+
return PRDetails(
175+
number=data["number"],
176+
url=data["html_url"],
177+
state=data["state"],
178+
title=data["title"],
179+
body=data.get("body"),
180+
created_at=created_at,
181+
merged_at=merged_at,
182+
head_branch=data["head"]["ref"],
183+
base_branch=data["base"]["ref"],
184+
)
185+
186+
async def create_pull_request(
187+
self,
188+
branch: str,
189+
title: str,
190+
body: str,
191+
base: str = "main",
192+
) -> PRDetails:
193+
"""Create a new pull request.
194+
195+
Args:
196+
branch: Head branch with changes
197+
title: PR title
198+
body: PR description
199+
base: Base branch to merge into (default: main)
200+
201+
Returns:
202+
PRDetails with the created PR info
203+
204+
Raises:
205+
GitHubAPIError: If PR creation fails
206+
"""
207+
endpoint = f"/repos/{self.owner}/{self.repo_name}/pulls"
208+
209+
data = await self._make_request(
210+
method="POST",
211+
endpoint=endpoint,
212+
json_data={
213+
"title": title,
214+
"body": body,
215+
"head": branch,
216+
"base": base,
217+
},
218+
)
219+
220+
logger.info(f"Created PR #{data['number']}: {title}")
221+
return self._parse_pr_response(data)
222+
223+
async def get_pull_request(self, pr_number: int) -> PRDetails:
224+
"""Get pull request details.
225+
226+
Args:
227+
pr_number: PR number
228+
229+
Returns:
230+
PRDetails with the PR info
231+
232+
Raises:
233+
GitHubAPIError: If PR not found or API error
234+
"""
235+
endpoint = f"/repos/{self.owner}/{self.repo_name}/pulls/{pr_number}"
236+
237+
data = await self._make_request(
238+
method="GET",
239+
endpoint=endpoint,
240+
)
241+
242+
return self._parse_pr_response(data)
243+
244+
async def list_pull_requests(
245+
self,
246+
state: str = "open",
247+
) -> List[PRDetails]:
248+
"""List pull requests for the repository.
249+
250+
Args:
251+
state: Filter by state (open, closed, all)
252+
253+
Returns:
254+
List of PRDetails
255+
256+
Raises:
257+
GitHubAPIError: If API error occurs
258+
"""
259+
endpoint = f"/repos/{self.owner}/{self.repo_name}/pulls"
260+
261+
data = await self._make_request(
262+
method="GET",
263+
endpoint=f"{endpoint}?state={state}",
264+
)
265+
266+
return [self._parse_pr_response(pr) for pr in data]
267+
268+
async def merge_pull_request(
269+
self,
270+
pr_number: int,
271+
method: str = "squash",
272+
) -> MergeResult:
273+
"""Merge a pull request.
274+
275+
Args:
276+
pr_number: PR number to merge
277+
method: Merge method (merge, squash, rebase)
278+
279+
Returns:
280+
MergeResult with merge outcome
281+
282+
Raises:
283+
GitHubAPIError: If merge fails
284+
"""
285+
endpoint = f"/repos/{self.owner}/{self.repo_name}/pulls/{pr_number}/merge"
286+
287+
data = await self._make_request(
288+
method="PUT",
289+
endpoint=endpoint,
290+
json_data={
291+
"merge_method": method,
292+
},
293+
)
294+
295+
logger.info(f"Merged PR #{pr_number} with method '{method}'")
296+
return MergeResult(
297+
sha=data.get("sha"),
298+
merged=data.get("merged", False),
299+
message=data.get("message", ""),
300+
)
301+
302+
async def close_pull_request(self, pr_number: int) -> bool:
303+
"""Close a pull request without merging.
304+
305+
Args:
306+
pr_number: PR number to close
307+
308+
Returns:
309+
True if successfully closed
310+
311+
Raises:
312+
GitHubAPIError: If close fails
313+
"""
314+
endpoint = f"/repos/{self.owner}/{self.repo_name}/pulls/{pr_number}"
315+
316+
data = await self._make_request(
317+
method="PATCH",
318+
endpoint=endpoint,
319+
json_data={
320+
"state": "closed",
321+
},
322+
)
323+
324+
logger.info(f"Closed PR #{pr_number}")
325+
return data.get("state") == "closed"
326+
327+
async def close(self) -> None:
328+
"""Close the HTTP client."""
329+
await self._client.aclose()

codeframe/persistence/database.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
CorrectionRepository,
3535
ActivityRepository,
3636
AuditRepository,
37+
PRRepository,
3738
)
3839

3940
if TYPE_CHECKING:
@@ -106,6 +107,7 @@ def __init__(self, db_path: Path | str):
106107
self.correction_attempts: Optional[CorrectionRepository] = None
107108
self.activities: Optional[ActivityRepository] = None
108109
self.audit_logs: Optional[AuditRepository] = None
110+
self.pull_requests: Optional[PRRepository] = None
109111

110112
def initialize(self) -> None:
111113
"""Initialize database schema and repositories."""
@@ -151,6 +153,7 @@ def _initialize_repositories(self) -> None:
151153
self.correction_attempts = CorrectionRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock)
152154
self.activities = ActivityRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock)
153155
self.audit_logs = AuditRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock)
156+
self.pull_requests = PRRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock)
154157

155158
# Backward compatibility properties (maintain old *_repository naming)
156159
@property
@@ -211,7 +214,8 @@ def _update_repository_async_connections(self) -> None:
211214
for repo in [self.projects, self.issues, self.tasks, self.agents, self.blockers,
212215
self.memories, self.context_items, self.checkpoints, self.git_branches,
213216
self.test_results, self.lint_results, self.code_reviews, self.quality_gates,
214-
self.token_usage, self.correction_attempts, self.activities, self.audit_logs]:
217+
self.token_usage, self.correction_attempts, self.activities, self.audit_logs,
218+
self.pull_requests]:
215219
if repo:
216220
repo._async_conn = self._async_conn
217221

0 commit comments

Comments
 (0)