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

View File

@@ -0,0 +1,93 @@
"""add_jira_links_and_worklogs
Revision ID: b020jiraworklogs
Revises: b019composite
Create Date: 2026-02-17 16:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "b020jiraworklogs"
down_revision: Union[str, None] = "b019composite"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ── Enums ────────────────────────────────────────────────────────
jira_link_entity_type = sa.Enum(
"test", "technique", "campaign", "evidence",
name="jiralinkentitytype",
)
jira_sync_direction = sa.Enum(
"aegis_to_jira", "jira_to_aegis", "bidirectional",
name="jirasyncdirection",
)
jira_link_entity_type.create(op.get_bind(), checkfirst=True)
jira_sync_direction.create(op.get_bind(), checkfirst=True)
# ── jira_links table ─────────────────────────────────────────────
op.create_table(
"jira_links",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("entity_type", jira_link_entity_type, nullable=False),
sa.Column("entity_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("jira_issue_key", sa.String(50), nullable=False),
sa.Column("jira_issue_id", sa.String(50)),
sa.Column("jira_project_key", sa.String(20)),
sa.Column("jira_status", sa.String(100)),
sa.Column("jira_priority", sa.String(50)),
sa.Column("jira_assignee", sa.String(255)),
sa.Column("jira_story_points", sa.String(10)),
sa.Column("sync_direction", jira_sync_direction, server_default="bidirectional"),
sa.Column("last_synced_at", sa.DateTime),
sa.Column("sync_metadata", postgresql.JSONB, server_default="{}"),
sa.Column("created_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id")),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
)
op.create_index("ix_jira_links_entity_id", "jira_links", ["entity_id"])
op.create_index("ix_jira_links_issue_key", "jira_links", ["jira_issue_key"])
op.create_index(
"ix_jira_links_entity_type_entity_id",
"jira_links",
["entity_type", "entity_id"],
)
# ── worklogs table ───────────────────────────────────────────────
op.create_table(
"worklogs",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("entity_type", sa.String(50), nullable=False),
sa.Column("entity_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False),
sa.Column("activity_type", sa.String(100), nullable=False),
sa.Column("started_at", sa.DateTime, nullable=False),
sa.Column("ended_at", sa.DateTime),
sa.Column("duration_seconds", sa.Integer, nullable=False),
sa.Column("description", sa.Text),
sa.Column("tempo_synced", sa.DateTime),
sa.Column("tempo_worklog_id", sa.String(100)),
sa.Column("integrity_hash", sa.String(64)),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
sa.Column("metadata", postgresql.JSONB, server_default="{}"),
)
op.create_index("ix_worklogs_entity_id", "worklogs", ["entity_id"])
op.create_index("ix_worklogs_user_id", "worklogs", ["user_id"])
op.create_index(
"ix_worklogs_entity_type_entity_id",
"worklogs",
["entity_type", "entity_id"],
)
def downgrade() -> None:
op.drop_table("worklogs")
op.drop_table("jira_links")
sa.Enum(name="jirasyncdirection").drop(op.get_bind(), checkfirst=True)
sa.Enum(name="jiralinkentitytype").drop(op.get_bind(), checkfirst=True)

View File

@@ -43,6 +43,21 @@ class Settings(BaseSettings):
# ── Re-testing ─────────────────────────────────────────────────── # ── Re-testing ───────────────────────────────────────────────────
MAX_RETEST_COUNT: int = 3 # maximum automatic retests per original test MAX_RETEST_COUNT: int = 3 # maximum automatic retests per original test
# ── Jira Integration ────────────────────────────────────────────
JIRA_ENABLED: bool = False
JIRA_URL: str = ""
JIRA_USERNAME: str = ""
JIRA_API_TOKEN: str = ""
JIRA_IS_CLOUD: bool = True
JIRA_DEFAULT_PROJECT: str = ""
JIRA_ISSUE_TYPE_TEST: str = "Task"
JIRA_ISSUE_TYPE_CAMPAIGN: str = "Epic"
# ── Tempo Integration ─────────────────────────────────────────────
TEMPO_ENABLED: bool = False
TEMPO_API_TOKEN: str = ""
TEMPO_DEFAULT_WORK_TYPE: str = "Red Team"
# ── Scoring weights (must sum to 100) ──────────────────────────── # ── Scoring weights (must sum to 100) ────────────────────────────
SCORING_WEIGHT_TESTS: int = 40 SCORING_WEIGHT_TESTS: int = 40
SCORING_WEIGHT_DETECTION_RULES: int = 20 SCORING_WEIGHT_DETECTION_RULES: int = 20

View File

@@ -0,0 +1,37 @@
"""Scheduled job — syncs all Jira links hourly."""
import logging
from app.config import settings
from app.database import SessionLocal
from app.models.jira_link import JiraLink
from app.services import jira_service
logger = logging.getLogger(__name__)
def sync_all_jira_links() -> None:
"""Pull latest status from Jira for every stored link.
Silently skips if ``JIRA_ENABLED`` is ``False``. Individual link
failures are logged but do not abort the rest of the batch.
"""
if not settings.JIRA_ENABLED:
return
db = SessionLocal()
try:
links = db.query(JiraLink).all()
synced = 0
for link in links:
try:
jira_service.sync_jira_to_aegis(db, link)
synced += 1
except Exception as e:
logger.warning("Jira sync failed for link %s: %s", link.id, e)
db.commit()
logger.info("Jira sync completed: %d/%d links updated", synced, len(links))
except Exception:
logger.exception("Jira sync batch job failed")
finally:
db.close()

View File

@@ -20,6 +20,7 @@ from app.services.intel_service import scan_intel
from app.services.notification_service import cleanup_old_notifications from app.services.notification_service import cleanup_old_notifications
from app.services.snapshot_service import create_snapshot, cleanup_old_snapshots from app.services.snapshot_service import create_snapshot, cleanup_old_snapshots
from app.services.campaign_scheduler_service import check_and_run_recurring_campaigns from app.services.campaign_scheduler_service import check_and_run_recurring_campaigns
from app.jobs.jira_sync_job import sync_all_jira_links
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -164,9 +165,17 @@ def start_scheduler() -> None:
name="Recurring campaigns check (daily)", name="Recurring campaigns check (daily)",
replace_existing=True, replace_existing=True,
) )
scheduler.add_job(
sync_all_jira_links,
trigger="interval",
hours=1,
id="jira_sync",
name="Jira link sync (hourly)",
replace_existing=True,
)
scheduler.start() scheduler.start()
logger.info( logger.info(
"Background scheduler started — mitre_sync (24h), intel_scan (7d), " "Background scheduler started — mitre_sync (24h), intel_scan (7d), "
"notification_cleanup (24h), weekly_snapshot (Sundays 00:00), " "notification_cleanup (24h), weekly_snapshot (Sundays 00:00), "
"recurring_campaigns (daily)" "recurring_campaigns (daily), jira_sync (1h)"
) )

View File

@@ -32,6 +32,8 @@ from app.routers import scores as scores_router
from app.routers import operational_metrics as operational_metrics_router from app.routers import operational_metrics as operational_metrics_router
from app.routers import compliance as compliance_router from app.routers import compliance as compliance_router
from app.routers import snapshots as snapshots_router from app.routers import snapshots as snapshots_router
from app.routers import jira as jira_router
from app.routers import worklogs as worklogs_router
from app.domain.exceptions import DomainException from app.domain.exceptions import DomainException
from app.middleware.error_handler import domain_exception_handler from app.middleware.error_handler import domain_exception_handler
from app.storage import ensure_bucket_exists from app.storage import ensure_bucket_exists
@@ -110,6 +112,8 @@ app.include_router(scores_router.router, prefix="/api/v1")
app.include_router(operational_metrics_router.router, prefix="/api/v1") app.include_router(operational_metrics_router.router, prefix="/api/v1")
app.include_router(compliance_router.router, prefix="/api/v1") app.include_router(compliance_router.router, prefix="/api/v1")
app.include_router(snapshots_router.router, prefix="/api/v1") app.include_router(snapshots_router.router, prefix="/api/v1")
app.include_router(jira_router.router, prefix="/api/v1")
app.include_router(worklogs_router.router, prefix="/api/v1")
@app.get("/health", include_in_schema=False) @app.get("/health", include_in_schema=False)

View File

@@ -16,6 +16,8 @@ from app.models.test_detection_result import TestDetectionResult
from app.models.campaign import Campaign, CampaignTest from app.models.campaign import Campaign, CampaignTest
from app.models.compliance import ComplianceFramework, ComplianceControl, ComplianceControlMapping from app.models.compliance import ComplianceFramework, ComplianceControl, ComplianceControlMapping
from app.models.coverage_snapshot import CoverageSnapshot, SnapshotTechniqueState from app.models.coverage_snapshot import CoverageSnapshot, SnapshotTechniqueState
from app.models.jira_link import JiraLink, JiraLinkEntityType, JiraSyncDirection
from app.models.worklog import Worklog
from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide
__all__ = [ __all__ = [
@@ -27,5 +29,7 @@ __all__ = [
"Campaign", "CampaignTest", "Campaign", "CampaignTest",
"ComplianceFramework", "ComplianceControl", "ComplianceControlMapping", "ComplianceFramework", "ComplianceControl", "ComplianceControlMapping",
"CoverageSnapshot", "SnapshotTechniqueState", "CoverageSnapshot", "SnapshotTechniqueState",
"JiraLink", "JiraLinkEntityType", "JiraSyncDirection",
"Worklog",
"TechniqueStatus", "TestState", "TestResult", "TeamSide", "TechniqueStatus", "TestState", "TestResult", "TeamSide",
] ]

View File

@@ -0,0 +1,57 @@
"""Jira integration models — link Aegis entities to Jira issues."""
import enum
import uuid
from datetime import datetime
from sqlalchemy import Column, String, DateTime, ForeignKey, Enum as SQLEnum, Index
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.database import Base
class JiraLinkEntityType(str, enum.Enum):
test = "test"
technique = "technique"
campaign = "campaign"
evidence = "evidence"
class JiraSyncDirection(str, enum.Enum):
aegis_to_jira = "aegis_to_jira"
jira_to_aegis = "jira_to_aegis"
bidirectional = "bidirectional"
class JiraLink(Base):
"""Associates an Aegis entity with a Jira issue for bidirectional sync."""
__tablename__ = "jira_links"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
entity_type = Column(SQLEnum(JiraLinkEntityType), nullable=False)
entity_id = Column(UUID(as_uuid=True), nullable=False)
jira_issue_key = Column(String(50), nullable=False)
jira_issue_id = Column(String(50))
jira_project_key = Column(String(20))
jira_status = Column(String(100))
jira_priority = Column(String(50))
jira_assignee = Column(String(255))
jira_story_points = Column(String(10))
sync_direction = Column(
SQLEnum(JiraSyncDirection), default=JiraSyncDirection.bidirectional
)
last_synced_at = Column(DateTime)
sync_metadata = Column(JSONB, default={})
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"))
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
creator = relationship("User", foreign_keys=[created_by])
__table_args__ = (
Index("ix_jira_links_entity_id", "entity_id"),
Index("ix_jira_links_issue_key", "jira_issue_key"),
Index("ix_jira_links_entity_type_entity_id", "entity_type", "entity_id"),
)

View File

@@ -0,0 +1,44 @@
"""Worklog model — immutable internal time-tracking records."""
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Text, Index
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.database import Base
class Worklog(Base):
"""Internal worklog entry with integrity hash for audit compliance.
Each worklog is tied to an Aegis entity (test, campaign, etc.) and
optionally synced to Tempo. The ``integrity_hash`` is a SHA-256 of
the immutable fields so tampering can be detected.
"""
__tablename__ = "worklogs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
entity_type = Column(String(50), nullable=False)
entity_id = Column(UUID(as_uuid=True), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
activity_type = Column(String(100), nullable=False)
started_at = Column(DateTime, nullable=False)
ended_at = Column(DateTime)
duration_seconds = Column(Integer, nullable=False)
description = Column(Text)
tempo_synced = Column(DateTime)
tempo_worklog_id = Column(String(100))
integrity_hash = Column(String(64))
created_at = Column(DateTime, default=datetime.utcnow)
extra_metadata = Column("metadata", JSONB, default={})
user = relationship("User", foreign_keys=[user_id])
__table_args__ = (
Index("ix_worklogs_entity_id", "entity_id"),
Index("ix_worklogs_user_id", "user_id"),
Index("ix_worklogs_entity_type_entity_id", "entity_type", "entity_id"),
)

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),
}

View File

@@ -0,0 +1,46 @@
"""Pydantic schemas for Jira integration endpoints."""
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
from app.models.jira_link import JiraLinkEntityType, JiraSyncDirection
class JiraLinkCreate(BaseModel):
entity_type: JiraLinkEntityType
entity_id: UUID
jira_issue_key: str = Field(..., pattern=r"^[A-Z][A-Z0-9]+-\d+$")
sync_direction: JiraSyncDirection = JiraSyncDirection.bidirectional
class JiraLinkOut(BaseModel):
id: UUID
entity_type: JiraLinkEntityType
entity_id: UUID
jira_issue_key: str
jira_issue_id: Optional[str] = None
jira_project_key: Optional[str] = None
jira_status: Optional[str] = None
jira_priority: Optional[str] = None
jira_assignee: Optional[str] = None
jira_story_points: Optional[str] = None
last_synced_at: Optional[datetime] = None
created_at: datetime
class Config:
from_attributes = True
class JiraIssueSearch(BaseModel):
query: str
class JiraIssueResult(BaseModel):
issue_key: str
summary: str
status: str
assignee: Optional[str] = None
priority: Optional[str] = None

View File

@@ -0,0 +1,105 @@
"""Jira integration service — wraps atlassian-python-api for Jira REST calls."""
import logging
from datetime import datetime
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__)
_jira_client = None
def get_jira_client():
"""Return a lazily-initialised Jira client, or raise if disabled."""
global _jira_client
if not settings.JIRA_ENABLED:
raise InvalidOperationError("Jira integration is not enabled")
if _jira_client is None:
from atlassian import Jira
_jira_client = Jira(
url=settings.JIRA_URL,
username=settings.JIRA_USERNAME,
password=settings.JIRA_API_TOKEN,
cloud=settings.JIRA_IS_CLOUD,
)
return _jira_client
def search_jira_issues(query: str, max_results: int = 10) -> list[dict]:
"""Search Jira issues by JQL or free text."""
jira = get_jira_client()
jql = query if "=" in query or "~" in query else f'summary ~ "{query}"'
results = jira.jql(jql, limit=max_results)
return [
{
"issue_key": issue["key"],
"summary": issue["fields"]["summary"],
"status": issue["fields"]["status"]["name"],
"assignee": (issue["fields"].get("assignee") or {}).get("displayName"),
"priority": (issue["fields"].get("priority") or {}).get("name"),
}
for issue in results.get("issues", [])
]
def create_jira_issue(
project_key: str,
summary: str,
description: str,
issue_type: str = "Task",
labels: Optional[list[str]] = None,
custom_fields: Optional[dict] = None,
) -> dict:
"""Create a Jira issue and return its key + id."""
jira = get_jira_client()
fields: dict = {
"project": {"key": project_key},
"summary": summary,
"description": description,
"issuetype": {"name": issue_type},
}
if labels:
fields["labels"] = labels
if custom_fields:
fields.update(custom_fields)
result = jira.issue_create(fields=fields)
return {"issue_key": result["key"], "issue_id": result["id"]}
def sync_jira_to_aegis(db: Session, link: JiraLink) -> None:
"""Pull current status from Jira into the local link record."""
jira = get_jira_client()
issue = jira.issue(link.jira_issue_key)
fields = issue.get("fields", {})
link.jira_status = fields.get("status", {}).get("name")
link.jira_priority = (fields.get("priority") or {}).get("name")
link.jira_assignee = (fields.get("assignee") or {}).get("displayName")
link.jira_story_points = str(fields.get("customfield_10016", ""))
link.last_synced_at = datetime.utcnow()
db.flush()
def sync_aegis_to_jira(db: Session, link: JiraLink, entity_data: dict) -> None:
"""Push an Aegis status update as a Jira comment."""
jira = get_jira_client()
comment_body = _build_sync_comment(entity_data)
jira.issue_add_comment(link.jira_issue_key, comment_body)
link.last_synced_at = datetime.utcnow()
db.flush()
def _build_sync_comment(data: dict) -> str:
"""Build a formatted Jira comment from entity data."""
lines = ["h3. Aegis Sync Update", ""]
for key, value in data.items():
lines.append(f"*{key}:* {value}")
lines.append(f"\n_Synced at {datetime.utcnow().isoformat()}_")
return "\n".join(lines)

View File

@@ -0,0 +1,96 @@
"""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:
"""Estimate duration in seconds based on test timestamps and activity type."""
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 3600 # default 1 hour if no timestamps available

