feat(jira): admin account replaces per-user tokens for all Jira/Tempo ops
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled

- Single admin Jira account stored in system_configs (jira.admin_email, jira.admin_api_token)
- Admin Tempo token in system_configs (tempo.admin_token)
- get_admin_jira_client() + has_admin_jira_configured() replace per-user auth in all lifecycle hooks
- lookup_user_jira_account_id() auto-discovers each user's Atlassian accountId by email on login
- auto_log_test_worklog() now uses admin Tempo token and logs both red+blue team time
- Settings > Jira: admin credentials fields + test buttons moved to admin Jira tab
- Profile section simplified: jira_account_id shown read-only (auto-detected)
This commit is contained in:
kitos
2026-06-24 09:15:35 +02:00
parent bb8b9a6a72
commit 18695197e8
7 changed files with 530 additions and 521 deletions
+8
View File
@@ -172,6 +172,14 @@ def login(
# Call uow.commit()
uow.commit()
# Auto-discover the user's Jira account ID on every login (non-fatal)
try:
from app.services.jira_service import lookup_user_jira_account_id
if lookup_user_jira_account_id(db, user):
db.commit()
except Exception:
pass
# Call response.set_cookie()
response.set_cookie(
# Keyword argument: key
+3 -3
View File
@@ -237,14 +237,14 @@ def _attach_evidence_to_jira(
) -> None:
"""Attach uploaded evidence to the linked Jira ticket (non-fatal)."""
try:
from app.services.jira_service import get_test_jira_key, get_user_jira_client, has_jira_configured
if not has_jira_configured(actor, db):
from app.services.jira_service import get_test_jira_key, get_admin_jira_client, has_admin_jira_configured
if not has_admin_jira_configured(db):
return
issue_key = get_test_jira_key(db, test_id)
if not issue_key:
return
import io
jira = get_user_jira_client(actor, db)
jira = get_admin_jira_client(db)
buf = io.BytesIO(content)
buf.name = file_name # requests uses .name as the multipart filename
jira.add_attachment_object(issue_key, buf)
+54 -70
View File
@@ -254,8 +254,9 @@ class JiraConfigOut(BaseModel):
url: str
project_key: str
parent_ticket: str
parent_ticket_standalone: str # parent for tests not in a campaign
# Credentials are never returned
parent_ticket_standalone: str
admin_email_set: bool # whether admin email is configured (value never returned)
tempo_admin_token_set: bool # whether admin Tempo token is configured
class JiraConfigUpdate(BaseModel):
@@ -264,6 +265,9 @@ class JiraConfigUpdate(BaseModel):
project_key: Optional[str] = None
parent_ticket: Optional[str] = None
parent_ticket_standalone: Optional[str] = None
admin_email: Optional[str] = None
admin_api_token: Optional[str] = None
tempo_admin_token: Optional[str] = None
_JIRA_KEYS = {
@@ -272,6 +276,9 @@ _JIRA_KEYS = {
"project_key": "jira.project_key",
"parent_ticket": "jira.parent_ticket",
"parent_ticket_standalone": "jira.parent_ticket_standalone",
"admin_email": "jira.admin_email",
"admin_api_token": "jira.admin_api_token",
"tempo_admin_token": "tempo.admin_token",
}
@@ -282,11 +289,12 @@ def get_jira_config(
):
"""Return current Jira configuration (merged DB + env).
**Requires** the ``admin`` role. Credentials are never returned.
**Requires** the ``admin`` role. Credential values are never returned.
"""
from app.services.jira_service import (
get_jira_url, get_jira_project_key, is_jira_enabled,
get_jira_parent_ticket, get_jira_parent_ticket_standalone,
get_admin_jira_email, _read_system_config,
)
return JiraConfigOut(
@@ -295,6 +303,8 @@ def get_jira_config(
project_key=get_jira_project_key(db) or "",
parent_ticket=get_jira_parent_ticket(db) or "",
parent_ticket_standalone=get_jira_parent_ticket_standalone(db) or "",
admin_email_set=bool(get_admin_jira_email(db)),
tempo_admin_token_set=bool(_read_system_config(db, "tempo.admin_token")),
)
@@ -311,6 +321,7 @@ def update_jira_config(
from app.services.jira_service import (
upsert_jira_config, get_jira_url, get_jira_project_key, is_jira_enabled,
get_jira_parent_ticket, get_jira_parent_ticket_standalone,
get_admin_jira_email, _read_system_config,
)
update_data = payload.model_dump(exclude_unset=True)
@@ -326,6 +337,8 @@ def update_jira_config(
project_key=get_jira_project_key(db) or "",
parent_ticket=get_jira_parent_ticket(db) or "",
parent_ticket_standalone=get_jira_parent_ticket_standalone(db) or "",
admin_email_set=bool(get_admin_jira_email(db)),
tempo_admin_token_set=bool(_read_system_config(db, "tempo.admin_token")),
)
@@ -334,70 +347,67 @@ def test_jira_connection(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Test the Jira connection using the current user's credentials.
Requires the admin to have a personal Jira API token configured in their
profile settings.
"""Test the Jira connection using the admin account credentials.
Always returns HTTP 200 with a ``status`` field so Cloudflare never
replaces the response with its own error page.
"""
from app.services.jira_service import get_user_jira_client, get_jira_url, _effective_jira_email
from app.services.jira_service import (
get_admin_jira_client, get_jira_url, has_admin_jira_configured,
get_admin_jira_email,
)
jira_url = get_jira_url(db)
if not jira_url:
return {"status": "error", "message": "Jira URL is not configured. Set it in System Settings → Jira Configuration.", "jira_url": ""}
return {"status": "error", "message": "Jira URL is not configured.", "jira_url": ""}
auth_email = _effective_jira_email(current_user)
if not has_admin_jira_configured(db):
return {
"status": "error",
"message": (
"Admin Jira credentials not configured. "
"Set the Admin Email and Admin API Token in the fields above."
),
"jira_url": jira_url,
}
admin_email = get_admin_jira_email(db)
try:
jira = get_user_jira_client(current_user, db)
# 10-second timeout so we never block Cloudflare into a 524
jira = get_admin_jira_client(db)
try:
jira._session.timeout = 10 # type: ignore[attr-defined]
except Exception: # nosec B110
pass
myself = jira.myself()
logger.info("Jira myself() response keys: %s", list(myself.keys()) if isinstance(myself, dict) else type(myself))
# Use displayName → emailAddress → name → the auth email as fallback
connected_as = (
(myself.get("displayName") if isinstance(myself, dict) else None)
or (myself.get("emailAddress") if isinstance(myself, dict) else None)
or (myself.get("name") if isinstance(myself, dict) else None)
or auth_email
or admin_email
or "authenticated"
)
return {
"status": "ok",
"connected_as": connected_as,
"jira_url": jira_url,
}
return {"status": "ok", "connected_as": connected_as, "jira_url": jira_url}
except Exception as exc:
err = str(exc)
# Always return HTTP 200 with status="error" so Cloudflare never
# intercepts the response and the frontend always sees our message.
if "Expecting value" in err or "line 1 column 1" in err:
msg = (
"Jira returned a non-JSON response. "
"Verify the URL (e.g. https://company.atlassian.net), "
"email and API token."
"admin email and API token."
)
elif "401" in err or "Unauthorized" in err:
msg = (
"Authentication failed (401). "
f"Check that the Atlassian email ({auth_email or 'not set'}) "
"and API token are correct. The token must be an Atlassian API token "
"(not your account password)."
f"Check that the admin email ({admin_email or 'not set'}) "
"and API token are correct."
)
elif "403" in err or "Forbidden" in err:
msg = "Access denied (403). The token may not have permission for this Jira project."
msg = "Access denied (403). The admin token may not have permission for this Jira project."
elif "timed out" in err.lower() or "timeout" in err.lower():
msg = "Connection timed out. Check that the Jira URL is reachable from the server."
elif "not configured" in err.lower():
msg = err
else:
msg = f"Jira connection failed: {err}"
logger.warning("Jira test connection failed: %s", err)
logger.warning("Admin Jira test connection failed: %s", err)
return {"status": "error", "message": msg, "jira_url": jira_url}
@@ -409,49 +419,32 @@ def test_jira_connection(
@router.post("/tempo-test")
def test_tempo_connection(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current_user: User = Depends(require_role("admin")),
):
"""Test the current user's personal Tempo connection.
"""Test the admin Tempo token connectivity.
Uses the Tempo API token stored in the user's profile (not a global token).
Always returns HTTP 200 with a ``status`` field so Cloudflare never
intercepts the response.
"""
from app.services.tempo_service import get_admin_tempo_token, get_admin_tempo_client
tempo_token = getattr(current_user, "tempo_api_token", None)
if not tempo_token:
admin_token = get_admin_tempo_token(db)
if not admin_token:
return {
"status": "error",
"message": (
"No Tempo API token configured. "
"Add it in Settings → Profile → Tempo Integration."
),
}
jira_account_id = getattr(current_user, "jira_account_id", None)
if not jira_account_id:
return {
"status": "error",
"message": (
"No Jira Account ID configured. "
"Set it in Settings → Profile → Jira Integration → Account ID."
"Admin Tempo token not configured. "
"Set it in System Settings → Jira Configuration → Admin Tempo Token."
),
}
try:
from tempoapiclient import client_v4 as tempo_client
tempo = tempo_client.Tempo(auth_token=tempo_token)
# search_worklogs by authorId is the correct v4 method; use a tight
# date range so we fetch almost nothing but still verify connectivity.
worklogs = tempo.search_worklogs(
dateFrom="2024-01-01",
dateTo="2024-01-02",
authorIds=[jira_account_id],
)
tempo = get_admin_tempo_client(db)
worklogs = tempo.search_worklogs(dateFrom="2024-01-01", dateTo="2024-01-02")
count = len(worklogs) if isinstance(worklogs, list) else "n/a"
return {
"status": "ok",
"message": f"Tempo connected successfully. Account ID: {jira_account_id}",
"message": "Admin Tempo token connected successfully.",
"worklogs_found": count,
}
except Exception as exc:
@@ -459,23 +452,14 @@ def test_tempo_connection(
if "401" in err or "Unauthorized" in err:
msg = (
"Authentication failed (401). "
"Check your Tempo API token — obtain it at "
"Check the admin Tempo API token — obtain it at "
"Jira → Apps → Tempo → Settings → API Integration."
)
elif "403" in err or "Forbidden" in err:
msg = "Access denied (403). The Tempo token lacks the required permissions."
elif "404" in err or "not found" in err.lower():
msg = (
"Account ID not found (404). "
f"The value '{jira_account_id}' may be wrong — see the instructions "
"below to find your correct Atlassian Account ID."
)
msg = "Access denied (403). The admin Tempo token lacks the required permissions."
else:
msg = f"Tempo connection failed: {err}"
logger.warning(
"Tempo test connection failed for user %s (account_id=%s): %s",
current_user.username, jira_account_id, err,
)
logger.warning("Admin Tempo test connection failed: %s", err)
return {"status": "error", "message": msg}
+109 -9
View File
@@ -188,10 +188,110 @@ def get_user_jira_client(user: User, db: Session):
def has_jira_configured(user: User, db: Session) -> bool:
"""Return True if *user* has everything needed to call Jira."""
"""Return True if *user* has everything needed to call Jira (legacy per-user check)."""
return bool(get_jira_url(db) and _effective_jira_email(user) and user.jira_api_token)
# ---------------------------------------------------------------------------
# Admin Jira client (single account for all lifecycle hooks)
# ---------------------------------------------------------------------------
def get_admin_jira_email(db: Session) -> Optional[str]:
"""Return the admin Jira email from system_configs."""
return _read_system_config(db, "jira.admin_email") or None
def get_admin_jira_api_token(db: Session) -> Optional[str]:
"""Return the admin Jira API token from system_configs."""
return _read_system_config(db, "jira.admin_api_token") or None
def has_admin_jira_configured(db: Session) -> bool:
"""Return True if the global Jira admin account is fully configured."""
return bool(
is_jira_enabled(db)
and get_jira_url(db)
and get_admin_jira_email(db)
and get_admin_jira_api_token(db)
)
def get_admin_jira_client(db: Session):
"""Return a Jira client authenticated with the global admin credentials.
All Aegis-to-Jira operations (issue creation, comments, transitions)
go through this single account. Users do not need personal Jira tokens.
"""
jira_url = get_jira_url(db)
if not jira_url:
raise InvalidOperationError("Jira URL is not configured.")
admin_email = get_admin_jira_email(db)
admin_token = get_admin_jira_api_token(db)
if not admin_email or not admin_token:
raise InvalidOperationError(
"Admin Jira credentials not configured. "
"Set them in System Settings → Jira Configuration → Admin Account."
)
from atlassian import Jira
return Jira(
url=jira_url.rstrip("/"),
username=admin_email,
password=admin_token,
cloud=True,
)
def lookup_user_jira_account_id(db: Session, user: User) -> bool:
"""Lookup *user*'s Atlassian account ID by email using the admin Jira client.
Updates ``user.jira_account_id`` in-place when found or changed.
Returns ``True`` when the value was updated, ``False`` otherwise.
Non-fatal — all errors are logged at DEBUG level and swallowed.
"""
if not has_admin_jira_configured(db):
return False
email = getattr(user, "email", None)
if not email:
return False
try:
jira = get_admin_jira_client(db)
results = jira.user_find_by_user_string(query=email, maxResults=10)
account_id: Optional[str] = None
for u in results or []:
if isinstance(u, dict) and u.get("emailAddress", "").lower() == email.lower():
account_id = u.get("accountId")
break
if not account_id:
logger.debug("No Jira user found for email %s", email)
return False
current = getattr(user, "jira_account_id", None)
if account_id != current:
user.jira_account_id = account_id
db.flush()
logger.info(
"Auto-updated jira_account_id for %s: %s", user.username, account_id
)
return True
return False
except Exception as exc:
logger.debug(
"Could not lookup Jira account_id for %s: %s",
getattr(user, "username", "?"),
exc,
)
return False
# ---------------------------------------------------------------------------
# Ticket content builders (inspired by the pentest-to-Jira script)
# ---------------------------------------------------------------------------
@@ -403,7 +503,7 @@ def auto_create_campaign_issue(
Called once right after a campaign is committed to the database.
The created ticket is stored as a JiraLink with entity_type=campaign.
"""
if not has_jira_configured(actor, db):
if not has_admin_jira_configured(db):
return None
project_key = get_jira_project_key(db)
@@ -417,7 +517,7 @@ def auto_create_campaign_issue(
parent_ticket = get_jira_parent_ticket(db)
try:
jira = get_user_jira_client(actor, db)
jira = get_admin_jira_client(db)
fields: dict = {
"project": {"key": project_key},
@@ -487,7 +587,7 @@ def auto_create_test_issue(
instead of the system-configured parent (e.g. OFS-9107).
Use this to nest test tickets under a campaign ticket.
"""
if not has_jira_configured(actor, db):
if not has_admin_jira_configured(db):
return None
project_key = get_jira_project_key(db)
@@ -502,7 +602,7 @@ def auto_create_test_issue(
mitre_id = technique.mitre_id if technique else "N/A"
try:
jira = get_user_jira_client(actor, db)
jira = get_admin_jira_client(db)
# All tests — whether inside a campaign or standalone — are created
# as Task. Campaign tests use the campaign Jira key as parent
@@ -574,7 +674,7 @@ def push_test_event(
Completely non-fatal — any Jira error is logged and swallowed so it
never blocks the test workflow.
"""
if not has_jira_configured(actor, db):
if not has_admin_jira_configured(db):
return
link = (
@@ -589,7 +689,7 @@ def push_test_event(
return
try:
jira = get_user_jira_client(actor, db)
jira = get_admin_jira_client(db)
comment = _build_state_comment(test, new_state, actor, extra)
jira.issue_add_comment(link.jira_issue_key, comment)
@@ -650,7 +750,7 @@ def push_hold_event(
Non-fatal — any Jira error is logged and swallowed.
"""
if not has_jira_configured(actor, db):
if not has_admin_jira_configured(db):
return
link = (
@@ -665,7 +765,7 @@ def push_hold_event(
return
try:
jira = get_user_jira_client(actor, db)
jira = get_admin_jira_client(db)
ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
if resuming:
+108 -85
View File
@@ -45,17 +45,50 @@ from app.models.user import User
# Assign logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Only red team execution time goes to Tempo.
# Blue team evaluation time is tracked internally (worklogs table) for SLA
# purposes but is NOT forwarded to Tempo — blue team has no Jira access.
_TEMPO_ACTIVITY_TYPES = {"red_team_execution"}
def has_tempo_configured(user) -> bool:
"""Return True if *user* has a personal Tempo API token stored."""
"""Return True if *user* has a personal Tempo API token stored (legacy)."""
return bool(getattr(user, "tempo_api_token", None))
def get_admin_tempo_token(db=None) -> Optional[str]:
"""Return the admin Tempo API token from system_configs or env-var fallback."""
if db is not None:
try:
from app.models.system_config import SystemConfig
row = db.query(SystemConfig).filter(
SystemConfig.key == "tempo.admin_token"
).first()
if row and row.value:
return row.value
except Exception: # nosec B110
pass
return getattr(settings, "TEMPO_API_TOKEN", None) or None
def has_admin_tempo_configured(db=None) -> bool:
"""Return True if the admin Tempo token is configured."""
return bool(get_admin_tempo_token(db))
def get_admin_tempo_client(db=None):
"""Return a Tempo API v4 client authenticated with the admin token."""
token = get_admin_tempo_token(db)
if not token:
raise InvalidOperationError(
"Admin Tempo token not configured. "
"Set it in System Settings → Jira Configuration."
)
try:
from tempoapiclient import client_v4 as tempo_client
base_url = _get_tempo_base_url(db)
return tempo_client.Tempo(auth_token=token, base_url=base_url)
except ImportError:
raise InvalidOperationError(
"tempo-api-python-client is not installed. "
"Run: pip install tempo-api-python-client"
)
_TEMPO_DEFAULT_BASE_URL = "https://api.tempo.io/4"
_TEMPO_EU_BASE_URL = "https://api.eu.tempo.io/4"
@@ -166,93 +199,56 @@ def get_tempo_client():
)
# 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,
duration_seconds: Optional[int] = None,
) -> Optional[dict]:
"""Log time to Tempo for the given test if conditions are met.
``duration_seconds``, when provided, is used as-is so the Tempo entry
matches the Aegis worklog exactly. When omitted, the duration is computed
from the test's phase start timestamp to ``updated_at`` (or now).
Uses the admin Tempo token (preferred) so regular users need no personal
token. Falls back to the user's personal token when no admin token is set.
Both ``red_team_execution`` and ``blue_team_evaluation`` are logged.
Only ``red_team_execution`` activities are forwarded to Tempo.
``blue_team_evaluation`` is tracked internally but not sent.
``duration_seconds``, when provided, is used as-is. When omitted, the
duration is computed from the test's phase start timestamp.
Returns the Tempo worklog response dict, or ``None`` if skipped.
Completely non-fatal — errors are logged and swallowed.
"""
# Only whitelisted activity types go to Tempo
if activity_type not in _TEMPO_ACTIVITY_TYPES:
logger.debug(
"Skipping Tempo sync for activity_type=%s (not in whitelist)", activity_type
)
# Only recognised activity types
if activity_type not in ("red_team_execution", "blue_team_evaluation"):
logger.debug("Skipping Tempo sync for activity_type=%s", activity_type)
return None
# Global kill-switch
if not settings.TEMPO_ENABLED:
# Return None
return None
# Compute duration from test timestamps when not supplied by the caller
if duration_seconds is None:
from datetime import datetime as _dt
started = getattr(test, "red_started_at", None)
if activity_type == "blue_team_evaluation":
started = getattr(test, "blue_work_started_at", None) or getattr(test, "blue_started_at", None)
else:
started = getattr(test, "red_started_at", None)
if started is None:
logger.debug("No red_started_at on test %s; skipping Tempo worklog", test.id)
logger.debug("No start timestamp on test %s for %s; skipping Tempo worklog", test.id, activity_type)
return None
ended = getattr(test, "updated_at", None) or _dt.utcnow()
duration_seconds = max(int((ended - started).total_seconds()), 0)
if duration_seconds <= 0:
logger.debug(
"Skipping Tempo sync for test %s: duration=%ds", test.id, duration_seconds
)
logger.debug("Skipping Tempo sync for test %s: duration=%ds", test.id, duration_seconds)
return None
# Tempo requires whole minutes. Always round UP to the nearest minute
# and enforce a minimum of 60 seconds (1 minute).
# 2 seconds → 60 s (1 min, minimum)
# 3 min 20 s (200s) → 240 s (4 min, ceiling)
# 5 min 0 s (300s) → 300 s (5 min, exact)
# Round UP to the nearest whole minute; enforce ≥ 60 s
import math
duration_seconds = max(60, math.ceil(duration_seconds / 60) * 60)
# Per-user token required
if not has_tempo_configured(user):
logger.debug(
"User %s has no Tempo token; skipping worklog for test %s",
getattr(user, "username", user), test.id,
)
return None
# Need a Jira link with a numeric issue ID
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
# user.jira_account_id is required for Tempo worklog attribution
jira_account_id = (getattr(user, "jira_account_id", "") or "").strip()
if not jira_account_id:
logger.debug(
@@ -261,37 +257,64 @@ def auto_log_test_worklog(
)
return None
# Attempt the following; catch errors below
try:
# Use the phase start timestamp as the worklog date so it matches when
# work actually happened (not the submission timestamp).
if activity_type == "blue_team_evaluation":
work_date = (
(test.blue_work_started_at or test.blue_started_at or test.created_at)
.strftime("%Y-%m-%d")
)
description = f"[Aegis] Blue Team evaluation: {test.name}"
else:
work_date = (
(test.red_started_at or getattr(test, "updated_at", None) or test.created_at)
.strftime("%Y-%m-%d")
)
description = f"[Aegis] Red Team execution: {test.name}"
result = log_worklog(
user=user,
jira_issue_id=int(link.jira_issue_id),
author_account_id=jira_account_id,
date=work_date,
time_spent_seconds=duration_seconds,
description=description,
db=db,
# Need a Jira link with a numeric issue ID
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
# Use the phase start timestamp for the worklog date
if activity_type == "blue_team_evaluation":
work_date = (
(test.blue_work_started_at or test.blue_started_at or test.created_at)
.strftime("%Y-%m-%d")
)
description = f"[Aegis] Blue Team evaluation: {test.name}"
else:
work_date = (
(test.red_started_at or getattr(test, "updated_at", None) or test.created_at)
.strftime("%Y-%m-%d")
)
description = f"[Aegis] Red Team execution: {test.name}"
try:
# Admin token preferred; fall back to user's personal token for legacy setups
admin_token = get_admin_tempo_token(db)
if admin_token:
from tempoapiclient import client_v4 as tempo_client
base_url = _get_tempo_base_url(db)
tempo = tempo_client.Tempo(auth_token=admin_token, base_url=base_url)
elif has_tempo_configured(user):
tempo = get_user_tempo_client(user, db=db)
else:
logger.debug(
"No Tempo credentials available; skipping worklog for test %s", test.id
)
return None
try:
result = tempo.create_worklog(
accountId=jira_account_id,
issueId=int(link.jira_issue_id),
dateFrom=work_date,
timeSpentSeconds=duration_seconds,
description=description,
)
except BaseException as exc:
raise RuntimeError(f"Tempo API error: {exc}") from exc
logger.info(
"Tempo worklog created for test %s by user %s: %ds on %s",
test.id, getattr(user, "username", user), duration_seconds, work_date,
)
return result
# Handle Exception
except Exception as e:
logger.warning(
"Tempo worklog failed for test %s (user %s): %s",