Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions codeframe/ui/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,15 @@ class RestoreCheckpointRequest(BaseModel):
)


class CheckpointDiffResponse(BaseModel):
"""Response model for checkpoint diff (Sprint 10 Phase 4)."""

files_changed: int = Field(description="Number of files changed")
insertions: int = Field(description="Number of lines inserted")
deletions: int = Field(description="Number of lines deleted")
diff: str = Field(description="Git diff output")


# Multi-Agent Per Project API Models (Phase 3)


Expand Down
162 changes: 162 additions & 0 deletions codeframe/ui/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
QualityGatesRequest,
CheckpointCreateRequest,
CheckpointResponse,
CheckpointDiffResponse,
RestoreCheckpointRequest,
AgentAssignmentRequest,
AgentRoleUpdateRequest,
Expand Down Expand Up @@ -3451,6 +3452,167 @@ async def restore_checkpoint(
raise HTTPException(status_code=500, detail=f"Checkpoint restore failed: {str(e)}")


@app.get("/api/projects/{project_id}/checkpoints/{checkpoint_id}/diff", tags=["checkpoints"])
async def get_checkpoint_diff(project_id: int, checkpoint_id: int) -> CheckpointDiffResponse:
"""Get git diff for a checkpoint (Sprint 10 Phase 4).

Returns the git diff between the checkpoint commit and current HEAD,
including statistics about files changed, insertions, and deletions.

Args:
project_id: Project ID
checkpoint_id: Checkpoint ID to get diff for

Returns:
200 OK: Checkpoint diff with statistics
{
"files_changed": int,
"insertions": int,
"deletions": int,
"diff": str
}
404 Not Found: Project or checkpoint not found
500 Internal Server Error: Git operation failed
"""
import re
import subprocess
from codeframe.lib.checkpoint_manager import CheckpointManager

# Verify project exists
project = app.state.db.get_project_by_id(project_id)
if not project:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")

# Get project workspace path
workspace_path = project.get("workspace_path")
if not workspace_path:
raise HTTPException(
status_code=500,
detail=f"Project {project_id} has no workspace path configured",
)

# Verify checkpoint exists
checkpoint = app.state.db.get_checkpoint_by_id(checkpoint_id)
if not checkpoint:
raise HTTPException(status_code=404, detail=f"Checkpoint {checkpoint_id} not found")

# Verify checkpoint belongs to this project
if checkpoint.project_id != project_id:
raise HTTPException(
status_code=404,
detail=f"Checkpoint {checkpoint_id} does not belong to project {project_id}",
)

# SECURITY: Validate git commit SHA format to prevent command injection
git_sha_pattern = re.compile(r'^[a-f0-9]{7,40}$')
if not git_sha_pattern.match(checkpoint.git_commit):
logger.error(f"Invalid git commit SHA format: {checkpoint.git_commit}")
raise HTTPException(
status_code=500,
detail=f"Invalid git commit format in checkpoint {checkpoint_id}",
)

try:
# Verify git commit exists before attempting diff
try:
verify_result = subprocess.run(
["git", "cat-file", "-e", checkpoint.git_commit],
cwd=Path(workspace_path),
check=True,
capture_output=True,
timeout=5
)
except subprocess.CalledProcessError:
logger.error(f"Git commit {checkpoint.git_commit} not found in repository")
raise HTTPException(
status_code=404,
detail=f"Checkpoint commit {checkpoint.git_commit[:7]} not found in repository",
)
except subprocess.TimeoutExpired:
logger.error(f"Git verification timed out for commit {checkpoint.git_commit}")
raise HTTPException(status_code=500, detail="Git operation timed out")

# Create checkpoint manager
checkpoint_mgr = CheckpointManager(
db=app.state.db,
project_root=Path(workspace_path),
project_id=project_id,
)

# Get diff output with size limit (10MB)
diff_output = checkpoint_mgr._show_diff(checkpoint.git_commit)
MAX_DIFF_SIZE = 10 * 1024 * 1024 # 10MB
if len(diff_output) > MAX_DIFF_SIZE:
diff_output = diff_output[:MAX_DIFF_SIZE] + "\n\n... [diff truncated - exceeded 10MB limit]"
logger.warning(f"Diff for checkpoint {checkpoint_id} truncated due to size limit")

# Parse diff statistics using git diff --numstat
try:
stats_result = subprocess.run(
["git", "diff", "--numstat", checkpoint.git_commit, "HEAD"],
cwd=Path(workspace_path),
check=True,
capture_output=True,
text=True,
timeout=30
)

# Parse numstat output
# Format: <insertions>\t<deletions>\t<filename>
files_changed = 0
total_insertions = 0
total_deletions = 0
binary_files = 0

for line in stats_result.stdout.strip().split('\n'):
if not line:
continue
files_changed += 1
parts = line.split('\t')
if len(parts) >= 2:
# Handle binary files (marked as '-')
if parts[0] == '-' or parts[1] == '-':
binary_files += 1
else:
insertions = int(parts[0])
deletions = int(parts[1])
total_insertions += insertions
total_deletions += deletions

response = CheckpointDiffResponse(
files_changed=files_changed,
insertions=total_insertions,
deletions=total_deletions,
diff=diff_output
)

# Add cache headers for immutable checkpoint diffs
return JSONResponse(
content=response.model_dump(),
headers={
"Cache-Control": "public, max-age=31536000, immutable",
"X-Binary-Files": str(binary_files)
}
)

except subprocess.CalledProcessError as e:
logger.error(f"Failed to get diff stats: {e.stderr}")
# Return error response when parsing fails (not misleading zeros)
raise HTTPException(
status_code=500,
detail=f"Failed to parse diff statistics: {e.stderr[:200]}"
)
except subprocess.TimeoutExpired:
logger.error(f"Git diff timed out for checkpoint {checkpoint_id}")
raise HTTPException(status_code=500, detail="Diff operation timed out")

except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get checkpoint diff {checkpoint_id}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to get checkpoint diff: {str(e)}")


# Sprint 10 Phase 5: Metrics API endpoints (T127-T129)


Expand Down
10 changes: 2 additions & 8 deletions tests/e2e/test_checkpoint_ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,7 @@ test.describe('Checkpoint UI Workflow', () => {
}
});

