Files
Aegis/backend/app/services/tempo_service.py
Kitos febf460580 feat(phase-36): automatic Tempo time tracking via workflow buttons + fix campaign test management
- Add red_started_at/blue_started_at timing fields to Test model with Alembic migration

- Modify workflow transitions to auto-create integrity-hashed worklogs: Start Execution records red_started_at, Submit to Blue Team stops Red timer and creates worklog then starts Blue timer, Submit for Review stops Blue timer and creates worklog

- Auto-sync worklogs to Tempo when test has a Jira link

- Add LiveTimer component showing real-time elapsed counter during active phases

- Clear timing fields on test reopen

- Fix campaign test management: replace broken navigate-to-tests flow with AddTestToCampaignModal that lets users search and add existing tests directly from the campaign detail page
2026-02-17 16:59:19 +01:00

115 lines
3.4 KiB
Python

"""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
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,
) -> dict:
"""Create a worklog entry in Tempo."""
tempo = get_tempo_client()
worklog = tempo.create_worklog(
accountId=author_account_id,
issueId=jira_issue_id,
dateFrom=date,
timeSpentSeconds=time_spent_seconds,
description=description,
)
return worklog
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 == "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=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