diff --git a/backend/alembic/versions/b020_add_jira_links_and_worklogs.py b/backend/alembic/versions/b020_add_jira_links_and_worklogs.py new file mode 100644 index 0000000..d09a23e --- /dev/null +++ b/backend/alembic/versions/b020_add_jira_links_and_worklogs.py @@ -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) diff --git a/backend/app/config.py b/backend/app/config.py index 6552510..c81e22b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -43,6 +43,21 @@ class Settings(BaseSettings): # ── Re-testing ─────────────────────────────────────────────────── 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_WEIGHT_TESTS: int = 40 SCORING_WEIGHT_DETECTION_RULES: int = 20 diff --git a/backend/app/jobs/jira_sync_job.py b/backend/app/jobs/jira_sync_job.py new file mode 100644 index 0000000..8bc3cc5 --- /dev/null +++ b/backend/app/jobs/jira_sync_job.py @@ -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() diff --git a/backend/app/jobs/mitre_sync_job.py b/backend/app/jobs/mitre_sync_job.py index 2a5160b..e83a1ef 100644 --- a/backend/app/jobs/mitre_sync_job.py +++ b/backend/app/jobs/mitre_sync_job.py @@ -20,6 +20,7 @@ from app.services.intel_service import scan_intel from app.services.notification_service import cleanup_old_notifications 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.jobs.jira_sync_job import sync_all_jira_links logger = logging.getLogger(__name__) @@ -164,9 +165,17 @@ def start_scheduler() -> None: name="Recurring campaigns check (daily)", 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() logger.info( "Background scheduler started — mitre_sync (24h), intel_scan (7d), " "notification_cleanup (24h), weekly_snapshot (Sundays 00:00), " - "recurring_campaigns (daily)" + "recurring_campaigns (daily), jira_sync (1h)" ) diff --git a/backend/app/main.py b/backend/app/main.py index ea2c659..e9beacd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 compliance as compliance_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.middleware.error_handler import domain_exception_handler 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(compliance_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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2fb5911..fbb7a9b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -16,6 +16,8 @@ from app.models.test_detection_result import TestDetectionResult from app.models.campaign import Campaign, CampaignTest from app.models.compliance import ComplianceFramework, ComplianceControl, ComplianceControlMapping 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 __all__ = [ @@ -27,5 +29,7 @@ __all__ = [ "Campaign", "CampaignTest", "ComplianceFramework", "ComplianceControl", "ComplianceControlMapping", "CoverageSnapshot", "SnapshotTechniqueState", + "JiraLink", "JiraLinkEntityType", "JiraSyncDirection", + "Worklog", "TechniqueStatus", "TestState", "TestResult", "TeamSide", ] diff --git a/backend/app/models/jira_link.py b/backend/app/models/jira_link.py new file mode 100644 index 0000000..4b69b7c --- /dev/null +++ b/backend/app/models/jira_link.py @@ -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"), + ) diff --git a/backend/app/models/worklog.py b/backend/app/models/worklog.py new file mode 100644 index 0000000..e46f0a1 --- /dev/null +++ b/backend/app/models/worklog.py @@ -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"), + ) diff --git a/backend/app/routers/jira.py b/backend/app/routers/jira.py new file mode 100644 index 0000000..f647539 --- /dev/null +++ b/backend/app/routers/jira.py @@ -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}" diff --git a/backend/app/routers/worklogs.py b/backend/app/routers/worklogs.py new file mode 100644 index 0000000..d4efdca --- /dev/null +++ b/backend/app/routers/worklogs.py @@ -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), + } diff --git a/backend/app/schemas/jira_schema.py b/backend/app/schemas/jira_schema.py new file mode 100644 index 0000000..489544c --- /dev/null +++ b/backend/app/schemas/jira_schema.py @@ -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 diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py new file mode 100644 index 0000000..cc6b13f --- /dev/null +++ b/backend/app/services/jira_service.py @@ -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) diff --git a/backend/app/services/tempo_service.py b/backend/app/services/tempo_service.py new file mode 100644 index 0000000..ca5ed23 --- /dev/null +++ b/backend/app/services/tempo_service.py @@ -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 diff --git a/backend/app/services/worklog_service.py b/backend/app/services/worklog_service.py new file mode 100644 index 0000000..7e1358f --- /dev/null +++ b/backend/app/services/worklog_service.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt index 590c014..6cc6b81 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,6 +18,7 @@ pydantic-settings slowapi defusedxml redis>=5.0.0 +atlassian-python-api>=4.0.0 # Testing pytest diff --git a/frontend/src/api/jira.ts b/frontend/src/api/jira.ts new file mode 100644 index 0000000..1364912 --- /dev/null +++ b/frontend/src/api/jira.ts @@ -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 { + const { data } = await client.get("/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 { + const { data } = await client.post("/jira/links", payload); + return data; +} + +/** List Jira links, optionally filtered. */ +export async function listJiraLinks(params?: { + entity_type?: JiraLinkEntityType; + entity_id?: string; +}): Promise { + const { data } = await client.get("/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 { + 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; +} diff --git a/frontend/src/api/worklogs.ts b/frontend/src/api/worklogs.ts new file mode 100644 index 0000000..50c5337 --- /dev/null +++ b/frontend/src/api/worklogs.ts @@ -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 { + const { data } = await client.post("/worklogs", payload); + return data; +} + +/** List worklogs with optional filters. */ +export async function listWorklogs(params?: { + entity_type?: string; + entity_id?: string; + user_id?: string; +}): Promise { + const { data } = await client.get("/worklogs", { params }); + return data; +} + +/** Get a single worklog. */ +export async function getWorklog(worklogId: string): Promise { + const { data } = await client.get(`/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; +} diff --git a/frontend/src/components/JiraLinkPanel.tsx b/frontend/src/components/JiraLinkPanel.tsx new file mode 100644 index 0000000..4bbc32b --- /dev/null +++ b/frontend/src/components/JiraLinkPanel.tsx @@ -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 = { + Highest: "text-red-400", + High: "text-orange-400", + Medium: "text-yellow-400", + Low: "text-cyan-400", + Lowest: "text-gray-400", +}; + +const statusColors: Record = { + "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 ( +
+
+

