feat(phase-35): Jira + Tempo integration with internal worklogs
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

Full Jira/Tempo pipeline: link Aegis entities to Jira issues, auto-sync
status hourly, log time internally with integrity hashing, and optionally
push worklogs to Tempo.

- 1.1 JiraLink model + Worklog model: Alembic migration b020 with indexes,
  enums (jiralinkentitytype, jirasyncdirection), and integrity_hash column
- 1.2 Jira service: atlassian-python-api wrapper with lazy singleton client,
  search/create/sync operations, feature-flagged via JIRA_ENABLED
- 1.3 Jira router: CRUD endpoints for /jira/links, /jira/search,
  /jira/create-issue with audit logging and entity-to-issue auto-creation
- 1.4 Tempo service: worklog push via tempo-api-python-client, auto-log from
  test completions when TEMPO_ENABLED, graceful fallback on failure
- 1.5 Worklog service + router: immutable internal time records with SHA-256
  integrity hash, CRUD at /worklogs, /worklogs/{id}/verify endpoint
- 1.6 Frontend: JiraLinkPanel component (search, link, sync, unlink) and
  WorklogTimeline component (timeline view, manual log form) integrated into
  TestDetailPage sidebar, CampaignDetailPage grid, TechniqueDetailPage
- 1.7 Jira sync job: APScheduler hourly job syncs all links from Jira,
  registered in background scheduler alongside existing jobs
This commit is contained in:
2026-02-17 15:57:39 +01:00
parent 6d18a5417d
commit 9b98f60a9a
23 changed files with 1605 additions and 1 deletions

201
backend/app/routers/jira.py Normal file
View File