View File

@@ -0,0 +1,75 @@
"""Internal worklog service — CRUD with integrity hashing."""
import hashlib
import logging
from datetime import datetime
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session
from app.models.worklog import Worklog
logger = logging.getLogger(__name__)
def create_worklog(
db: Session,
*,
entity_type: str,
entity_id: UUID,
user_id: UUID,
activity_type: str,
started_at: datetime,
duration_seconds: int,
ended_at: Optional[datetime] = None,
description: Optional[str] = None,
) -> Worklog:
"""Create a worklog with an auto-computed integrity hash."""
wl = Worklog(
entity_type=entity_type,
entity_id=entity_id,
user_id=user_id,
activity_type=activity_type,
started_at=started_at,
ended_at=ended_at,
duration_seconds=duration_seconds,
description=description,
)
wl.integrity_hash = _compute_hash(wl)
db.add(wl)
db.commit()
db.refresh(wl)
return wl
def list_worklogs(
db: Session,
*,
entity_type: Optional[str] = None,
entity_id: Optional[UUID] = None,
user_id: Optional[UUID] = None,
) -> list[Worklog]:
"""List worklogs with optional filters."""
query = db.query(Worklog)
if entity_type:
query = query.filter(Worklog.entity_type == entity_type)
if entity_id:
query = query.filter(Worklog.entity_id == entity_id)
if user_id:
query = query.filter(Worklog.user_id == user_id)
return query.order_by(Worklog.started_at.desc()).all()
def verify_worklog_integrity(wl: Worklog) -> bool:
"""Return True if the worklog has not been tampered with."""
return wl.integrity_hash == _compute_hash(wl)
def _compute_hash(wl: Worklog) -> str:
"""SHA-256 of the immutable fields for audit integrity."""
data = (
f"{wl.entity_type}:{wl.entity_id}:{wl.user_id}:"
f"{wl.activity_type}:{wl.started_at}:{wl.duration_seconds}"
)
return hashlib.sha256(data.encode()).hexdigest()

