Ryan Malloy 44ed9936b7 Initial commit: Claude Code Project Tracker
Add comprehensive development intelligence system that tracks:
- Development sessions with automatic start/stop
- Full conversation history with semantic search
- Tool usage and file operation analytics
- Think time and engagement analysis
- Git activity correlation
- Learning pattern recognition
- Productivity insights and metrics

Features:
- FastAPI backend with SQLite database
- Modern web dashboard with interactive charts
- Claude Code hook integration for automatic tracking
- Comprehensive test suite with 100+ tests
- Complete API documentation (OpenAPI/Swagger)
- Privacy-first design with local data storage

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-11 02:59:21 -06:00

360 lines
13 KiB
Python

"""
Git operation tracking API endpoints.
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.database.connection import get_db
from app.models.git_operation import GitOperation
from app.models.session import Session
from app.api.schemas import GitOperationRequest, GitOperationResponse
router = APIRouter()
@router.post("/git", response_model=GitOperationResponse, status_code=status.HTTP_201_CREATED)
async def record_git_operation(
request: GitOperationRequest,
db: AsyncSession = Depends(get_db)
):
"""
Record a git operation performed during development.
Called by hooks when git commands are executed via Bash tool.
"""
try:
# Verify session exists
result = await db.execute(
select(Session).where(Session.id == request.session_id)
)
session = result.scalars().first()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {request.session_id} not found"
)
# Create git operation record
git_operation = GitOperation(
session_id=request.session_id,
timestamp=request.timestamp,
operation=request.operation,
command=request.command,
result=request.result,
success=request.success,
files_changed=request.files_changed,
lines_added=request.lines_added,
lines_removed=request.lines_removed,
commit_hash=request.commit_hash,
branch_from=request.branch_from,
branch_to=request.branch_to
)
db.add(git_operation)
await db.commit()
await db.refresh(git_operation)
return GitOperationResponse(
id=git_operation.id,
session_id=git_operation.session_id,
operation=git_operation.operation,
timestamp=git_operation.timestamp,
success=git_operation.success,
commit_hash=git_operation.commit_hash
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to record git operation: {str(e)}"
)
@router.get("/git/operations")
async def get_git_operations(
session_id: Optional[int] = Query(None, description="Filter by session ID"),
project_id: Optional[int] = Query(None, description="Filter by project ID"),
operation: Optional[str] = Query(None, description="Filter by operation type"),
limit: int = Query(50, description="Maximum number of results"),
offset: int = Query(0, description="Number of results to skip"),
db: AsyncSession = Depends(get_db)
):
"""Get git operations with optional filtering."""
try:
query = select(GitOperation).options(
selectinload(GitOperation.session).selectinload(Session.project)
)
# Apply filters
if session_id:
query = query.where(GitOperation.session_id == session_id)
elif project_id:
query = query.join(Session).where(Session.project_id == project_id)
if operation:
query = query.where(GitOperation.operation == operation)
# Order by timestamp descending
query = query.order_by(GitOperation.timestamp.desc()).offset(offset).limit(limit)
result = await db.execute(query)
git_operations = result.scalars().all()
return [
{
"id": op.id,
"session_id": op.session_id,
"project_name": op.session.project.name,
"timestamp": op.timestamp,
"operation": op.operation,
"command": op.command,
"result": op.result,
"success": op.success,
"files_changed": op.files_changed,
"files_count": op.files_count,
"lines_added": op.lines_added,
"lines_removed": op.lines_removed,
"total_lines_changed": op.total_lines_changed,
"net_lines_changed": op.net_lines_changed,
"commit_hash": op.commit_hash,
"commit_message": op.get_commit_message(),
"commit_category": op.get_commit_category(),
"change_size_category": op.get_change_size_category()
}
for op in git_operations
]
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get git operations: {str(e)}"
)
@router.get("/git/operations/{operation_id}")
async def get_git_operation(
operation_id: int,
db: AsyncSession = Depends(get_db)
):
"""Get detailed information about a specific git operation."""
try:
result = await db.execute(
select(GitOperation)
.options(selectinload(GitOperation.session).selectinload(Session.project))
.where(GitOperation.id == operation_id)
)
git_operation = result.scalars().first()
if not git_operation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Git operation {operation_id} not found"
)
return {
"id": git_operation.id,
"session_id": git_operation.session_id,
"project_name": git_operation.session.project.name,
"timestamp": git_operation.timestamp,
"operation": git_operation.operation,
"command": git_operation.command,
"result": git_operation.result,
"success": git_operation.success,
"files_changed": git_operation.files_changed,
"files_count": git_operation.files_count,
"lines_added": git_operation.lines_added,
"lines_removed": git_operation.lines_removed,
"total_lines_changed": git_operation.total_lines_changed,
"net_lines_changed": git_operation.net_lines_changed,
"commit_hash": git_operation.commit_hash,
"branch_from": git_operation.branch_from,
"branch_to": git_operation.branch_to,
"commit_message": git_operation.get_commit_message(),
"branch_name": git_operation.get_branch_name(),
"is_commit": git_operation.is_commit,
"is_push": git_operation.is_push,
"is_pull": git_operation.is_pull,
"is_branch_operation": git_operation.is_branch_operation,
"is_merge_commit": git_operation.is_merge_commit(),
"is_feature_commit": git_operation.is_feature_commit(),
"is_bugfix_commit": git_operation.is_bugfix_commit(),
"is_refactor_commit": git_operation.is_refactor_commit(),
"commit_category": git_operation.get_commit_category(),
"change_size_category": git_operation.get_change_size_category()
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get git operation: {str(e)}"
)
@router.get("/git/stats/commits")
async def get_commit_stats(
project_id: Optional[int] = Query(None, description="Filter by project ID"),
days: int = Query(30, description="Number of days to include"),
db: AsyncSession = Depends(get_db)
):
"""Get commit statistics and patterns."""
try:
# Base query for commits
query = select(GitOperation).where(GitOperation.operation == "commit")
# Apply filters
if project_id:
query = query.join(Session).where(Session.project_id == project_id)
if days > 0:
from datetime import datetime, timedelta
start_date = datetime.utcnow() - timedelta(days=days)
query = query.where(GitOperation.timestamp >= start_date)
result = await db.execute(query)
commits = result.scalars().all()
if not commits:
return {
"total_commits": 0,
"commit_categories": {},
"change_size_distribution": {},
"average_lines_per_commit": 0.0,
"commit_frequency": []
}
# Calculate statistics
total_commits = len(commits)
# Categorize commits
categories = {}
for commit in commits:
category = commit.get_commit_category()
categories[category] = categories.get(category, 0) + 1
# Change size distribution
size_distribution = {}
for commit in commits:
size_category = commit.get_change_size_category()
size_distribution[size_category] = size_distribution.get(size_category, 0) + 1
# Average lines per commit
total_lines = sum(commit.total_lines_changed for commit in commits)
avg_lines_per_commit = total_lines / total_commits if total_commits > 0 else 0
# Daily commit frequency
daily_commits = {}
for commit in commits:
date_key = commit.timestamp.date().isoformat()
daily_commits[date_key] = daily_commits.get(date_key, 0) + 1
commit_frequency = [
{"date": date, "commits": count}
for date, count in sorted(daily_commits.items())
]
return {
"total_commits": total_commits,
"commit_categories": categories,
"change_size_distribution": size_distribution,
"average_lines_per_commit": round(avg_lines_per_commit, 1),
"commit_frequency": commit_frequency,
"top_commit_messages": [
{
"message": commit.get_commit_message(),
"lines_changed": commit.total_lines_changed,
"timestamp": commit.timestamp
}
for commit in sorted(commits, key=lambda c: c.total_lines_changed, reverse=True)[:5]
if commit.get_commit_message()
]
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get commit stats: {str(e)}"
)
@router.get("/git/stats/activity")
async def get_git_activity_stats(
project_id: Optional[int] = Query(None, description="Filter by project ID"),
days: int = Query(30, description="Number of days to include"),
db: AsyncSession = Depends(get_db)
):
"""Get overall git activity statistics."""
try:
query = select(GitOperation)
# Apply filters
if project_id:
query = query.join(Session).where(Session.project_id == project_id)
if days > 0:
from datetime import datetime, timedelta
start_date = datetime.utcnow() - timedelta(days=days)
query = query.where(GitOperation.timestamp >= start_date)
result = await db.execute(query)
operations = result.scalars().all()
if not operations:
return {
"total_operations": 0,
"operations_by_type": {},
"success_rate": 0.0,
"most_active_days": []
}
# Operations by type
operations_by_type = {}
successful_operations = 0
for op in operations:
operations_by_type[op.operation] = operations_by_type.get(op.operation, 0) + 1
if op.success:
successful_operations += 1
success_rate = successful_operations / len(operations) * 100 if operations else 0
# Daily activity
daily_activity = {}
for op in operations:
date_key = op.timestamp.date().isoformat()
if date_key not in daily_activity:
daily_activity[date_key] = 0
daily_activity[date_key] += 1
most_active_days = [
{"date": date, "operations": count}
for date, count in sorted(daily_activity.items(), key=lambda x: x[1], reverse=True)[:7]
]
return {
"total_operations": len(operations),
"operations_by_type": operations_by_type,
"success_rate": round(success_rate, 1),
"most_active_days": most_active_days,
"timeline": [
{
"date": date,
"operations": count
}
for date, count in sorted(daily_activity.items())
]
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get git activity stats: {str(e)}"
)