Files
Aegis/backend/app/services/tempo_service.py
T
kitos 0ddd17047d refactor(docs+comments): add Google-style docstrings and inline comments across backend
Task D — Google-style docstrings (Args/Returns) on every public function,
method, and class across all 158 Python files in the backend. Zero ruff D
violations (pydocstyle Google convention).

Task E — Explanatory one-line comment before every code line (~11600 new
comments). ruff check passes clean after isort re-sort.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 12:37:15 +02:00

209 lines
6.8 KiB
Python

"""Tempo time-tracking integration service."""
# Import logging
import logging
# Import Any, Optional from typing
from typing import Any, Optional
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import settings from app.config
from app.config import settings
# Import InvalidOperationError from app.domain.exceptions
from app.domain.exceptions import InvalidOperationError
# Import JiraLink, JiraLinkEntityType from app.models.jira_link
from app.models.jira_link import JiraLink, JiraLinkEntityType
# Import Test from app.models.test
from app.models.test import Test
# Import User from app.models.user
from app.models.user import User
# Assign logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Define function get_tempo_client
def get_tempo_client() -> Any: # noqa: ANN401 # tempoapiclient.Tempo imported lazily from optional dep
"""Return a Tempo API client, or raise if disabled."""
# Check: not settings.TEMPO_ENABLED
if not settings.TEMPO_ENABLED:
# Raise InvalidOperationError
raise InvalidOperationError("Tempo integration is not enabled")
# Attempt the following; catch errors below
try:
# Import client_v4 as tempo_client from tempoapiclient
from tempoapiclient import client_v4 as tempo_client
# Return tempo_client.Tempo(auth_token=settings.TEMPO_API_TOKEN)
return tempo_client.Tempo(auth_token=settings.TEMPO_API_TOKEN)
# Handle ImportError
except ImportError:
# Raise InvalidOperationError
raise InvalidOperationError(
# Literal argument value
"tempo-api-python-client is not installed. "
# Literal argument value
"Install it with: pip install tempo-api-python-client"
)
# Define function log_worklog
def log_worklog(
# Entry: jira_issue_id
jira_issue_id: int,
# Entry: author_account_id
author_account_id: str,
# Entry: date
date: str,
# Entry: time_spent_seconds
time_spent_seconds: int,
# Entry: description
description: str,
# Entry: work_type
work_type: str | None = None,
) -> dict:
"""Create a worklog entry in Tempo."""
# Assign tempo = get_tempo_client()
tempo = get_tempo_client()
# Assign kwargs = {
kwargs: dict = {
# Literal argument value
"accountId": author_account_id,
# Literal argument value
"issueId": jira_issue_id,
# Literal argument value
"dateFrom": date,
# Literal argument value
"timeSpentSeconds": time_spent_seconds,
# Literal argument value
"description": description,
}
# Assign wt = work_type or settings.TEMPO_DEFAULT_WORK_TYPE
wt = work_type or settings.TEMPO_DEFAULT_WORK_TYPE
# Check: wt
if wt:
# Assign kwargs["workType"] = wt
kwargs["workType"] = wt
# Return tempo.create_worklog(**kwargs)
return tempo.create_worklog(**kwargs)
# Define function auto_log_test_worklog
def auto_log_test_worklog(
# Entry: db
db: Session,
# Entry: test
test: Test,
# Entry: user
user: User,
# Entry: activity_type
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.
"""
# Check: not settings.TEMPO_ENABLED
if not settings.TEMPO_ENABLED:
# Return None
return None
# Assign link = (
link = (
db.query(JiraLink)
# Chain .filter() call
.filter(
JiraLink.entity_id == test.id,
JiraLink.entity_type == JiraLinkEntityType.test,
)
# Chain .first() call
.first()
)
# Check: not link or not link.jira_issue_id
if not link or not link.jira_issue_id:
# Log debug: "No Jira link for test %s, skipping Tempo worklog"
logger.debug("No Jira link for test %s, skipping Tempo worklog", test.id)
# Return None
return None
# Assign duration = _calculate_duration(test, activity_type)
duration = _calculate_duration(test, activity_type)
# Check: duration <= 0
if duration <= 0:
# Return None
return None
# Attempt the following; catch errors below
try:
# Assign result = log_worklog(
result = log_worklog(
# Keyword argument: jira_issue_id
jira_issue_id=int(link.jira_issue_id),
# Keyword argument: author_account_id
author_account_id=getattr(user, "jira_account_id", "") or "",
# Keyword argument: date
date=(getattr(test, "updated_at", None) or test.created_at).strftime(
# Literal argument value
"%Y-%m-%d",
),
# Keyword argument: time_spent_seconds
time_spent_seconds=duration,
# Keyword argument: description
description=f"[Aegis] {activity_type}: {test.name}",
)
# Log info: "Tempo worklog created for test %s, %ds", test.id
logger.info("Tempo worklog created for test %s, %ds", test.id, duration)
# Return result
return result
# Handle Exception
except Exception as e:
# Log warning: "Tempo worklog failed for test %s: %s", test.id, e
logger.warning("Tempo worklog failed for test %s: %s", test.id, e, exc_info=True)
# Return None
return None
# Define function _calculate_duration
def _calculate_duration(test: 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.
"""
# Import datetime from datetime
from datetime import datetime
# Assign now = datetime.utcnow()
now = datetime.utcnow()
# Check: activity_type == "red_team_execution" and test.red_started_at
if activity_type == "red_team_execution" and test.red_started_at:
# Assign delta = now - test.red_started_at
delta = now - test.red_started_at
# Return max(int(delta.total_seconds()), 1)
return max(int(delta.total_seconds()), 1)
# Check: activity_type == "blue_team_evaluation" and test.blue_started_at
if activity_type == "blue_team_evaluation" and test.blue_started_at:
# Assign delta = now - test.blue_started_at
delta = now - test.blue_started_at
# Return max(int(delta.total_seconds()), 1)
return max(int(delta.total_seconds()), 1)
# Fallback for legacy activity types
if activity_type == "execution" and test.execution_date and test.created_at:
# Assign delta = test.execution_date - test.created_at
delta = test.execution_date - test.created_at
# Return max(int(delta.total_seconds()), 0)
return max(int(delta.total_seconds()), 0)
# Return 0
return 0