Skip to content

Commit 71cbb25

Browse files
frankbriaCodeRabbit
andauthored
feat(web-ui): PR status panel with live CI checks, review, and merge state (#579)
feat(web-ui): PR status panel with live CI checks, review status, and merge state (#570) Closes #570 Co-authored-by: CodeRabbit <coderabbitai@users.noreply.github.com>
1 parent 6166e0f commit 71cbb25

8 files changed

Lines changed: 803 additions & 12 deletions

File tree

codeframe/git/github_integration.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ def __init__(
3232
super().__init__(f"GitHub API Error ({status_code}): {message}")
3333

3434

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+
3544
@dataclass
3645
class PRDetails:
3746
"""Pull Request details from GitHub API."""
@@ -364,6 +373,74 @@ async def close_pull_request(self, pr_number: int) -> bool:
364373
logger.info(f"Closed PR #{pr_number}")
365374
return data.get("state") == "closed"
366375

376+
async def get_pr_ci_checks(
377+
self,
378+
pr_number: int,
379+
head_sha: Optional[str] = None,
380+
) -> List[CICheck]:
381+
"""Get CI check runs for a pull request.
382+
383+
Args:
384+
pr_number: PR number
385+
head_sha: Head commit SHA (fetched from the PR if not provided)
386+
387+
Returns:
388+
List of CICheck results
389+
"""
390+
if head_sha is None:
391+
pr_data = await self._make_request(
392+
"GET",
393+
f"/repos/{self.owner}/{self.repo_name}/pulls/{pr_number}",
394+
)
395+
head_sha = pr_data["head"]["sha"]
396+
397+
data = await self._make_request(
398+
"GET",
399+
f"/repos/{self.owner}/{self.repo_name}/commits/{head_sha}/check-runs",
400+
)
401+
check_runs = data.get("check_runs", []) if isinstance(data, dict) else []
402+
normalized: list[CICheck] = []
403+
for run in check_runs:
404+
if not isinstance(run, dict):
405+
continue
406+
name = run.get("name")
407+
status = run.get("status")
408+
if not name or not status:
409+
logger.warning("Skipping malformed check-run entry: %s", run)
410+
continue
411+
normalized.append(
412+
CICheck(name=name, status=status, conclusion=run.get("conclusion"))
413+
)
414+
return normalized
415+
416+
async def get_pr_review_status(self, pr_number: int) -> str:
417+
"""Get the aggregate review status for a pull request.
418+
419+
Returns "changes_requested" if any reviewer requested changes,
420+
"approved" if any reviewer approved (and none requested changes),
421+
or "pending" if there are no actionable reviews.
422+
423+
Args:
424+
pr_number: PR number
425+
426+
Returns:
427+
"approved" | "changes_requested" | "pending"
428+
"""
429+
reviews = await self._make_request(
430+
"GET",
431+
f"/repos/{self.owner}/{self.repo_name}/pulls/{pr_number}/reviews",
432+
)
433+
reviews = reviews or []
434+
435+
has_changes_requested = any(r.get("state") == "CHANGES_REQUESTED" for r in reviews)
436+
has_approved = any(r.get("state") == "APPROVED" for r in reviews)
437+
438+
if has_changes_requested:
439+
return "changes_requested"
440+
if has_approved:
441+
return "approved"
442+
return "pending"
443+
367444
async def close(self) -> None:
368445
"""Close the HTTP client."""
369446
await self._client.aclose()

codeframe/ui/routers/pr_v2.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
POST /api/v2/pr/{number}/close - Close a PR without merging
1212
"""
1313

14+
import asyncio
1415
import logging
1516
from typing import Optional
1617

@@ -77,6 +78,24 @@ class MergeResponse(BaseModel):
7778
message: str
7879

7980

81+
class CICheckResponse(BaseModel):
82+
"""A single CI check run result."""
83+
84+
name: str
85+
status: str
86+
conclusion: Optional[str]
87+
88+
89+
class PRStatusResponse(BaseModel):
90+
"""Live PR status: CI checks, review status, and merge state."""
91+
92+
ci_checks: list[CICheckResponse]
93+
review_status: str # "approved" | "changes_requested" | "pending"
94+
merge_state: str # "open" | "merged" | "closed"
95+
pr_url: str
96+
pr_number: int
97+
98+
8099
# ============================================================================
81100
# Helper Functions
82101
# ============================================================================
@@ -124,6 +143,75 @@ def _get_github_client() -> GitHubIntegration:
124143
# ============================================================================
125144

126145

146+
@router.get("/status", response_model=PRStatusResponse)
147+
@rate_limit_standard()
148+
async def get_pr_status(
149+
request: Request,
150+
pr_number: int = Query(..., description="PR number to poll"),
151+
workspace: Workspace = Depends(get_v2_workspace),
152+
) -> PRStatusResponse:
153+
"""Get live PR status: CI checks, review status, and merge state.
154+
155+
Polls the GitHub API for the given PR number and returns a snapshot
156+
of all three status dimensions. The frontend polls this every 30 s
157+
and stops when merge_state is merged or closed.
158+
159+
Args:
160+
pr_number: PR number to inspect
161+
workspace: v2 Workspace (for context)
162+
163+
Returns:
164+
PRStatusResponse with CI checks, review status, and merge state
165+
"""
166+
try:
167+
client = _get_github_client()
168+
169+
# Single call to get PR state, URL, and head SHA.
170+
pr_raw = await client._make_request(
171+
"GET",
172+
f"/repos/{client.owner}/{client.repo_name}/pulls/{pr_number}",
173+
)
174+
head_sha: str = pr_raw["head"]["sha"]
175+
pr_url: str = pr_raw["html_url"]
176+
merge_state: str = "merged" if pr_raw.get("merged_at") else pr_raw["state"]
177+
178+
# Fetch CI checks and reviews in parallel (2 more GitHub API calls).
179+
ci_checks, review_status = await asyncio.gather(
180+
client.get_pr_ci_checks(pr_number, head_sha=head_sha),
181+
client.get_pr_review_status(pr_number),
182+
)
183+
184+
return PRStatusResponse(
185+
ci_checks=[
186+
CICheckResponse(name=c.name, status=c.status, conclusion=c.conclusion)
187+
for c in ci_checks
188+
],
189+
review_status=review_status,
190+
merge_state=merge_state,
191+
pr_url=pr_url,
192+
pr_number=pr_number,
193+
)
194+
195+
except GitHubAPIError as e:
196+
if e.status_code == 404:
197+
raise HTTPException(
198+
status_code=404,
199+
detail=api_error("PR not found", ErrorCodes.NOT_FOUND, f"No PR #{pr_number}"),
200+
)
201+
raise HTTPException(
202+
status_code=e.status_code,
203+
detail=api_error("GitHub API error", ErrorCodes.EXECUTION_FAILED, e.message),
204+
)
205+
except HTTPException:
206+
raise
207+
except Exception as e:
208+
logger.error(f"Failed to get PR #{pr_number} status: {e}", exc_info=True)
209+
raise HTTPException(
210+
status_code=500,
211+
detail=api_error("Failed to get PR status", ErrorCodes.EXECUTION_FAILED, str(e)),
212+
)
213+
214+
127215
@router.get("", response_model=PRListResponse)
128216
@rate_limit_standard()
129217
async def list_pull_requests(

0 commit comments

Comments
 (0)