Skip to content

Commit 705b160

Browse files
authored
feat: Review & Commit View - Diff Viewer & Git Actions (#334)
## Summary Implements #334: [Phase 3] Review & Commit View - Diff Viewer & Git Actions - File tree panel showing changed files grouped by directory with change type icons - Syntax-highlighted unified diff viewer with dual-column line numbers - Quality gate badges (pytest, ruff, python-build) with PASSED/FAILED status - Export patch modal with copy-to-clipboard and download - AI-generated commit messages using heuristic analysis - Git commit with file list and commit message - PR creation form with title/body and success modal - 3 new backend endpoints: /api/v2/review/diff, /patch, /commit-message - 4 new API client namespaces: reviewApi, gatesApi, gitApi, prApi - 24 new tests (9 backend + 15 frontend) ## Acceptance Criteria - [x] File tree panel displays changed files grouped by directory - [x] Unified diff viewer with syntax highlighting and line numbers - [x] Navigation between files (prev/next + tree click) - [x] Quality gate status badges - [x] Export patch with copy/download - [x] Commit message textarea with AI generation - [x] Commit button with loading state - [x] PR creation form with success modal - [x] Review nav item enabled in sidebar - [x] Types added to shared types file - [x] API client namespaces for review, gates, git, PR - [x] All new code lint-clean ## Validation - Tests: All passing (9 backend + 15 frontend + sidebar test updated) - Linting: Clean (ruff + ESLint) - Code review: All feedback addressed (3 internal + 4 automated) - Demo: All 12 acceptance criteria verified with live browser demo Closes #334
1 parent 3821731 commit 705b160

19 files changed

Lines changed: 2269 additions & 6 deletions

File tree

codeframe/core/git.py

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"""
99

1010
import logging
11-
from dataclasses import dataclass
11+
import re
12+
from dataclasses import dataclass, field
1213
from pathlib import Path
1314
from typing import Optional
1415

@@ -55,6 +56,27 @@ class CommitResult:
5556
files_changed: int
5657

5758

59+
@dataclass
60+
class FileChange:
61+
"""Per-file change statistics from a diff."""
62+
63+
path: str
64+
change_type: str # "modified", "added", "deleted", "renamed"
65+
insertions: int = 0
66+
deletions: int = 0
67+
68+
69+
@dataclass
70+
class DiffStats:
71+
"""Parsed diff statistics."""
72+
73+
diff: str
74+
files_changed: int
75+
insertions: int
76+
deletions: int
77+
changed_files: list[FileChange] = field(default_factory=list)
78+
79+
5880
# ============================================================================
5981
# Git Operations
6082
# ============================================================================
@@ -293,3 +315,163 @@ def is_clean(workspace: Workspace) -> bool:
293315
"""
294316
repo = _get_repo(workspace)
295317
return not repo.is_dirty(untracked_files=True)
318+
319+
320+
def get_diff_stats(workspace: Workspace, staged: bool = False) -> DiffStats:
321+
"""Get diff with parsed statistics.
322+
323+
Args:
324+
workspace: Target workspace
325+
staged: If True, show staged changes; if False, show unstaged
326+
327+
Returns:
328+
DiffStats with parsed per-file statistics
329+
"""
330+
repo = _get_repo(workspace)
331+
diff_text = get_diff(workspace, staged=staged)
332+
333+
if not diff_text.strip():
334+
return DiffStats(diff=diff_text, files_changed=0, insertions=0, deletions=0)
335+
336+
# Use git diff --stat for accurate statistics
337+
try:
338+
if staged:
339+
stat_output = repo.git.diff("--cached", "--numstat") if repo.head.is_valid() else ""
340+
else:
341+
stat_output = repo.git.diff("--numstat")
342+
except git.GitCommandError:
343+
stat_output = ""
344+
345+
changed_files: list[FileChange] = []
346+
total_insertions = 0
347+
total_deletions = 0
348+
349+
for line in stat_output.strip().split("\n"):
350+
if not line.strip():
351+
continue
352+
parts = line.split("\t")
353+
if len(parts) >= 3:
354+
ins_str, del_str, file_path = parts[0], parts[1], parts[2]
355+
ins = int(ins_str) if ins_str != "-" else 0
356+
dels = int(del_str) if del_str != "-" else 0
357+
total_insertions += ins
358+
total_deletions += dels
359+
360+
# Extract per-file section from diff for accurate change type detection
361+
file_section_match = re.search(
362+
rf"diff --git a/.*? b/{re.escape(file_path)}\n(.*?)(?=diff --git|\Z)",
363+
diff_text,
364+
re.DOTALL,
365+
)
366+
file_section = file_section_match.group(0) if file_section_match else ""
367+
368+
change_type = "modified"
369+
if "new file mode" in file_section:
370+
change_type = "added"
371+
elif "deleted file mode" in file_section:
372+
change_type = "deleted"
373+
elif "rename from" in file_section:
374+
change_type = "renamed"
375+
376+
changed_files.append(FileChange(
377+
path=file_path,
378+
change_type=change_type,
379+
insertions=ins,
380+
deletions=dels,
381+
))
382+
383+
return DiffStats(
384+
diff=diff_text,
385+
files_changed=len(changed_files),
386+
insertions=total_insertions,
387+
deletions=total_deletions,
388+
changed_files=changed_files,
389+
)
390+
391+
392+
def get_patch(workspace: Workspace, staged: bool = False) -> str:
393+
"""Get patch-formatted diff for export.
394+
395+
Args:
396+
workspace: Target workspace
397+
staged: If True, show staged changes; if False, show unstaged
398+
399+
Returns:
400+
Patch content as string (with full headers for git apply)
401+
"""
402+
repo = _get_repo(workspace)
403+
404+
try:
405+
if staged:
406+
if repo.head.is_valid():
407+
return repo.git.diff("--cached", "--patch", "--full-index")
408+
return ""
409+
else:
410+
return repo.git.diff("--patch", "--full-index")
411+
except git.GitCommandError as e:
412+
logger.warning(f"Failed to get patch: {e}")
413+
return ""
414+
415+
416+
def generate_commit_message(workspace: Workspace, staged: bool = False) -> str:
417+
"""Generate a commit message from the current diff.
418+
419+
Uses heuristic analysis of changed files and diff content to suggest
420+
a conventional commit message. Does not require LLM.
421+
422+
Args:
423+
workspace: Target workspace
424+
staged: If True, analyze staged changes; if False, unstaged
425+
426+
Returns:
427+
Suggested commit message string
428+
"""
429+
stats = get_diff_stats(workspace, staged=staged)
430+
431+
if not stats.changed_files:
432+
return ""
433+
434+
files = stats.changed_files
435+
file_count = len(files)
436+
437+
# Determine primary action from change types
438+
added = [f for f in files if f.change_type == "added"]
439+
deleted = [f for f in files if f.change_type == "deleted"]
440+
modified = [f for f in files if f.change_type == "modified"]
441+
442+
# Pick prefix based on dominant change type
443+
if len(added) > len(modified) and len(added) > len(deleted):
444+
prefix = "feat"
445+
action = "add"
446+
elif len(deleted) > len(modified):
447+
prefix = "refactor"
448+
action = "remove"
449+
else:
450+
prefix = "feat"
451+
action = "update"
452+
453+
# Detect common patterns
454+
test_files = [f for f in files if "test" in f.path.lower()]
455+
if test_files and len(test_files) == file_count:
456+
prefix = "test"
457+
action = "add" if added else "update"
458+
459+
config_files = [f for f in files if f.path.endswith((".json", ".yaml", ".yml", ".toml", ".cfg", ".ini"))]
460+
if config_files and len(config_files) == file_count:
461+
prefix = "chore"
462+
action = "update"
463+
464+
# Build description
465+
if file_count == 1:
466+
file_path = files[0].path
467+
name = Path(file_path).stem
468+
description = f"{action} {name}"
469+
else:
470+
# Find common directory
471+
dirs = set(str(Path(f.path).parent) for f in files)
472+
if len(dirs) == 1 and list(dirs)[0] != ".":
473+
description = f"{action} {list(dirs)[0]} ({file_count} files)"
474+
else:
475+
description = f"{action} {file_count} files"
476+
477+
return f"{prefix}: {description}"

codeframe/ui/routers/review_v2.py

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from codeframe.core.workspace import Workspace
1616
from codeframe.lib.rate_limiter import rate_limit_standard
17-
from codeframe.core import review
17+
from codeframe.core import review, git
1818
from codeframe.ui.dependencies import get_v2_workspace
1919

2020
logger = logging.getLogger(__name__)
@@ -71,6 +71,38 @@ class ReviewTaskRequest(BaseModel):
7171
files_modified: list[str] = Field(..., min_length=1, description="Modified files to review")
7272

7373

74+
class FileChangeResponse(BaseModel):
75+
"""Per-file change statistics."""
76+
77+
path: str
78+
change_type: str
79+
insertions: int
80+
deletions: int
81+
82+
83+
class DiffStatsResponse(BaseModel):
84+
"""Response model for diff with statistics."""
85+
86+
diff: str
87+
files_changed: int
88+
insertions: int
89+
deletions: int
90+
changed_files: list[FileChangeResponse]
91+
92+
93+
class PatchResponse(BaseModel):
94+
"""Response model for patch export."""
95+
96+
patch: str
97+
filename: str
98+
99+
100+
class CommitMessageResponse(BaseModel):
101+
"""Response model for generated commit message."""
102+
103+
message: str
104+
105+
74106
# ============================================================================
75107
# Review Endpoints
76108
# ============================================================================
@@ -205,3 +237,124 @@ async def review_files_summary(
205237
except Exception as e:
206238
logger.error(f"Failed to get review summary: {e}", exc_info=True)
207239
raise HTTPException(status_code=500, detail=str(e))
240+
241+
242+
# ============================================================================
243+
# Diff & Patch Endpoints (for Review & Commit View)
244+
# ============================================================================
245+
246+
247+
@router.get("/diff", response_model=DiffStatsResponse)
248+
@rate_limit_standard()
249+
async def get_review_diff(
250+
request: Request,
251+
staged: bool = False,
252+
workspace: Workspace = Depends(get_v2_workspace),
253+
) -> DiffStatsResponse:
254+
"""Get unified diff with parsed statistics.
255+
256+
Returns the raw diff plus per-file change counts for display
257+
in the Review & Commit View.
258+
259+
Args:
260+
request: HTTP request for rate limiting
261+
staged: If True, show staged changes; if False, show unstaged
262+
workspace: v2 Workspace
263+
264+
Returns:
265+
DiffStatsResponse with diff text and statistics
266+
"""
267+
try:
268+
stats = git.get_diff_stats(workspace, staged=staged)
269+
270+
return DiffStatsResponse(
271+
diff=stats.diff,
272+
files_changed=stats.files_changed,
273+
insertions=stats.insertions,
274+
deletions=stats.deletions,
275+
changed_files=[
276+
FileChangeResponse(
277+
path=f.path,
278+
change_type=f.change_type,
279+
insertions=f.insertions,
280+
deletions=f.deletions,
281+
)
282+
for f in stats.changed_files
283+
],
284+
)
285+
286+
except ValueError as e:
287+
logger.error(f"Get review diff failed: {e}")
288+
raise HTTPException(status_code=400, detail=str(e))
289+
except Exception as e:
290+
logger.error(f"Failed to get review diff: {e}", exc_info=True)
291+
raise HTTPException(status_code=500, detail=str(e))
292+
293+
294+
@router.get("/patch", response_model=PatchResponse)
295+
@rate_limit_standard()
296+
async def get_review_patch(
297+
request: Request,
298+
staged: bool = False,
299+
workspace: Workspace = Depends(get_v2_workspace),
300+
) -> PatchResponse:
301+
"""Get patch-formatted diff for export.
302+
303+
Returns the diff in patch format suitable for `git apply`.
304+
305+
Args:
306+
request: HTTP request for rate limiting
307+
staged: If True, show staged changes; if False, show unstaged
308+
workspace: v2 Workspace
309+
310+
Returns:
311+
PatchResponse with patch content and suggested filename
312+
"""
313+
try:
314+
patch_content = git.get_patch(workspace, staged=staged)
315+
branch = git.get_current_branch(workspace)
316+
filename = f"{branch.replace('/', '-')}.patch"
317+
318+
return PatchResponse(
319+
patch=patch_content,
320+
filename=filename,
321+
)
322+
323+
except ValueError as e:
324+
logger.error(f"Get patch failed: {e}")
325+
raise HTTPException(status_code=400, detail=str(e))
326+
except Exception as e:
327+
logger.error(f"Failed to get patch: {e}", exc_info=True)
328+
raise HTTPException(status_code=500, detail=str(e))
329+
330+
331+
@router.post("/commit-message", response_model=CommitMessageResponse)
332+
@rate_limit_standard()
333+
async def generate_commit_message(
334+
request: Request,
335+
staged: bool = False,
336+
workspace: Workspace = Depends(get_v2_workspace),
337+
) -> CommitMessageResponse:
338+
"""Generate a commit message from the current diff.
339+
340+
Analyzes changed files to suggest a conventional commit message.
341+
342+
Args:
343+
request: HTTP request for rate limiting
344+
staged: If True, analyze staged changes; if False, unstaged
345+
workspace: v2 Workspace
346+
347+
Returns:
348+
CommitMessageResponse with suggested message
349+
"""
350+
try:
351+
message = git.generate_commit_message(workspace, staged=staged)
352+
353+
return CommitMessageResponse(message=message)
354+
355+
except ValueError as e:
356+
logger.error(f"Generate commit message failed: {e}")
357+
raise HTTPException(status_code=400, detail=str(e))
358+
except Exception as e:
359+
logger.error(f"Failed to generate commit message: {e}", exc_info=True)
360+
raise HTTPException(status_code=500, detail=str(e))

0 commit comments

Comments
 (0)