"""Tempo time-tracking integration service.""" import logging from typing import Optional from sqlalchemy.orm import Session from app.config import settings from app.domain.exceptions import InvalidOperationError from app.models.jira_link import JiraLink, JiraLinkEntityType logger = logging.getLogger(__name__) def get_tempo_client(): """Return a Tempo API client, or raise if disabled.""" if not settings.TEMPO_ENABLED: raise InvalidOperationError("Tempo integration is not enabled") try: from tempoapiclient import client_v4 as tempo_client return tempo_client.Tempo(auth_token=settings.TEMPO_API_TOKEN) except ImportError: raise InvalidOperationError( "tempo-api-python-client is not installed. " "Install it with: pip install tempo-api-python-client" ) def log_worklog( jira_issue_id: int, author_account_id: str, date: str, time_spent_seconds: int, description: str, work_type: str | None = None, ) -> dict: """Create a worklog entry in Tempo.""" tempo = get_tempo_client() kwargs: dict = { "accountId": author_account_id, "issueId": jira_issue_id, "dateFrom": date, "timeSpentSeconds": time_spent_seconds, "description": description, } wt = work_type or settings.TEMPO_DEFAULT_WORK_TYPE if wt: kwargs["workType"] = wt return tempo.create_worklog(**kwargs) def auto_log_test_worklog( db: Session, test, user, activity_type: str, ) -> Optional[dict]: """If the test has a Jira link, log time to Tempo automatically. Returns the Tempo worklog response, or None if skipped. """ if not settings.TEMPO_ENABLED: return None link = ( db.query(JiraLink) .filter( JiraLink.entity_id == test.id, JiraLink.entity_type == JiraLinkEntityType.test, ) .first() ) if not link or not link.jira_issue_id: logger.debug("No Jira link for test %s, skipping Tempo worklog", test.id) return None duration = _calculate_duration(test, activity_type) if duration <= 0: return None try: result = log_worklog( jira_issue_id=int(link.jira_issue_id), author_account_id=getattr(user, "jira_account_id", "") or "", date=(getattr(test, "updated_at", None) or test.created_at).strftime( "%Y-%m-%d", ), time_spent_seconds=duration, description=f"[Aegis] {activity_type}: {test.name}", ) logger.info("Tempo worklog created for test %s, %ds", test.id, duration) return result except Exception as e: logger.warning("Tempo worklog failed for test %s: %s", test.id, e, exc_info=True) return None def _calculate_duration(test, activity_type: str) -> int: """Calculate real duration in seconds from the phase timing fields. Uses the actual start/end timestamps recorded by the workflow buttons, so the data cannot be falsified. """ from datetime import datetime now = datetime.utcnow() if activity_type == "red_team_execution" and test.red_started_at: delta = now - test.red_started_at return max(int(delta.total_seconds()), 1) if activity_type == "blue_team_evaluation" and test.blue_started_at: delta = now - test.blue_started_at return max(int(delta.total_seconds()), 1) # Fallback for legacy activity types if activity_type == "execution" and test.execution_date and test.created_at: delta = test.execution_date - test.created_at return max(int(delta.total_seconds()), 0) return 0