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, and merge state#579
Conversation
…state (#570) Adds a live PR Status panel to the Review page that polls GitHub every 30 s after a PR is created, surfacing CI check results, review status, and merge state without leaving the app. Backend: - github_integration.py: add CICheck dataclass, get_pr_ci_checks(), and get_pr_review_status() methods (asyncio-parallel, 3 total GitHub API calls) - pr_v2.py: add CICheckResponse + PRStatusResponse Pydantic models and GET /api/v2/pr/status endpoint (placed before /{pr_number} to avoid routing conflict); reuses _make_request for a single /pulls/{N} fetch then gathers CI checks and reviews in parallel Frontend: - types/index.ts: CICheck + PRStatusResponse interfaces - api.ts: prApi.getStatus(workspacePath, prNumber) - PRStatusPanel.tsx: SWR-polling Card component; refreshInterval=0 when merge_state is merged/closed; Badge variants map CI conclusions, review status, and merge state; loading skeleton while data pending - review/page.tsx: render PRStatusPanel below CommitPanel when prNumber > 0 Tests: - tests/ui/test_pr_status.py: 10 pytest tests (mocked GitHub, no live calls) - PRStatusPanel.test.tsx: 19 Jest tests using jest.mock('swr') pattern
WalkthroughAdded a live PR Status feature: backend GET /api/v2/pr/status aggregates PR metadata, CI check runs, and review status from GitHub; frontend polls this endpoint every 30s via a new PRStatusPanel until the PR is merged or closed. Changes
Sequence DiagramsequenceDiagram
participant Browser as Browser / ReviewPage
participant Panel as PRStatusPanel (SWR)
participant Backend as GET /api/v2/pr/status
participant GitHub as GitHub API
Browser->>Panel: Mount with prNumber, workspacePath
Panel->>Backend: GET /api/v2/pr/status?workspace_path=...&pr_number=...
activate Backend
Backend->>GitHub: GET /repos/.../pulls/{pr_number}
GitHub-->>Backend: PR payload (head.sha, merged_at, state)
par Concurrent fetches
Backend->>GitHub: GET /repos/.../commits/{head_sha}/check-runs
GitHub-->>Backend: check_runs list
and
Backend->>GitHub: GET /repos/.../pulls/{pr_number}/reviews
GitHub-->>Backend: reviews list
end
Backend-->>Panel: PRStatusResponse (ci_checks, review_status, merge_state, pr_url, pr_number)
deactivate Backend
Panel->>Panel: Render badges + CI checks list
loop Every 30s while merge_state == "open"
Panel->>Backend: Poll PR status
Backend->>GitHub: Fetch latest PR/checks/reviews
Backend-->>Panel: Updated PRStatusResponse
end
alt merge_state == "merged" or "closed"
Panel->>Panel: Stop polling (refreshInterval = 0)
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
PR Review — feat(web-ui): PR status panel with live CI checks, review, and merge stateOverall this is a solid, well-scoped implementation. The parallel API calls, stop-when-done polling, and test coverage are all good. A few issues worth addressing before merge: Bug — Dismissed reviews treated as active (
|
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@codeframe/git/github_integration.py`:
- Around line 424-431: The reviews payload from self._make_request may not be a
list of dicts, so before computing has_changes_requested and has_approved (and
before any use of reviews) validate/coerce the response: ensure reviews is a
list (default to []) and filter its items to dicts only (e.g., keep only items
where isinstance(item, dict)); then compute has_changes_requested =
any(r.get("state") == "CHANGES_REQUESTED" for r in filtered_reviews) and
has_approved = any(r.get("state") == "APPROVED" for r in filtered_reviews) so
.get is only called on dict objects and unexpected payload shapes are guarded.
- Around line 397-409: The code that builds CICheck instances from check_runs
can raise KeyError when GitHub returns partial entries; update the list
construction in the function that calls self._make_request so each run is
validated (ensure isinstance(run, dict) and that "name" and "status" exist)
before indexing, skip or provide safe defaults for malformed entries, use
run.get("conclusion") as already done, and optionally log or increment a metric
for skipped runs; this hardens the CICheck creation (referencing
variables/functions: self._make_request, data, check_runs, run, CICheck).
In `@codeframe/ui/routers/pr_v2.py`:
- Around line 174-176: The code directly indexes pr_raw to derive head_sha,
pr_url, and merge_state which will raise KeyError on malformed responses; before
using pr_raw["head"]["sha"], pr_raw["html_url"], and
pr_raw.get("merged_at")/["state"], explicitly validate the required fields exist
(e.g., ensure "head" is present and is a dict with "sha", and "html_url" and
"state" keys exist), or use safe lookups like pr_raw.get("head", {}).get("sha")
and pr_raw.get("html_url") and if any required value is missing raise a clear,
controlled error/HTTPException (with a helpful message and status code) or
return a validation error response rather than letting a KeyError bubble up;
make this check immediately before computing head_sha, pr_url, and merge_state
so callers receive deterministic error handling.
- Around line 167-213: The GitHubIntegration client returned by
_get_github_client() is never closed; wrap its usage in an async context manager
or ensure it's closed in a finally block to avoid leaking httpx connections.
Replace the direct assignment client = _get_github_client() with either "async
with _get_github_client() as client:" around the logic that calls
client.get_pr_ci_checks and client.get_pr_review_status, or add a try/finally
where you call await client.aclose() in the finally; ensure this runs for all
code paths including the GitHubAPIError and generic Exception handlers so the
GitHubIntegration/httpx AsyncClient is always closed.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 75ab9776-1995-4b91-b868-036f5275823e
📒 Files selected for processing (8)
codeframe/git/github_integration.pycodeframe/ui/routers/pr_v2.pytests/ui/test_pr_status.pyweb-ui/src/__tests__/components/review/PRStatusPanel.test.tsxweb-ui/src/app/review/page.tsxweb-ui/src/components/review/PRStatusPanel.tsxweb-ui/src/lib/api.tsweb-ui/src/types/index.ts
| reviews = await self._make_request( | ||
| "GET", | ||
| f"/repos/{self.owner}/{self.repo_name}/pulls/{pr_number}/reviews", | ||
| ) | ||
| reviews = reviews or [] | ||
|
|
||
| has_changes_requested = any(r.get("state") == "CHANGES_REQUESTED" for r in reviews) | ||
| has_approved = any(r.get("state") == "APPROVED" for r in reviews) |
There was a problem hiding this comment.
Guard review payload type before state aggregation.
At Line 430-Line 431, r.get(...) assumes each review item is a dict. If reviews is non-list or mixed, this can raise and mask the real upstream issue.
Suggested hardening
- reviews = await self._make_request(
+ reviews_raw = await self._make_request(
"GET",
f"/repos/{self.owner}/{self.repo_name}/pulls/{pr_number}/reviews",
)
- reviews = reviews or []
+ reviews = reviews_raw if isinstance(reviews_raw, list) else []
- has_changes_requested = any(r.get("state") == "CHANGES_REQUESTED" for r in reviews)
- has_approved = any(r.get("state") == "APPROVED" for r in reviews)
+ has_changes_requested = any(
+ isinstance(r, dict) and r.get("state") == "CHANGES_REQUESTED"
+ for r in reviews
+ )
+ has_approved = any(
+ isinstance(r, dict) and r.get("state") == "APPROVED"
+ for r in reviews
+ )🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@codeframe/git/github_integration.py` around lines 424 - 431, The reviews
payload from self._make_request may not be a list of dicts, so before computing
has_changes_requested and has_approved (and before any use of reviews)
validate/coerce the response: ensure reviews is a list (default to []) and
filter its items to dicts only (e.g., keep only items where isinstance(item,
dict)); then compute has_changes_requested = any(r.get("state") ==
"CHANGES_REQUESTED" for r in filtered_reviews) and has_approved =
any(r.get("state") == "APPROVED" for r in filtered_reviews) so .get is only
called on dict objects and unexpected payload shapes are guarded.
| client = _get_github_client() | ||
|
|
||
| # Single call to get PR state, URL, and head SHA. | ||
| pr_raw = await client._make_request( | ||
| "GET", | ||
| f"/repos/{client.owner}/{client.repo_name}/pulls/{pr_number}", | ||
| ) | ||
| head_sha: str = pr_raw["head"]["sha"] | ||
| pr_url: str = pr_raw["html_url"] | ||
| merge_state: str = "merged" if pr_raw.get("merged_at") else pr_raw["state"] | ||
|
|
||
| # Fetch CI checks and reviews in parallel (2 more GitHub API calls). | ||
| ci_checks, review_status = await asyncio.gather( | ||
| client.get_pr_ci_checks(pr_number, head_sha=head_sha), | ||
| client.get_pr_review_status(pr_number), | ||
| ) | ||
|
|
||
| return PRStatusResponse( | ||
| ci_checks=[ | ||
| CICheckResponse(name=c.name, status=c.status, conclusion=c.conclusion) | ||
| for c in ci_checks | ||
| ], | ||
| review_status=review_status, | ||
| merge_state=merge_state, | ||
| pr_url=pr_url, | ||
| pr_number=pr_number, | ||
| ) | ||
|
|
||
| except GitHubAPIError as e: | ||
| if e.status_code == 404: | ||
| raise HTTPException( | ||
| status_code=404, | ||
| detail=api_error("PR not found", ErrorCodes.NOT_FOUND, f"No PR #{pr_number}"), | ||
| ) | ||
| raise HTTPException( | ||
| status_code=e.status_code, | ||
| detail=api_error("GitHub API error", ErrorCodes.EXECUTION_FAILED, e.message), | ||
| ) | ||
| except HTTPException: | ||
| raise | ||
| except Exception as e: | ||
| logger.error(f"Failed to get PR #{pr_number} status: {e}", exc_info=True) | ||
| raise HTTPException( | ||
| status_code=500, | ||
| detail=api_error("Failed to get PR status", ErrorCodes.EXECUTION_FAILED, str(e)), | ||
| ) | ||
|
|
There was a problem hiding this comment.
Close GitHubIntegration client in this endpoint to prevent connection leaks.
At Line 167, a new client is created but never closed. Since GitHubIntegration wraps httpx.AsyncClient, repeated polling can accumulate open connections.
Suggested fix
async def get_pr_status(
@@
) -> PRStatusResponse:
@@
- try:
- client = _get_github_client()
+ client: Optional[GitHubIntegration] = None
+ try:
+ client = _get_github_client()
@@
except Exception as e:
logger.error(f"Failed to get PR #{pr_number} status: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=api_error("Failed to get PR status", ErrorCodes.EXECUTION_FAILED, str(e)),
)
+ finally:
+ if client is not None:
+ await client.close()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@codeframe/ui/routers/pr_v2.py` around lines 167 - 213, The GitHubIntegration
client returned by _get_github_client() is never closed; wrap its usage in an
async context manager or ensure it's closed in a finally block to avoid leaking
httpx connections. Replace the direct assignment client = _get_github_client()
with either "async with _get_github_client() as client:" around the logic that
calls client.get_pr_ci_checks and client.get_pr_review_status, or add a
try/finally where you call await client.aclose() in the finally; ensure this
runs for all code paths including the GitHubAPIError and generic Exception
handlers so the GitHubIntegration/httpx AsyncClient is always closed.
| head_sha: str = pr_raw["head"]["sha"] | ||
| pr_url: str = pr_raw["html_url"] | ||
| merge_state: str = "merged" if pr_raw.get("merged_at") else pr_raw["state"] |
There was a problem hiding this comment.
Validate required PR fields before deriving status fields.
Line 174-Line 176 directly indexes pr_raw; malformed-but-200 responses will raise KeyError and return generic 500s. Prefer explicit validation and a controlled error.
Suggested fix
- head_sha: str = pr_raw["head"]["sha"]
- pr_url: str = pr_raw["html_url"]
- merge_state: str = "merged" if pr_raw.get("merged_at") else pr_raw["state"]
+ head_sha = (pr_raw.get("head") or {}).get("sha") if isinstance(pr_raw, dict) else None
+ pr_url = pr_raw.get("html_url") if isinstance(pr_raw, dict) else None
+ pr_state = pr_raw.get("state") if isinstance(pr_raw, dict) else None
+ if not head_sha or not pr_url or pr_state not in {"open", "closed"}:
+ raise HTTPException(
+ status_code=502,
+ detail=api_error(
+ "Invalid GitHub PR payload",
+ ErrorCodes.EXECUTION_FAILED,
+ f"PR #{pr_number} missing required fields",
+ ),
+ )
+ merge_state: str = "merged" if pr_raw.get("merged_at") else pr_state🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@codeframe/ui/routers/pr_v2.py` around lines 174 - 176, The code directly
indexes pr_raw to derive head_sha, pr_url, and merge_state which will raise
KeyError on malformed responses; before using pr_raw["head"]["sha"],
pr_raw["html_url"], and pr_raw.get("merged_at")/["state"], explicitly validate
the required fields exist (e.g., ensure "head" is present and is a dict with
"sha", and "html_url" and "state" keys exist), or use safe lookups like
pr_raw.get("head", {}).get("sha") and pr_raw.get("html_url") and if any required
value is missing raise a clear, controlled error/HTTPException (with a helpful
message and status code) or return a validation error response rather than
letting a KeyError bubble up; make this check immediately before computing
head_sha, pr_url, and merge_state so callers receive deterministic error
handling.
Skip entries that are not dicts or are missing required name/status fields, logging a warning for malformed objects rather than raising KeyError. Addresses CodeRabbit feedback on PR #579.
There was a problem hiding this comment.
♻️ Duplicate comments (1)
codeframe/git/github_integration.py (1)
429-436:⚠️ Potential issue | 🟡 MinorGuard review entries before calling
.get()in aggregation.Line 435 and Line 436 assume every
reviewsitem is a dict. Mixed payloads can raiseAttributeErrorand fail/api/v2/pr/status. Coerce to a list and filter dicts first.Suggested fix
- reviews = await self._make_request( + reviews_raw = await self._make_request( "GET", f"/repos/{self.owner}/{self.repo_name}/pulls/{pr_number}/reviews", ) - reviews = reviews or [] + reviews = reviews_raw if isinstance(reviews_raw, list) else [] + reviews = [r for r in reviews if isinstance(r, dict)] has_changes_requested = any(r.get("state") == "CHANGES_REQUESTED" for r in reviews) has_approved = any(r.get("state") == "APPROVED" for r in reviews)#!/bin/bash # Verify the current implementation still calls .get on untyped review entries. # Expected result before fix: generator expressions on Line 435/436 use r.get(...) over raw reviews. rg -n -C3 'has_changes_requested|has_approved|reviews = await self\._make_request|r\.get\("state"\)' codeframe/git/github_integration.py🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@codeframe/git/github_integration.py` around lines 429 - 436, The aggregation assumes every element in reviews is a dict, which can raise AttributeError; before computing has_changes_requested and has_approved, coerce reviews to a list and filter to dicts only (e.g., replace direct generator over reviews with a filtered list comprehension), then compute has_changes_requested and has_approved from that filtered collection; update the block around the reviews variable (result of self._make_request for the PR reviews) so you only call .get("state") on items confirmed to be dicts.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@codeframe/git/github_integration.py`:
- Around line 429-436: The aggregation assumes every element in reviews is a
dict, which can raise AttributeError; before computing has_changes_requested and
has_approved, coerce reviews to a list and filter to dicts only (e.g., replace
direct generator over reviews with a filtered list comprehension), then compute
has_changes_requested and has_approved from that filtered collection; update the
block around the reviews variable (result of self._make_request for the PR
reviews) so you only call .get("state") on items confirmed to be dicts.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c2326f9b-cc88-417d-bc15-4a40362e3198
📒 Files selected for processing (1)
codeframe/git/github_integration.py
Review: PR Status Panel (Phase 4A)Good overall structure — the three-layer separation (GitHub integration → FastAPI endpoint → SWR-polling component) is clean, test coverage is solid, and the 1. httpx client leak in
|
Closes #570
Summary
github_integration.py):CICheckdataclass +get_pr_ci_checks()+get_pr_review_status()— 3 total GitHub API calls (1 for PR raw data, 2 gathered in parallel for CI and reviews)pr_v2.py):GET /api/v2/pr/status?workspace_path=...&pr_number=Nendpoint withCICheckResponse+PRStatusResponsemodels; placed before/{pr_number}to avoid routing conflictsPRStatusPanel.tsx): SWR-polling Card rendered below CommitPanel whenprNumber > 0; stops polling whenmerge_stateismergedorclosed; maps CI conclusions, review status, and merge state to Badge variantsCICheck,PRStatusResponse,prApi.getStatus()Test plan
uv run pytest tests/ui/test_pr_status.py -v— 10/10 pass (mocked GitHub, no live API calls)npm test -- --testPathPattern=PRStatusPanel— 19/19 passuv run pytest && uv run ruff check .— cleannpm test— 747/747 pass,npm run build— succeedsAcceptance criteria
prNumber > 0)refreshIntervalreturns 0)npm testanduv run pytestpass for the new backend endpointSummary by CodeRabbit
New Features
Tests