test.skip('should display checkpoint diff preview', async ({ page }) => {
// SKIP: Checkpoint diff preview not implemented in current CheckpointList
// CheckpointList shows metadata but not git diff preview

test('should display checkpoint diff preview', async ({ page }) => {
const checkpointItems = page.locator('[data-testid^="checkpoint-item-"]');

if (await checkpointItems.count() > 0) {
Expand Down Expand Up @@ -164,10 +161,7 @@ test.describe('Checkpoint UI Workflow', () => {
}
});

test.skip('should allow deleting checkpoint', async ({ page }) => {
// SKIP: Delete uses browser confirm dialog, not custom dialog with testids
// The current implementation uses window.confirm() for delete confirmation

test('should allow deleting checkpoint', async ({ page }) => {
const checkpointItems = page.locator('[data-testid^="checkpoint-item-"]');

if (await checkpointItems.count() > 0) {
Expand Down
4 changes: 3 additions & 1 deletion web-ui/src/api/checkpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ export async function restoreCheckpoint(
*/
export async function getCheckpointDiff(
projectId: number,
checkpointId: number
checkpointId: number,
signal?: AbortSignal
): Promise<CheckpointDiff> {
const response = await fetch(
`${API_BASE_URL}/api/projects/${projectId}/checkpoints/${checkpointId}/diff`,
Expand All @@ -145,6 +146,7 @@ export async function getCheckpointDiff(
headers: {
'Content-Type': 'application/json',
},
signal,
}
);

Expand Down
12 changes: 5 additions & 7 deletions web-ui/src/components/TaskTreeView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,7 @@ describe('TaskTreeView', () => {
expect(humanBadges.length).toBeGreaterThan(0); // Task
});

// TODO: Task dependencies not rendering - see beads issue cf-jf1
it.skip('should display task dependencies', async () => {
it('should display task dependencies', async () => {
const user = userEvent.setup();

render(<TaskTreeView issues={mockIssues} />);
Expand Down Expand Up @@ -380,8 +379,7 @@ describe('TaskTreeView', () => {
expect(titleElement).toBeInTheDocument();
});

// TODO: Task dependencies not rendering - see beads issue cf-jf1
it.skip('should handle multiple dependencies correctly', async () => {
it('should handle multiple dependencies correctly', async () => {
const user = userEvent.setup();

const multiDepTask: Task = {
Expand Down Expand Up @@ -614,9 +612,9 @@ describe('TaskTreeView', () => {
const expandButton = screen.getAllByRole('button', { name: /expand/i })[0];
await user.click(expandButton);

// Should show dependency count
const depCount = screen.getByText(/1 dependency/i);
expect(depCount).toBeInTheDocument();
// Should show dependency text
const depText = screen.getByText(/depends on.*task-1/i);
expect(depText).toBeInTheDocument();
});

it('should mark task as blocked when dependencies are not completed', async () => {
Expand Down
50 changes: 14 additions & 36 deletions web-ui/src/components/TaskTreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,44 +225,22 @@ const TaskTreeView = memo(function TaskTreeView({ issues }: TaskTreeViewProps) {
</span>
)}

{/* Dependency details with hover tooltip */}
{hasDependencies && (
{/* Dependency details */}
{hasDependencies && task.depends_on && (
<span
className="group relative inline-flex items-center text-xs text-gray-500 cursor-help"
title={`Dependencies: ${task.depends_on.join(', ')}`}
className="ml-2 text-xs text-gray-500 cursor-help"
title={`Dependencies:\n${task.depends_on
.map((depId) => {
const depTask = allTasks.find(
(t) => t.id === depId || t.task_number === depId
);
return depTask
? `${depTask.task_number}: ${depTask.title} (${depTask.status})`
: depId;
})
.join('\n')}`}
>
↳ {task.depends_on.length} {task.depends_on.length === 1 ? 'dependency' : 'dependencies'}
{/* Hover tooltip */}
<span className="invisible group-hover:visible absolute left-0 top-full mt-1 w-48 p-2 bg-gray-900 text-white text-xs rounded shadow-lg z-10">
<strong>Depends on:</strong>
<ul className="mt-1 list-disc list-inside">
{task.depends_on.map((depId) => {
const depTask = allTasks.find(
(t) => t.id === depId || t.task_number === depId
);
return (
<li key={depId} className="truncate">
{depTask ? (
<span>
{depTask.task_number}: {depTask.title}
<span
className={`ml-1 ${
depTask.status === 'completed'
? 'text-green-400'
: 'text-yellow-400'
}`}
>
({depTask.status})
</span>
</span>
) : (
depId
)}
</li>
);
})}
</ul>
</span>
Depends on: {task.depends_on.join(', ')}
</span>
)}
</div>
Expand Down
Loading
Loading