View File

@@ -18,6 +18,7 @@ pydantic-settings
slowapi slowapi
defusedxml defusedxml
redis>=5.0.0 redis>=5.0.0
atlassian-python-api>=4.0.0
# Testing # Testing
pytest pytest

90
frontend/src/api/jira.ts Normal file
View File

@@ -0,0 +1,90 @@
import client from "./client";
// ── Types ───────────────────────────────────────────────────────────
export type JiraLinkEntityType = "test" | "technique" | "campaign" | "evidence";
export type JiraSyncDirection = "aegis_to_jira" | "jira_to_aegis" | "bidirectional";
export interface JiraLink {
id: string;
entity_type: JiraLinkEntityType;
entity_id: string;
jira_issue_key: string;
jira_issue_id: string | null;
jira_project_key: string | null;
jira_status: string | null;
jira_priority: string | null;
jira_assignee: string | null;
jira_story_points: string | null;
last_synced_at: string | null;
created_at: string;
}
export interface JiraIssueResult {
issue_key: string;
summary: string;
status: string;
assignee: string | null;
priority: string | null;
}
export interface JiraLinkCreatePayload {
entity_type: JiraLinkEntityType;
entity_id: string;
jira_issue_key: string;
sync_direction?: JiraSyncDirection;
}
// ── API Functions ───────────────────────────────────────────────────
/** Search Jira issues by JQL or free text. */
export async function searchJiraIssues(
q: string,
maxResults: number = 10,
): Promise<JiraIssueResult[]> {
const { data } = await client.get<JiraIssueResult[]>("/jira/search", {
params: { q, max_results: maxResults },
});
return data;
}
/** Create a new Jira link for an Aegis entity. */
export async function createJiraLink(
payload: JiraLinkCreatePayload,
): Promise<JiraLink> {
const { data } = await client.post<JiraLink>("/jira/links", payload);
return data;
}
/** List Jira links, optionally filtered. */
export async function listJiraLinks(params?: {
entity_type?: JiraLinkEntityType;
entity_id?: string;
}): Promise<JiraLink[]> {
const { data } = await client.get<JiraLink[]>("/jira/links", { params });
return data;
}
/** Force sync a Jira link. */
export async function syncJiraLink(
linkId: string,
): Promise<{ message: string; jira_status: string }> {
const { data } = await client.post(`/jira/links/${linkId}/sync`);
return data;
}
/** Delete a Jira link. */
export async function deleteJiraLink(linkId: string): Promise<void> {
await client.delete(`/jira/links/${linkId}`);
}
/** Auto-create a Jira issue from an Aegis entity and link them. */
export async function createIssueFromEntity(
entityType: JiraLinkEntityType,
entityId: string,
): Promise<{ issue_key: string; link_id: string }> {
const { data } = await client.post("/jira/create-issue", null, {
params: { entity_type: entityType, entity_id: entityId },
});
return data;
}

