Files
markitect-main/markitect/issues/activity_tracker.py
tegwick d49fa8e9fb feat: implement issue activity tracking system (issue #113)
- Add comprehensive IssueActivityTracker service with ActivityType enum and IssueActivity dataclass
- Implement full CLI interface with log, show, list, summary, delete, and import-activities commands
- Support activity logging with automatic period detection and cost allocation integration
- Add activity retrieval by issue, by period, with filtering and pagination
- Include activity summaries with statistics and breakdowns across issues and time periods
- Support bulk operations for activity import from JSON/CSV formats
- Integrate with existing finance schema using cost_periods and issue_activity_log tables
- Add 28 comprehensive test cases covering all functionality with 100% pass rate
- Enable both table and JSON output formats for all CLI commands

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 03:14:04 +02:00

364 lines
12 KiB
Python

"""
Issue Activity Tracking Service
This module provides comprehensive issue activity tracking functionality,
building on the existing database infrastructure to log and retrieve
issue activities for cost allocation and project management.
"""
import sqlite3
from datetime import datetime, date
from typing import List, Dict, Any, Optional
from dataclasses import dataclass, asdict
from enum import Enum
from markitect.finance.models import FinanceModels
class ActivityType(Enum):
"""Enumeration of supported issue activity types."""
CREATED = "created"
MODIFIED = "modified"
CLOSED = "closed"
REOPENED = "reopened"
COMMENTED = "commented"
STATUS_CHANGED = "status_changed"
@dataclass
class IssueActivity:
"""Data class representing an issue activity record."""
id: Optional[int] = None
issue_id: int = None
activity_type: ActivityType = None
activity_date: date = None
period_id: Optional[int] = None
activity_details: Optional[str] = None
created_at: Optional[datetime] = None
class IssueActivityTracker:
"""
Service for tracking and managing issue activities.
Provides functionality to log issue activities, retrieve activity history,
and generate activity reports for cost allocation and project management.
"""
def __init__(self, db_path: str = "markitect.db"):
"""
Initialize the issue activity tracker.
Args:
db_path: Path to the SQLite database file
"""
self.db_path = db_path
self.finance_models = FinanceModels(db_path)
self._ensure_schema()
def _ensure_schema(self):
"""Ensure the database schema is properly initialized."""
self.finance_models.initialize_finance_schema()
def log_activity(
self,
issue_id: int,
activity_type: ActivityType,
activity_date: Optional[date] = None,
activity_details: Optional[str] = None,
period_id: Optional[int] = None
) -> int:
"""
Log an issue activity.
Args:
issue_id: ID of the issue
activity_type: Type of activity performed
activity_date: Date when activity occurred (defaults to today)
activity_details: Additional details about the activity
period_id: Optional period ID for cost allocation
Returns:
ID of the created activity record
Raises:
sqlite3.Error: If database operation fails
"""
if activity_date is None:
activity_date = date.today()
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
# If period_id is not provided, try to get the current period
if period_id is None:
cursor.execute('''
SELECT id FROM cost_periods
WHERE ? BETWEEN period_start AND period_end
ORDER BY created_at DESC LIMIT 1
''', (activity_date,))
result = cursor.fetchone()
if result:
period_id = result[0]
cursor.execute('''
INSERT INTO issue_activity_log
(issue_id, activity_type, activity_date, period_id, activity_details)
VALUES (?, ?, ?, ?, ?)
''', (issue_id, activity_type.value, activity_date, period_id, activity_details))
return cursor.lastrowid
def get_issue_activities(
self,
issue_id: int,
limit: Optional[int] = None,
offset: int = 0
) -> List[IssueActivity]:
"""
Get activities for a specific issue.
Args:
issue_id: ID of the issue
limit: Maximum number of activities to return
offset: Number of activities to skip
Returns:
List of issue activities, ordered by activity date (most recent first)
"""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
query = '''
SELECT id, issue_id, activity_type, activity_date,
period_id, activity_details, created_at
FROM issue_activity_log
WHERE issue_id = ?
ORDER BY activity_date DESC, created_at DESC
'''
params = [issue_id]
if limit:
query += ' LIMIT ? OFFSET ?'
params.extend([limit, offset])
cursor.execute(query, params)
rows = cursor.fetchall()
activities = []
for row in rows:
activity = IssueActivity(
id=row[0],
issue_id=row[1],
activity_type=ActivityType(row[2]),
activity_date=datetime.strptime(row[3], '%Y-%m-%d').date() if row[3] else None,
period_id=row[4],
activity_details=row[5],
created_at=datetime.fromisoformat(row[6]) if row[6] else None
)
activities.append(activity)
return activities
def get_activities_by_period(
self,
period_id: int,
activity_types: Optional[List[ActivityType]] = None
) -> List[IssueActivity]:
"""
Get all activities within a specific cost period.
Args:
period_id: ID of the cost period
activity_types: Optional list of activity types to filter by
Returns:
List of issue activities within the period
"""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
query = '''
SELECT id, issue_id, activity_type, activity_date,
period_id, activity_details, created_at
FROM issue_activity_log
WHERE period_id = ?
'''
params = [period_id]
if activity_types:
placeholders = ','.join(['?' for _ in activity_types])
query += f' AND activity_type IN ({placeholders})'
params.extend([at.value for at in activity_types])
query += ' ORDER BY activity_date DESC, created_at DESC'
cursor.execute(query, params)
rows = cursor.fetchall()
activities = []
for row in rows:
activity = IssueActivity(
id=row[0],
issue_id=row[1],
activity_type=ActivityType(row[2]),
activity_date=datetime.strptime(row[3], '%Y-%m-%d').date() if row[3] else None,
period_id=row[4],
activity_details=row[5],
created_at=datetime.fromisoformat(row[6]) if row[6] else None
)
activities.append(activity)
return activities
def get_activity_summary(
self,
issue_id: Optional[int] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None
) -> Dict[str, Any]:
"""
Get activity summary statistics.
Args:
issue_id: Optional issue ID to filter by
start_date: Optional start date filter
end_date: Optional end date filter
Returns:
Dictionary containing activity summary statistics
"""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
# Build base query
base_conditions = []
params = []
if issue_id:
base_conditions.append('issue_id = ?')
params.append(issue_id)
if start_date:
base_conditions.append('activity_date >= ?')
params.append(start_date)
if end_date:
base_conditions.append('activity_date <= ?')
params.append(end_date)
where_clause = ' AND '.join(base_conditions) if base_conditions else '1=1'
# Get total activity count
cursor.execute(f'''
SELECT COUNT(*) FROM issue_activity_log WHERE {where_clause}
''', params)
total_activities = cursor.fetchone()[0]
# Get activity count by type
cursor.execute(f'''
SELECT activity_type, COUNT(*)
FROM issue_activity_log
WHERE {where_clause}
GROUP BY activity_type
ORDER BY COUNT(*) DESC
''', params)
activities_by_type = dict(cursor.fetchall())
# Get unique issues count
cursor.execute(f'''
SELECT COUNT(DISTINCT issue_id)
FROM issue_activity_log
WHERE {where_clause}
''', params)
unique_issues = cursor.fetchone()[0]
# Get date range
cursor.execute(f'''
SELECT MIN(activity_date), MAX(activity_date)
FROM issue_activity_log
WHERE {where_clause}
''', params)
date_range = cursor.fetchone()
return {
'total_activities': total_activities,
'activities_by_type': activities_by_type,
'unique_issues': unique_issues,
'date_range': {
'start': date_range[0] if date_range[0] else None,
'end': date_range[1] if date_range[1] else None
},
'filters': {
'issue_id': issue_id,
'start_date': start_date.isoformat() if start_date else None,
'end_date': end_date.isoformat() if end_date else None
}
}
def delete_activity(self, activity_id: int) -> bool:
"""
Delete an activity record.
Args:
activity_id: ID of the activity to delete
Returns:
True if activity was deleted, False if not found
"""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM issue_activity_log WHERE id = ?', (activity_id,))
return cursor.rowcount > 0
def bulk_log_activities(self, activities: List[Dict[str, Any]]) -> List[int]:
"""
Log multiple activities in a single transaction.
Args:
activities: List of activity dictionaries with keys:
issue_id, activity_type, activity_date (optional),
activity_details (optional), period_id (optional)
Returns:
List of created activity IDs
Raises:
ValueError: If activity data is invalid
sqlite3.Error: If database operation fails
"""
activity_ids = []
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
for activity_data in activities:
if 'issue_id' not in activity_data or 'activity_type' not in activity_data:
raise ValueError("Each activity must have 'issue_id' and 'activity_type'")
issue_id = activity_data['issue_id']
activity_type = ActivityType(activity_data['activity_type'])
activity_date = activity_data.get('activity_date', date.today())
activity_details = activity_data.get('activity_details')
period_id = activity_data.get('period_id')
# Auto-determine period if not provided
if period_id is None:
cursor.execute('''
SELECT id FROM cost_periods
WHERE ? BETWEEN period_start AND period_end
ORDER BY created_at DESC LIMIT 1
''', (activity_date,))
result = cursor.fetchone()
if result:
period_id = result[0]
cursor.execute('''
INSERT INTO issue_activity_log
(issue_id, activity_type, activity_date, period_id, activity_details)
VALUES (?, ?, ?, ?, ?)
''', (issue_id, activity_type.value, activity_date, period_id, activity_details))
activity_ids.append(cursor.lastrowid)
return activity_ids