"""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