-
Notifications
You must be signed in to change notification settings - Fork 5
feat(pr-history): PR history view with proof snapshots (#572) #585
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -96,6 +96,39 @@ class PRStatusResponse(BaseModel): | |
| pr_number: int | ||
|
|
||
|
|
||
| class GateBreakdownItem(BaseModel): | ||
| """A single gate pass/fail entry in a proof snapshot.""" | ||
|
|
||
| gate: str | ||
| status: str | ||
|
|
||
|
|
||
| class ProofSnapshotOut(BaseModel): | ||
| """Proof snapshot at time of PR creation.""" | ||
|
|
||
| gates_passed: int | ||
| gates_total: int | ||
| gate_breakdown: list[GateBreakdownItem] | ||
|
|
||
|
|
||
| class PRHistoryItem(BaseModel): | ||
| """A single merged PR with optional proof snapshot.""" | ||
|
|
||
| number: int | ||
| title: str | ||
| merged_at: str | ||
| author: Optional[str] | ||
| url: str | ||
| proof_snapshot: Optional[ProofSnapshotOut] | ||
|
|
||
|
|
||
| class PRHistoryResponse(BaseModel): | ||
| """Response for PR history list.""" | ||
|
|
||
| pull_requests: list[PRHistoryItem] | ||
| total: int | ||
|
|
||
|
|
||
| # ============================================================================ | ||
| # Helper Functions | ||
| # ============================================================================ | ||
|
|
@@ -285,6 +318,78 @@ async def list_pull_requests( | |
| await client.close() | ||
|
|
||
|
|
||
| @router.get("/history", response_model=PRHistoryResponse) | ||
| @rate_limit_standard() | ||
| async def get_pr_history( | ||
| request: Request, | ||
| limit: int = Query(10, ge=1, le=50), | ||
| workspace: Workspace = Depends(get_v2_workspace), | ||
| ) -> PRHistoryResponse: | ||
| """List recently merged PRs with proof snapshots. | ||
|
|
||
| Returns merged PRs sorted by merged_at descending, each with an | ||
| optional proof snapshot showing gate pass/fail at PR creation time. | ||
|
|
||
| Args: | ||
| limit: Maximum number of PRs to return (1-50, default 10) | ||
| workspace: v2 Workspace | ||
|
|
||
| Returns: | ||
| PRHistoryResponse with merged PRs and proof snapshots | ||
| """ | ||
| from codeframe.core.proof.ledger import get_pr_proof_snapshot | ||
|
|
||
| client = _get_github_client() | ||
| try: | ||
| prs = await client.list_pull_requests(state="closed") | ||
|
|
||
| # Filter to only merged PRs and sort newest first. | ||
| merged = [pr for pr in prs if pr.merged_at is not None] | ||
| merged.sort(key=lambda pr: pr.merged_at, reverse=True) | ||
| merged = merged[:limit] | ||
|
|
||
| items: list[PRHistoryItem] = [] | ||
| for pr in merged: | ||
| snapshot = get_pr_proof_snapshot(workspace, pr.number) | ||
| proof_snapshot = None | ||
| if snapshot: | ||
| proof_snapshot = ProofSnapshotOut( | ||
| gates_passed=snapshot["gates_passed"], | ||
| gates_total=snapshot["gates_total"], | ||
| gate_breakdown=[ | ||
| GateBreakdownItem(**g) for g in snapshot["gate_breakdown"] | ||
| ], | ||
| ) | ||
| items.append( | ||
| PRHistoryItem( | ||
| number=pr.number, | ||
| title=pr.title, | ||
| merged_at=pr.merged_at.isoformat(), | ||
| author=pr.author, | ||
| url=pr.url, | ||
| proof_snapshot=proof_snapshot, | ||
| ) | ||
| ) | ||
|
|
||
| return PRHistoryResponse(pull_requests=items, total=len(items)) | ||
|
|
||
| except GitHubAPIError as e: | ||
| 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 history: {e}", exc_info=True) | ||
| raise HTTPException( | ||
| status_code=500, | ||
| detail=api_error("Failed to get PR history", ErrorCodes.EXECUTION_FAILED, str(e)), | ||
| ) | ||
| finally: | ||
| await client.close() | ||
|
|
||
|
|
||
| @router.get("/{pr_number}", response_model=PRResponse) | ||
| @rate_limit_standard() | ||
| async def get_pull_request( | ||
|
|
@@ -355,6 +460,41 @@ async def create_pull_request( | |
| base=body.base, | ||
| ) | ||
|
|
||
| # Capture proof snapshot at PR creation time. | ||
| try: | ||
| from codeframe.core.proof.ledger import ( | ||
| init_proof_tables, | ||
| list_requirements, | ||
| save_pr_proof_snapshot, | ||
| ) | ||
|
|
||
| init_proof_tables(workspace) | ||
| reqs = list_requirements(workspace) | ||
|
|
||
| gates_total = 0 | ||
| gates_passed = 0 | ||
| gate_breakdown: list[dict] = [] | ||
| for req in reqs: | ||
| for ob in req.obligations: | ||
| gates_total += 1 | ||
| passed = ob.status == "satisfied" | ||
| if passed: | ||
| gates_passed += 1 | ||
| gate_breakdown.append({ | ||
| "gate": ob.gate.value, | ||
| "status": ob.status, | ||
| }) | ||
|
Comment on lines
+474
to
+486
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This counts requirement obligations, not unique gates.
🤖 Prompt for AI Agents |
||
|
|
||
| save_pr_proof_snapshot( | ||
| workspace, | ||
| pr_number=pr.number, | ||
| gates_passed=gates_passed, | ||
| gates_total=gates_total, | ||
| gate_breakdown=gate_breakdown, | ||
| ) | ||
|
Comment on lines
+463
to
+494
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The snapshot is captured at PR creation, not at merge. This stores proof data immediately after 🤖 Prompt for AI Agents |
||
| except Exception as snap_err: | ||
| logger.warning(f"Failed to save proof snapshot for PR #{pr.number}: {snap_err}") | ||
|
|
||
| return _pr_to_response(pr) | ||
|
|
||
| except GitHubAPIError as e: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| """Tests for pr_proof_snapshots ledger functions. | ||
|
|
||
| Verifies save_pr_proof_snapshot and get_pr_proof_snapshot work correctly | ||
| with the SQLite-backed proof ledger. | ||
| """ | ||
|
|
||
| import shutil | ||
| import tempfile | ||
| from pathlib import Path | ||
|
|
||
| import pytest | ||
|
|
||
| from codeframe.core.workspace import create_or_load_workspace | ||
|
|
||
| pytestmark = pytest.mark.v2 | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def test_workspace(): | ||
| temp_dir = Path(tempfile.mkdtemp()) | ||
| workspace_path = temp_dir / "test_ws" | ||
| workspace_path.mkdir(parents=True, exist_ok=True) | ||
|
|
||
| workspace = create_or_load_workspace(workspace_path) | ||
|
|
||
| yield workspace | ||
|
|
||
| shutil.rmtree(temp_dir, ignore_errors=True) | ||
|
|
||
|
|
||
| class TestPrProofSnapshot: | ||
| """Tests for save/get pr_proof_snapshot functions.""" | ||
|
|
||
| def test_save_and_get_snapshot(self, test_workspace): | ||
| """Save a snapshot, retrieve it, verify all fields.""" | ||
| from codeframe.core.proof.ledger import ( | ||
| init_proof_tables, | ||
| save_pr_proof_snapshot, | ||
| get_pr_proof_snapshot, | ||
| ) | ||
|
|
||
| init_proof_tables(test_workspace) | ||
|
|
||
| gate_breakdown = [ | ||
| {"gate": "unit_test", "status": "satisfied"}, | ||
| {"gate": "lint", "status": "failed"}, | ||
| ] | ||
| save_pr_proof_snapshot( | ||
| test_workspace, | ||
| pr_number=42, | ||
| gates_passed=1, | ||
| gates_total=2, | ||
| gate_breakdown=gate_breakdown, | ||
| ) | ||
|
|
||
| result = get_pr_proof_snapshot(test_workspace, 42) | ||
|
|
||
| assert result is not None | ||
| assert result["pr_number"] == 42 | ||
| assert result["gates_passed"] == 1 | ||
| assert result["gates_total"] == 2 | ||
| assert result["gate_breakdown"] == gate_breakdown | ||
| assert "snapshotted_at" in result | ||
|
|
||
| def test_get_nonexistent_snapshot_returns_none(self, test_workspace): | ||
| """Getting a snapshot for a non-existent PR returns None.""" | ||
| from codeframe.core.proof.ledger import ( | ||
| init_proof_tables, | ||
| get_pr_proof_snapshot, | ||
| ) | ||
|
|
||
| init_proof_tables(test_workspace) | ||
|
|
||
| result = get_pr_proof_snapshot(test_workspace, 9999) | ||
| assert result is None | ||
|
|
||
| def test_snapshot_overwrites_on_same_pr_number(self, test_workspace): | ||
| """Saving a snapshot for the same PR overwrites the previous one.""" | ||
| from codeframe.core.proof.ledger import ( | ||
| init_proof_tables, | ||
| save_pr_proof_snapshot, | ||
| get_pr_proof_snapshot, | ||
| ) | ||
|
|
||
| init_proof_tables(test_workspace) | ||
|
|
||
| save_pr_proof_snapshot( | ||
| test_workspace, | ||
| pr_number=10, | ||
| gates_passed=3, | ||
| gates_total=5, | ||
| gate_breakdown=[{"gate": "unit_test", "status": "satisfied"}], | ||
| ) | ||
|
|
||
| save_pr_proof_snapshot( | ||
| test_workspace, | ||
| pr_number=10, | ||
| gates_passed=5, | ||
| gates_total=5, | ||
| gate_breakdown=[ | ||
| {"gate": "unit_test", "status": "satisfied"}, | ||
| {"gate": "lint", "status": "satisfied"}, | ||
| ], | ||
| ) | ||
|
|
||
| result = get_pr_proof_snapshot(test_workspace, 10) | ||
| assert result is not None | ||
| assert result["gates_passed"] == 5 | ||
| assert result["gates_total"] == 5 | ||
| assert len(result["gate_breakdown"]) == 2 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/historycan miss the actual latest merged PRs.This only examines GitHub's first page of
state="closed"PRs and then sorts that subset bymerged_at. A long-lived PR merged recently can fall off page 1, so the history view can omit the real latest merged PRs. Paginate until you collectlimitmerged PRs, or query GitHub by merge time directly.🤖 Prompt for AI Agents