Skip to content

Commit 2df7918

Browse files
authored
feat(pr-history): PR history view with proof snapshots (#572)
## Summary - Backend: `GET /api/v2/pr/history` endpoint returning merged PRs with PROOF9 gate snapshots - Backend: `pr_proof_snapshots` SQLite table capturing gate pass/fail at PR creation time - Backend: `author` field on `PRDetails` dataclass from GitHub API - Frontend: `PRHistoryPanel` component on Review page with expandable gate breakdown - Tests: 11 Python tests + 13 Jest tests ## Validation - Review feedback: All addressed (2 rounds — claude-review + CodeRabbit) - Demo: All acceptance criteria verified via code review and test evidence - Tests: All passing (Python 3158, Web-UI 773) - CI: All checks green (both rounds) - Linting: Clean Closes #572
1 parent f67caa3 commit 2df7918

11 files changed

Lines changed: 1041 additions & 0 deletions

File tree

codeframe/core/proof/ledger.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,18 @@ def init_proof_tables(workspace: Workspace) -> None:
8484
)
8585
""")
8686

87+
cursor.execute("""
88+
CREATE TABLE IF NOT EXISTS pr_proof_snapshots (
89+
pr_number INTEGER NOT NULL,
90+
workspace_id TEXT NOT NULL,
91+
gates_passed INTEGER NOT NULL,
92+
gates_total INTEGER NOT NULL,
93+
gate_breakdown TEXT NOT NULL,
94+
snapshotted_at TEXT NOT NULL,
95+
PRIMARY KEY (pr_number, workspace_id)
96+
)
97+
""")
98+
8799
conn.commit()
88100
conn.close()
89101

@@ -101,6 +113,11 @@ def _ensure_tables(workspace: Workspace) -> None:
101113
"SELECT name FROM sqlite_master WHERE type='table' AND name='proof_runs'"
102114
)
103115
missing = not cursor.fetchone()
116+
if not missing:
117+
cursor.execute(
118+
"SELECT name FROM sqlite_master WHERE type='table' AND name='pr_proof_snapshots'"
119+
)
120+
missing = not cursor.fetchone()
104121
conn.close()
105122
if missing:
106123
init_proof_tables(workspace)
@@ -493,3 +510,65 @@ def check_expired_waivers(workspace: Workspace) -> list[Requirement]:
493510
conn.commit()
494511
conn.close()
495512
return expired
513+
514+
515+
# --- PR Proof Snapshots ---
516+
517+
518+
def save_pr_proof_snapshot(
519+
workspace: Workspace,
520+
pr_number: int,
521+
gates_passed: int,
522+
gates_total: int,
523+
gate_breakdown: list[dict],
524+
) -> None:
525+
"""Save a proof snapshot for a PR at creation time."""
526+
_ensure_tables(workspace)
527+
conn = get_db_connection(workspace)
528+
cursor = conn.cursor()
529+
cursor.execute(
530+
"""INSERT OR REPLACE INTO pr_proof_snapshots
531+
(pr_number, workspace_id, gates_passed, gates_total,
532+
gate_breakdown, snapshotted_at)
533+
VALUES (?, ?, ?, ?, ?, ?)""",
534+
(
535+
pr_number,
536+
workspace.id,
537+
gates_passed,
538+
gates_total,
539+
json.dumps(gate_breakdown),
540+
_utc_now().isoformat(),
541+
),
542+
)
543+
conn.commit()
544+
conn.close()
545+
546+
547+
def get_pr_proof_snapshot(
548+
workspace: Workspace, pr_number: int
549+
) -> Optional[dict]:
550+
"""Fetch a proof snapshot for a PR.
551+
552+
Returns:
553+
Dict with pr_number, gates_passed, gates_total, gate_breakdown,
554+
snapshotted_at — or None if not found.
555+
"""
556+
_ensure_tables(workspace)
557+
conn = get_db_connection(workspace)
558+
cursor = conn.cursor()
559+
cursor.execute(
560+
"""SELECT pr_number, gates_passed, gates_total, gate_breakdown, snapshotted_at
561+
FROM pr_proof_snapshots WHERE pr_number = ? AND workspace_id = ?""",
562+
(pr_number, workspace.id),
563+
)
564+
row = cursor.fetchone()
565+
conn.close()
566+
if not row:
567+
return None
568+
return {
569+
"pr_number": row[0],
570+
"gates_passed": row[1],
571+
"gates_total": row[2],
572+
"gate_breakdown": json.loads(row[3]),
573+
"snapshotted_at": row[4],
574+
}

codeframe/git/github_integration.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class PRDetails:
5454
merged_at: Optional[datetime]
5555
head_branch: str
5656
base_branch: str
57+
author: Optional[str] = None
5758

5859

5960
@dataclass
@@ -220,6 +221,9 @@ def _parse_pr_response(self, data: Dict[str, Any]) -> PRDetails:
220221
data["merged_at"].replace("Z", "+00:00")
221222
)
222223

224+
user = data.get("user")
225+
author = user.get("login") if isinstance(user, dict) else None
226+
223227
return PRDetails(
224228
number=data["number"],
225229
url=data["html_url"],
@@ -230,6 +234,7 @@ def _parse_pr_response(self, data: Dict[str, Any]) -> PRDetails:
230234
merged_at=merged_at,
231235
head_branch=data["head"]["ref"],
232236
base_branch=data["base"]["ref"],
237+
author=author,
233238
)
234239

235240
async def create_pull_request(

codeframe/ui/routers/pr_v2.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,39 @@ class PRStatusResponse(BaseModel):
9696
pr_number: int
9797

9898

99+
class GateBreakdownItem(BaseModel):
100+
"""A single gate pass/fail entry in a proof snapshot."""
101+
102+
gate: str
103+
status: str
104+
105+
106+
class ProofSnapshotOut(BaseModel):
107+
"""Proof snapshot at time of PR creation."""
108+
109+
gates_passed: int
110+
gates_total: int
111+
gate_breakdown: list[GateBreakdownItem]
112+
113+
114+
class PRHistoryItem(BaseModel):
115+
"""A single merged PR with optional proof snapshot."""
116+
117+
number: int
118+
title: str
119+
merged_at: str
120+
author: Optional[str]
121+
url: str
122+
proof_snapshot: Optional[ProofSnapshotOut]
123+
124+
125+
class PRHistoryResponse(BaseModel):
126+
"""Response for PR history list."""
127+
128+
pull_requests: list[PRHistoryItem]
129+
total: int
130+
131+
99132
# ============================================================================
100133
# Helper Functions
101134
# ============================================================================
@@ -285,6 +318,78 @@ async def list_pull_requests(
285318
await client.close()
286319

287320

321+
@router.get("/history", response_model=PRHistoryResponse)
322+
@rate_limit_standard()
323+
async def get_pr_history(
324+
request: Request,
325+
limit: int = Query(10, ge=1, le=50),
326+
workspace: Workspace = Depends(get_v2_workspace),
327+
) -> PRHistoryResponse:
328+
"""List recently merged PRs with proof snapshots.
329+
330+
Returns merged PRs sorted by merged_at descending, each with an
331+
optional proof snapshot showing gate pass/fail at PR creation time.
332+
333+
Args:
334+
limit: Maximum number of PRs to return (1-50, default 10)
335+
workspace: v2 Workspace
336+
337+
Returns:
338+
PRHistoryResponse with merged PRs and proof snapshots
339+
"""
340+
from codeframe.core.proof.ledger import get_pr_proof_snapshot
341+
342+
client = _get_github_client()
343+
try:
344+
prs = await client.list_pull_requests(state="closed")
345+
346+
# Filter to only merged PRs and sort newest first.
347+
merged = [pr for pr in prs if pr.merged_at is not None]
348+
merged.sort(key=lambda pr: pr.merged_at, reverse=True)
349+
merged = merged[:limit]
350+
351+
items: list[PRHistoryItem] = []
352+
for pr in merged:
353+
snapshot = get_pr_proof_snapshot(workspace, pr.number)
354+
proof_snapshot = None
355+
if snapshot:
356+
proof_snapshot = ProofSnapshotOut(
357+
gates_passed=snapshot["gates_passed"],
358+
gates_total=snapshot["gates_total"],
359+
gate_breakdown=[
360+
GateBreakdownItem(**g) for g in snapshot["gate_breakdown"]
361+
],
362+
)
363+
items.append(
364+
PRHistoryItem(
365+
number=pr.number,
366+
title=pr.title,
367+
merged_at=pr.merged_at.isoformat(),
368+
author=pr.author,
369+
url=pr.url,
370+
proof_snapshot=proof_snapshot,
371+
)
372+
)
373+
374+
return PRHistoryResponse(pull_requests=items, total=len(items))
375+
376+
except GitHubAPIError as e:
377+
raise HTTPException(
378+
status_code=e.status_code,
379+
detail=api_error("GitHub API error", ErrorCodes.EXECUTION_FAILED, e.message),
380+
)
381+
except HTTPException:
382+
raise
383+
except Exception as e:
384+
logger.error(f"Failed to get PR history: {e}", exc_info=True)
385+
raise HTTPException(
386+
status_code=500,
387+
detail=api_error("Failed to get PR history", ErrorCodes.EXECUTION_FAILED, str(e)),
388+
)
389+
finally:
390+
await client.close()
391+
392+
288393
@router.get("/{pr_number}", response_model=PRResponse)
289394
@rate_limit_standard()
290395
async def get_pull_request(
@@ -355,6 +460,41 @@ async def create_pull_request(
355460
base=body.base,
356461
)
357462

463+
# Capture proof snapshot at PR creation time.
464+
try:
465+
from codeframe.core.proof.ledger import (
466+
init_proof_tables,
467+
list_requirements,
468+
save_pr_proof_snapshot,
469+
)
470+
471+
init_proof_tables(workspace)
472+
reqs = list_requirements(workspace)
473+
474+
gates_total = 0
475+
gates_passed = 0
476+
gate_breakdown: list[dict] = []
477+
for req in reqs:
478+
for ob in req.obligations:
479+
gates_total += 1
480+
passed = ob.status == "satisfied"
481+
if passed:
482+
gates_passed += 1
483+
gate_breakdown.append({
484+
"gate": ob.gate.value,
485+
"status": ob.status,
486+
})
487+
488+
save_pr_proof_snapshot(
489+
workspace,
490+
pr_number=pr.number,
491+
gates_passed=gates_passed,
492+
gates_total=gates_total,
493+
gate_breakdown=gate_breakdown,
494+
)
495+
except Exception as snap_err:
496+
logger.warning(f"Failed to save proof snapshot for PR #{pr.number}: {snap_err}")
497+
358498
return _pr_to_response(pr)
359499

360500
except GitHubAPIError as e:

tests/core/test_proof_snapshot.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Tests for pr_proof_snapshots ledger functions.
2+
3+
Verifies save_pr_proof_snapshot and get_pr_proof_snapshot work correctly
4+
with the SQLite-backed proof ledger.
5+
"""
6+
7+
import shutil
8+
import tempfile
9+
from pathlib import Path
10+
11+
import pytest
12+
13+
from codeframe.core.workspace import create_or_load_workspace
14+
15+
pytestmark = pytest.mark.v2
16+
17+
18+
@pytest.fixture
19+
def test_workspace():
20+
temp_dir = Path(tempfile.mkdtemp())
21+
workspace_path = temp_dir / "test_ws"
22+
workspace_path.mkdir(parents=True, exist_ok=True)
23+
24+
workspace = create_or_load_workspace(workspace_path)
25+
26+
yield workspace
27+
28+
shutil.rmtree(temp_dir, ignore_errors=True)
29+
30+
31+
class TestPrProofSnapshot:
32+
"""Tests for save/get pr_proof_snapshot functions."""
33+
34+
def test_save_and_get_snapshot(self, test_workspace):
35+
"""Save a snapshot, retrieve it, verify all fields."""
36+
from codeframe.core.proof.ledger import (
37+
init_proof_tables,
38+
save_pr_proof_snapshot,
39+
get_pr_proof_snapshot,
40+
)
41+
42+
init_proof_tables(test_workspace)
43+
44+
gate_breakdown = [
45+
{"gate": "unit_test", "status": "satisfied"},
46+
{"gate": "lint", "status": "failed"},
47+
]
48+
save_pr_proof_snapshot(
49+
test_workspace,
50+
pr_number=42,
51+
gates_passed=1,
52+
gates_total=2,
53+
gate_breakdown=gate_breakdown,
54+
)
55+
56+
result = get_pr_proof_snapshot(test_workspace, 42)
57+
58+
assert result is not None
59+
assert result["pr_number"] == 42
60+
assert result["gates_passed"] == 1
61+
assert result["gates_total"] == 2
62+
assert result["gate_breakdown"] == gate_breakdown
63+
assert "snapshotted_at" in result
64+
65+
def test_get_nonexistent_snapshot_returns_none(self, test_workspace):
66+
"""Getting a snapshot for a non-existent PR returns None."""
67+
from codeframe.core.proof.ledger import (
68+
init_proof_tables,
69+
get_pr_proof_snapshot,
70+
)
71+
72+
init_proof_tables(test_workspace)
73+
74+
result = get_pr_proof_snapshot(test_workspace, 9999)
75+
assert result is None
76+
77+
def test_snapshot_overwrites_on_same_pr_number(self, test_workspace):
78+
"""Saving a snapshot for the same PR overwrites the previous one."""
79+
from codeframe.core.proof.ledger import (
80+
init_proof_tables,
81+
save_pr_proof_snapshot,
82+
get_pr_proof_snapshot,
83+
)
84+
85+
init_proof_tables(test_workspace)
86+
87+
save_pr_proof_snapshot(
88+
test_workspace,
89+
pr_number=10,
90+
gates_passed=3,
91+
gates_total=5,
92+
gate_breakdown=[{"gate": "unit_test", "status": "satisfied"}],
93+
)
94+
95+
save_pr_proof_snapshot(
96+
test_workspace,
97+
pr_number=10,
98+
gates_passed=5,
99+
gates_total=5,
100+
gate_breakdown=[
101+
{"gate": "unit_test", "status": "satisfied"},
102+
{"gate": "lint", "status": "satisfied"},
103+
],
104+
)
105+
106+
result = get_pr_proof_snapshot(test_workspace, 10)
107+
assert result is not None
108+
assert result["gates_passed"] == 5
109+
assert result["gates_total"] == 5
110+
assert len(result["gate_breakdown"]) == 2

0 commit comments

Comments
 (0)