+ + Jira +

+ +
+ + {/* Search panel */} + {showSearch && ( +
+
+ + 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 && ( + + )} +
+ + {searchResults.length > 0 && ( +
+ {searchResults.map((issue: JiraIssueResult) => ( + + ))} +
+ )} + + {debouncedQuery.length >= 2 && !isSearching && searchResults.length === 0 && ( +

No issues found

+ )} +
+ )} + + {/* Linked issues */} + {isLoadingLinks ? ( +
+ +
+ ) : links.length === 0 ? ( +

+ No Jira issues linked +

+ ) : ( +
+ {links.map((link: JiraLink) => ( +
+
+
+
+ + {link.jira_issue_key} + + {link.jira_status && ( + + {link.jira_status} + + )} + {link.jira_priority && ( + + {link.jira_priority} + + )} +
+ {link.jira_assignee && ( +

+ Assignee: {link.jira_assignee} +

+ )} + {link.last_synced_at && ( +

+ Synced: {formatDate(link.last_synced_at)} +

+ )} +
+ +
+ + + + + +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/WorklogTimeline.tsx b/frontend/src/components/WorklogTimeline.tsx new file mode 100644 index 0000000..56851e7 --- /dev/null +++ b/frontend/src/components/WorklogTimeline.tsx @@ -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 = { + 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 ( +
+
+

+ + Time Log +

+
+ {totalSeconds > 0 && ( + + Total: {formatDuration(totalSeconds)} + + )} + +
+
+ + {/* New worklog form */} + {showForm && ( +
+
+
+ + +
+
+ + 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" + /> +
+
+
+ + 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" + /> +
+ +
+ )} + + {/* Timeline */} + {isLoading ? ( +
+ +
+ ) : worklogs.length === 0 ? ( +

No time logged yet

+ ) : ( +
+ {/* Vertical line */} +
+ + {worklogs.map((wl: Worklog) => { + const style = activityColors[wl.activity_type] || defaultActivity; + return ( +
+ {/* Dot */} +
+ + {/* Content */} +
+
+ + {wl.activity_type.replace(/_/g, " ")} + + + {formatDuration(wl.duration_seconds)} + + {wl.tempo_synced && ( + + + + )} +
+ {wl.description && ( +

+ {wl.description} +

+ )} +

+ {formatDate(wl.started_at)} +

+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/CampaignDetailPage.tsx b/frontend/src/pages/CampaignDetailPage.tsx index fc7fa76..be6ec5c 100644 --- a/frontend/src/pages/CampaignDetailPage.tsx +++ b/frontend/src/pages/CampaignDetailPage.tsx @@ -28,6 +28,8 @@ import { } from "../api/campaigns"; import { useAuth } from "../context/AuthContext"; import CampaignTimeline from "../components/CampaignTimeline"; +import JiraLinkPanel from "../components/JiraLinkPanel"; +import WorklogTimeline from "../components/WorklogTimeline"; const statusColors: Record = { draft: "bg-gray-800/50 text-gray-400 border-gray-600/30", @@ -598,6 +600,12 @@ export default function CampaignDetailPage() { )}
+ {/* Jira & Worklogs */} +
+ + +
+ {/* Toast notification */} {toast && (
= { @@ -447,6 +448,11 @@ export default function TechniqueDetailPage() {
)} + {/* Jira Integration */} + {technique && ( + + )} + {/* Template instantiation modal */} {templateFormId && technique && (
)} + + {/* Jira Integration */} + + + {/* Time Tracking */} + diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts index a3c3ea7..4b4b902 100644 --- a/frontend/src/types/models.ts +++ b/frontend/src/types/models.ts @@ -202,3 +202,36 @@ export interface DefensiveTechnique { tactic: 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; +}