View File

@@ -0,0 +1,62 @@
import client from "./client";
// ── Types ───────────────────────────────────────────────────────────
export interface Worklog {
id: string;
entity_type: string;
entity_id: string;
user_id: string;
activity_type: string;
started_at: string;
ended_at: string | null;
duration_seconds: number;
description: string | null;
tempo_synced: string | null;
integrity_hash: string | null;
created_at: string;
}
export interface WorklogCreatePayload {
entity_type: string;
entity_id: string;
activity_type: string;
started_at: string;
ended_at?: string;
duration_seconds: number;
description?: string;
}
// ── API Functions ───────────────────────────────────────────────────
/** Create a manual worklog entry. */
export async function createWorklog(
payload: WorklogCreatePayload,
): Promise<Worklog> {
const { data } = await client.post<Worklog>("/worklogs", payload);
return data;
}
/** List worklogs with optional filters. */
export async function listWorklogs(params?: {
entity_type?: string;
entity_id?: string;
user_id?: string;
}): Promise<Worklog[]> {
const { data } = await client.get<Worklog[]>("/worklogs", { params });
return data;
}
/** Get a single worklog. */
export async function getWorklog(worklogId: string): Promise<Worklog> {
const { data } = await client.get<Worklog>(`/worklogs/${worklogId}`);
return data;
}
/** Verify a worklog's integrity hash. */
export async function verifyWorklogIntegrity(
worklogId: string,
): Promise<{ worklog_id: string; integrity_valid: boolean }> {
const { data } = await client.get(`/worklogs/${worklogId}/verify`);
return data;
}