@@ -0,0 +1,201 @@
"""Jira integration router — link, search, sync, create issues."""
import logging
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.dependencies.auth import get_current_user, require_role
from app.domain.exceptions import EntityNotFoundError
from app.models.jira_link import JiraLink, JiraLinkEntityType
from app.models.test import Test
from app.models.technique import Technique
from app.models.campaign import Campaign
from app.models.user import User
from app.schemas.jira_schema import (
JiraIssueResult,
JiraLinkCreate,
JiraLinkOut,
)
from app.services import jira_service, audit_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/jira", tags=["jira"])
@router.get("/search", response_model=list[JiraIssueResult])
def search_issues(
q: str = Query(..., min_length=2),
max_results: int = Query(10, le=50),
user: User = Depends(get_current_user),
):
"""Search Jira issues by JQL or free text."""
return jira_service.search_jira_issues(q, max_results)
@router.post("/links", response_model=JiraLinkOut, status_code=201)
def create_link(
body: JiraLinkCreate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Associate an Aegis entity with a Jira issue."""
link = JiraLink(
entity_type=body.entity_type,
entity_id=body.entity_id,
jira_issue_key=body.jira_issue_key,
sync_direction=body.sync_direction,
created_by=user.id,
)
db.add(link)
db.flush()
# Pull initial data from Jira if enabled
if settings.JIRA_ENABLED:
try:
jira_service.sync_jira_to_aegis(db, link)
except Exception as e:
logger.warning("Initial Jira sync failed for %s: %s", body.jira_issue_key, e)
db.commit()
db.refresh(link)
audit_service.log_action(
db,
user_id=user.id,
action="jira_link_created",
entity_type="jira_link",
entity_id=str(link.id),
details={
"linked_entity_type": body.entity_type.value,
"linked_entity_id": str(body.entity_id),
"jira_issue_key": body.jira_issue_key,
},
)
return link
@router.get("/links", response_model=list[JiraLinkOut])
def list_links(
entity_type: Optional[JiraLinkEntityType] = None,
entity_id: Optional[UUID] = None,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""List Jira links, optionally filtered by entity."""
query = db.query(JiraLink)
if entity_type:
query = query.filter(JiraLink.entity_type == entity_type)
if entity_id:
query = query.filter(JiraLink.entity_id == entity_id)
return query.order_by(JiraLink.created_at.desc()).all()
@router.post("/links/{link_id}/sync")
def sync_link(
link_id: UUID,
db: Session = Depends(get_db),
user: User = Depends(require_role("admin")),
):
"""Force bidirectional sync for a specific Jira link."""
link = db.query(JiraLink).filter(JiraLink.id == link_id).first()
if not link:
raise EntityNotFoundError("JiraLink", str(link_id))
jira_service.sync_jira_to_aegis(db, link)
db.commit()
return {"message": "Sync completed", "jira_status": link.jira_status}
@router.delete("/links/{link_id}", status_code=204)
def delete_link(
link_id: UUID,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Remove a Jira link."""
link = db.query(JiraLink).filter(JiraLink.id == link_id).first()
if not link:
raise EntityNotFoundError("JiraLink", str(link_id))
db.delete(link)
db.commit()
audit_service.log_action(
db,
user_id=user.id,
action="jira_link_deleted",
entity_type="jira_link",
entity_id=str(link_id),
details={"jira_issue_key": link.jira_issue_key},
)
@router.post("/create-issue")
def create_issue_from_entity(
entity_type: JiraLinkEntityType,
entity_id: UUID,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Auto-create a Jira issue from an Aegis entity and link them."""
summary, description = _build_issue_data(db, entity_type, entity_id)
result = jira_service.create_jira_issue(
project_key=settings.JIRA_DEFAULT_PROJECT,
summary=summary,
description=description,
labels=["aegis", entity_type.value],
)
link = JiraLink(
entity_type=entity_type,
entity_id=entity_id,
jira_issue_key=result["issue_key"],
jira_issue_id=result["issue_id"],
jira_project_key=settings.JIRA_DEFAULT_PROJECT,
created_by=user.id,
)
db.add(link)
db.commit()
return {"issue_key": result["issue_key"], "link_id": str(link.id)}
def _build_issue_data(
db: Session,
entity_type: JiraLinkEntityType,
entity_id: UUID,
) -> tuple[str, str]:
"""Build Jira issue summary + description from an Aegis entity."""
if entity_type == JiraLinkEntityType.test:
entity = db.query(Test).filter(Test.id == entity_id).first()
if not entity:
raise EntityNotFoundError("Test", str(entity_id))
return (
f"[Aegis Test] {entity.name}",
f"Test: {entity.name}\n"
f"State: {entity.state.value if entity.state else 'draft'}\n"
f"Description: {entity.description or 'N/A'}",
)
elif entity_type == JiraLinkEntityType.campaign:
entity = db.query(Campaign).filter(Campaign.id == entity_id).first()
if not entity:
raise EntityNotFoundError("Campaign", str(entity_id))
return (
f"[Aegis Campaign] {entity.name}",
f"Campaign: {entity.name}\n"
f"Type: {entity.type}\nStatus: {entity.status}\n"
f"Description: {entity.description or 'N/A'}",
)
elif entity_type == JiraLinkEntityType.technique:
entity = db.query(Technique).filter(Technique.id == entity_id).first()
if not entity:
raise EntityNotFoundError("Technique", str(entity_id))
return (
f"[Aegis Technique] {entity.mitre_id} - {entity.name}",
f"MITRE ID: {entity.mitre_id}\nName: {entity.name}\n"
f"Tactic: {entity.tactic or 'N/A'}\n"
f"Description: {entity.description or 'N/A'}",
)
else:
return f"[Aegis] Entity {entity_id}", f"Entity type: {entity_type.value}"

View File

@@ -0,0 +1,119 @@
"""Worklog router — internal time-tracking records with integrity verification."""
from datetime import datetime
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user
from app.domain.exceptions import EntityNotFoundError
from app.models.user import User
from app.models.worklog import Worklog
from app.services import worklog_service
router = APIRouter(prefix="/worklogs", tags=["worklogs"])
# ── Schemas ──────────────────────────────────────────────────────────────
class WorklogCreate(BaseModel):
entity_type: str = Field(..., max_length=50)
entity_id: UUID
activity_type: str = Field(..., max_length=100)
started_at: datetime
ended_at: Optional[datetime] = None
duration_seconds: int = Field(..., gt=0)
description: Optional[str] = None
class WorklogOut(BaseModel):
id: UUID
entity_type: str
entity_id: UUID
user_id: UUID
activity_type: str
started_at: datetime
ended_at: Optional[datetime] = None
duration_seconds: int
description: Optional[str] = None
tempo_synced: Optional[datetime] = None
integrity_hash: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
# ── Endpoints ────────────────────────────────────────────────────────────
@router.post("", response_model=WorklogOut, status_code=201)
def create(
body: WorklogCreate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Create a manually-logged worklog entry."""
wl = worklog_service.create_worklog(
db,
entity_type=body.entity_type,
entity_id=body.entity_id,
user_id=user.id,
activity_type=body.activity_type,
started_at=body.started_at,
ended_at=body.ended_at,
duration_seconds=body.duration_seconds,
description=body.description,
)
return wl
@router.get("", response_model=list[WorklogOut])
def list_all(
entity_type: Optional[str] = None,
entity_id: Optional[UUID] = None,
user_id: Optional[UUID] = None,
db: Session = Depends(get_db),
_user: User = Depends(get_current_user),
):
"""List worklogs with optional filters."""
return worklog_service.list_worklogs(
db,
entity_type=entity_type,
entity_id=entity_id,
user_id=user_id,
)
@router.get("/{worklog_id}", response_model=WorklogOut)
def get_one(
worklog_id: UUID,
db: Session = Depends(get_db),
_user: User = Depends(get_current_user),
):
"""Get a single worklog by ID."""
wl = db.query(Worklog).filter(Worklog.id == worklog_id).first()
if not wl:
raise EntityNotFoundError("Worklog", str(worklog_id))
return wl
@router.get("/{worklog_id}/verify")
def verify_integrity(
worklog_id: UUID,
db: Session = Depends(get_db),
_user: User = Depends(get_current_user),
):
"""Check whether a worklog's integrity hash is still valid."""
wl = db.query(Worklog).filter(Worklog.id == worklog_id).first()
if not wl:
raise EntityNotFoundError("Worklog", str(worklog_id))
return {
"worklog_id": str(wl.id),
"integrity_valid": worklog_service.verify_worklog_integrity(wl),
}