View File

@@ -0,0 +1,274 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
ExternalLink,
Link2,
RefreshCw,
Search,
Trash2,
Loader2,
Plus,
X,
} from "lucide-react";
import {
listJiraLinks,
searchJiraIssues,
createJiraLink,
syncJiraLink,
deleteJiraLink,
type JiraLink,
type JiraLinkEntityType,
type JiraIssueResult,
} from "../api/jira";
import { useDebounce } from "../hooks/useDebounce";
interface JiraLinkPanelProps {
entityType: JiraLinkEntityType;
entityId: string;
}
const priorityColors: Record<string, string> = {
Highest: "text-red-400",
High: "text-orange-400",
Medium: "text-yellow-400",
Low: "text-cyan-400",
Lowest: "text-gray-400",
};
const statusColors: Record<string, string> = {
"To Do": "bg-gray-700 text-gray-300",
"In Progress": "bg-blue-900/50 text-blue-400",
"Done": "bg-green-900/50 text-green-400",
};
export default function JiraLinkPanel({ entityType, entityId }: JiraLinkPanelProps) {
const queryClient = useQueryClient();
const [showSearch, setShowSearch] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const debouncedQuery = useDebounce(searchQuery, 400);
// ── Queries ─────────────────────────────────────────────────────
const { data: links = [], isLoading: isLoadingLinks } = useQuery({
queryKey: ["jira-links", entityType, entityId],
queryFn: () => listJiraLinks({ entity_type: entityType, entity_id: entityId }),
});
const { data: searchResults = [], isFetching: isSearching } = useQuery({
queryKey: ["jira-search", debouncedQuery],
queryFn: () => searchJiraIssues(debouncedQuery),
enabled: debouncedQuery.length >= 2,
});
// ── Mutations ───────────────────────────────────────────────────
const invalidate = () => {
queryClient.invalidateQueries({
queryKey: ["jira-links", entityType, entityId],
});
};
const linkMutation = useMutation({
mutationFn: (issueKey: string) =>
createJiraLink({
entity_type: entityType,
entity_id: entityId,
jira_issue_key: issueKey,
}),
onSuccess: () => {
invalidate();
setShowSearch(false);
setSearchQuery("");
},
});
const syncMutation = useMutation({
mutationFn: (linkId: string) => syncJiraLink(linkId),
onSuccess: invalidate,
});
const deleteMutation = useMutation({
mutationFn: (linkId: string) => deleteJiraLink(linkId),
onSuccess: invalidate,
});
// ── Render helpers ──────────────────────────────────────────────
const formatDate = (dateStr: string | null) => {
if (!dateStr) return null;
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const getStatusClass = (status: string | null) => {
if (!status) return "bg-gray-700 text-gray-400";
return statusColors[status] || "bg-gray-700 text-gray-300";
};
return (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="flex items-center gap-2 text-lg font-semibold text-white">
<Link2 className="h-5 w-5 text-blue-400" />
Jira
</h2>
<button
onClick={() => setShowSearch(!showSearch)}
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-xs text-gray-300 hover:border-blue-500/50 hover:text-blue-400 transition-colors"
>
{showSearch ? (
<>
<X className="h-3.5 w-3.5" /> Cancel
</>
) : (
<>
<Plus className="h-3.5 w-3.5" /> Link Issue
</>
)}
</button>
</div>
{/* Search panel */}
{showSearch && (
<div className="mb-4 rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search Jira issues (e.g. SEC-1234 or keyword)..."
className="w-full rounded-lg border border-gray-700 bg-gray-900 py-2 pl-10 pr-3 text-sm text-gray-200 placeholder-gray-500 focus:border-blue-500 focus:outline-none"
autoFocus
/>
{isSearching && (
<Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-gray-500" />
)}
</div>
{searchResults.length > 0 && (
<div className="max-h-48 space-y-1 overflow-y-auto">
{searchResults.map((issue: JiraIssueResult) => (
<button
key={issue.issue_key}
onClick={() => linkMutation.mutate(issue.issue_key)}
disabled={linkMutation.isPending}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm hover:bg-gray-700/50 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-blue-400">
{issue.issue_key}
</span>
<span className={`text-xs ${priorityColors[issue.priority || ""] || "text-gray-500"}`}>
{issue.priority}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-gray-300">
{issue.summary}
</p>
</div>
<span className={`ml-2 shrink-0 rounded px-2 py-0.5 text-xs ${getStatusClass(issue.status)}`}>
{issue.status}
</span>
</button>
))}
</div>
)}
{debouncedQuery.length >= 2 && !isSearching && searchResults.length === 0 && (
<p className="text-center text-xs text-gray-500">No issues found</p>
)}
</div>
)}
{/* Linked issues */}
{isLoadingLinks ? (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-gray-500" />
</div>
) : links.length === 0 ? (
<p className="text-center text-sm text-gray-500 py-4">
No Jira issues linked
</p>
) : (
<div className="space-y-2">
{links.map((link: JiraLink) => (
<div
key={link.id}
className="rounded-lg border border-gray-700 bg-gray-800/30 p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm font-medium text-blue-400">
{link.jira_issue_key}
</span>
{link.jira_status && (
<span
className={`rounded px-1.5 py-0.5 text-xs ${getStatusClass(link.jira_status)}`}
>
{link.jira_status}
</span>
)}
{link.jira_priority && (
<span
className={`text-xs ${priorityColors[link.jira_priority] || "text-gray-500"}`}
>
{link.jira_priority}
</span>
)}
</div>
{link.jira_assignee && (
<p className="mt-1 text-xs text-gray-400">
Assignee: {link.jira_assignee}
</p>
)}
{link.last_synced_at && (
<p className="mt-0.5 text-xs text-gray-500">
Synced: {formatDate(link.last_synced_at)}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => syncMutation.mutate(link.id)}
disabled={syncMutation.isPending}
title="Sync from Jira"
className="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-blue-400 transition-colors"
>
<RefreshCw
className={`h-3.5 w-3.5 ${syncMutation.isPending ? "animate-spin" : ""}`}
/>
</button>
<a
href={`https://jira.atlassian.com/browse/${link.jira_issue_key}`}
target="_blank"
rel="noopener noreferrer"
title="Open in Jira"
className="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-blue-400 transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
<button
onClick={() => deleteMutation.mutate(link.id)}
disabled={deleteMutation.isPending}
title="Unlink"
className="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-red-400 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,217 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Clock, Plus, Loader2, ShieldCheck, ShieldAlert, X } from "lucide-react";
import { listWorklogs, createWorklog, type Worklog } from "../api/worklogs";
import { useAuth } from "../context/AuthContext";
interface WorklogTimelineProps {
entityType: string;
entityId: string;
}
const activityColors: Record<string, { bg: string; text: string; icon: string }> = {
red_team: { bg: "bg-orange-900/30", text: "text-orange-400", icon: "border-orange-500/40" },
blue_validation: { bg: "bg-indigo-900/30", text: "text-indigo-400", icon: "border-indigo-500/40" },
purple_review: { bg: "bg-purple-900/30", text: "text-purple-400", icon: "border-purple-500/40" },
reporting: { bg: "bg-cyan-900/30", text: "text-cyan-400", icon: "border-cyan-500/40" },
execution: { bg: "bg-orange-900/30", text: "text-orange-400", icon: "border-orange-500/40" },
};
const defaultActivity = { bg: "bg-gray-800/50", text: "text-gray-400", icon: "border-gray-600" };
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export default function WorklogTimeline({ entityType, entityId }: WorklogTimelineProps) {
const queryClient = useQueryClient();
const { user } = useAuth();
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({
activity_type: "red_team",
duration_minutes: "60",
description: "",
});
// ── Query ───────────────────────────────────────────────────────
const { data: worklogs = [], isLoading } = useQuery({
queryKey: ["worklogs", entityType, entityId],
queryFn: () => listWorklogs({ entity_type: entityType, entity_id: entityId }),
});
const createMutation = useMutation({
mutationFn: () =>
createWorklog({
entity_type: entityType,
entity_id: entityId,
activity_type: form.activity_type,
started_at: new Date().toISOString(),
duration_seconds: parseInt(form.duration_minutes, 10) * 60,
description: form.description || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["worklogs", entityType, entityId],
});
setShowForm(false);
setForm({ activity_type: "red_team", duration_minutes: "60", description: "" });
},
});
// ── Total time ──────────────────────────────────────────────────
const totalSeconds = worklogs.reduce(
(sum: number, wl: Worklog) => sum + wl.duration_seconds,
0,
);
return (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="flex items-center gap-2 text-lg font-semibold text-white">
<Clock className="h-5 w-5 text-cyan-400" />
Time Log
</h2>
<div className="flex items-center gap-3">
{totalSeconds > 0 && (
<span className="text-xs text-gray-400">
Total: <span className="text-cyan-400 font-medium">{formatDuration(totalSeconds)}</span>
</span>
)}
<button
onClick={() => setShowForm(!showForm)}
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-xs text-gray-300 hover:border-cyan-500/50 hover:text-cyan-400 transition-colors"
>
{showForm ? (
<>
<X className="h-3.5 w-3.5" /> Cancel
</>
) : (
<>
<Plus className="h-3.5 w-3.5" /> Log Time
</>
)}
</button>
</div>
</div>
{/* New worklog form */}
{showForm && (
<div className="mb-4 rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-gray-400">Activity Type</label>
<select
value={form.activity_type}
onChange={(e) => setForm({ ...form, activity_type: e.target.value })}
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="red_team">Red Team</option>
<option value="blue_validation">Blue Validation</option>
<option value="purple_review">Purple Review</option>
<option value="reporting">Reporting</option>
<option value="execution">Execution</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs text-gray-400">Duration (minutes)</label>
<input
type="number"
min="1"
value={form.duration_minutes}
onChange={(e) => setForm({ ...form, duration_minutes: e.target.value })}
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs text-gray-400">Description (optional)</label>
<input
type="text"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="What did you work on?"
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
/>
</div>
<button
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !form.duration_minutes}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{createMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Save Worklog
</button>
</div>
)}
{/* Timeline */}
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-gray-500" />
</div>
) : worklogs.length === 0 ? (
<p className="text-center text-sm text-gray-500 py-4">No time logged yet</p>
) : (
<div className="relative space-y-0">
{/* Vertical line */}
<div className="absolute left-[15px] top-2 bottom-2 w-px bg-gray-700" />
{worklogs.map((wl: Worklog) => {
const style = activityColors[wl.activity_type] || defaultActivity;
return (
<div key={wl.id} className="relative flex gap-3 py-2">
{/* Dot */}
<div
className={`relative z-10 mt-1 h-[10px] w-[10px] shrink-0 rounded-full border-2 bg-gray-900 ${style.icon}`}
style={{ marginLeft: "6px" }}
/>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span
className={`rounded px-1.5 py-0.5 text-xs font-medium ${style.bg} ${style.text}`}
>
{wl.activity_type.replace(/_/g, " ")}
</span>
<span className="text-xs font-medium text-gray-200">
{formatDuration(wl.duration_seconds)}
</span>
{wl.tempo_synced && (
<span className="text-xs text-green-500" title="Synced to Tempo">
<ShieldCheck className="inline h-3 w-3" />
</span>
)}
</div>
{wl.description && (
<p className="mt-0.5 text-xs text-gray-400 truncate">
{wl.description}
</p>
)}
<p className="mt-0.5 text-xs text-gray-500">
{formatDate(wl.started_at)}
</p>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -28,6 +28,8 @@ import {
} from "../api/campaigns"; } from "../api/campaigns";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import CampaignTimeline from "../components/CampaignTimeline"; import CampaignTimeline from "../components/CampaignTimeline";
import JiraLinkPanel from "../components/JiraLinkPanel";
import WorklogTimeline from "../components/WorklogTimeline";
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30", draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
@@ -598,6 +600,12 @@ export default function CampaignDetailPage() {
)} )}
</div> </div>
{/* Jira & Worklogs */}
<div className="grid gap-6 lg:grid-cols-2">
<JiraLinkPanel entityType="campaign" entityId={campaignId!} />
<WorklogTimeline entityType="campaign" entityId={campaignId!} />
</div>
{/* Toast notification */} {/* Toast notification */}
{toast && ( {toast && (
<div <div

View File

@@ -23,6 +23,7 @@ import { getTechniqueByMitreId, markTechniqueReviewed } from "../api/techniques"
import { getTemplatesByTechnique } from "../api/test-templates"; import { getTemplatesByTechnique } from "../api/test-templates";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import TestFromTemplateForm from "../components/TestFromTemplateForm"; import TestFromTemplateForm from "../components/TestFromTemplateForm";
import JiraLinkPanel from "../components/JiraLinkPanel";
import type { TechniqueStatus, TestState, TestResult } from "../types/models"; import type { TechniqueStatus, TestState, TestResult } from "../types/models";
const statusBadgeColors: Record<TechniqueStatus, string> = { const statusBadgeColors: Record<TechniqueStatus, string> = {
@@ -447,6 +448,11 @@ export default function TechniqueDetailPage() {
</div> </div>
)} )}
{/* Jira Integration */}
{technique && (
<JiraLinkPanel entityType="technique" entityId={technique.id} />
)}
{/* Template instantiation modal */} {/* Template instantiation modal */}
{templateFormId && technique && ( {templateFormId && technique && (
<TestFromTemplateForm <TestFromTemplateForm

View File

@@ -24,6 +24,8 @@ import TestDetailHeader from "../components/test-detail/TestDetailHeader";
import TeamTabs from "../components/test-detail/TeamTabs"; import TeamTabs from "../components/test-detail/TeamTabs";
import ValidationModal from "../components/test-detail/ValidationModal"; import ValidationModal from "../components/test-detail/ValidationModal";
import ConfirmDialog from "../components/ConfirmDialog"; import ConfirmDialog from "../components/ConfirmDialog";
import JiraLinkPanel from "../components/JiraLinkPanel";
import WorklogTimeline from "../components/WorklogTimeline";
// ── Page Component ───────────────────────────────────────────────── // ── Page Component ─────────────────────────────────────────────────
@@ -498,6 +500,12 @@ export default function TestDetailPage() {
</div> </div>
</div> </div>
)} )}
{/* Jira Integration */}
<JiraLinkPanel entityType="test" entityId={testId!} />
{/* Time Tracking */}
<WorklogTimeline entityType="test" entityId={testId!} />
</div> </div>
</div> </div>

View File

@@ -202,3 +202,36 @@ export interface DefensiveTechnique {
tactic: string | null; tactic: string | null;
d3fend_url: string | null; d3fend_url: string | null;
} }
// ── Jira ──────────────────────────────────────────────────────────
export type JiraLinkEntityType = "test" | "technique" | "campaign" | "evidence";
export interface JiraLink {
id: string;
entity_type: JiraLinkEntityType;
entity_id: string;
jira_issue_key: string;
jira_status: string | null;
jira_priority: string | null;
jira_assignee: string | null;
last_synced_at: string | null;
created_at: string;
}
// ── Worklogs ──────────────────────────────────────────────────────
export interface Worklog {
id: string;
entity_type: string;
entity_id: string;
user_id: string;
activity_type: string;
started_at: string;
ended_at: string | null;
duration_seconds: number;
description: string | null;
tempo_synced: string | null;
integrity_hash: string | null;
created_at: string;
}