feat(phase-35): Jira + Tempo integration with internal worklogs
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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:
93
backend/alembic/versions/b020_add_jira_links_and_worklogs.py
Normal file
93
backend/alembic/versions/b020_add_jira_links_and_worklogs.py
Normal 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)
|
||||||
@@ -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
|
||||||
|
|||||||
37
backend/app/jobs/jira_sync_job.py
Normal file
37
backend/app/jobs/jira_sync_job.py
Normal 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()
|
||||||
@@ -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)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
57
backend/app/models/jira_link.py
Normal file
57
backend/app/models/jira_link.py
Normal 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"),
|
||||||
|
)
|
||||||
44
backend/app/models/worklog.py
Normal file
44
backend/app/models/worklog.py
Normal 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
201
backend/app/routers/jira.py
Normal 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}"
|
||||||
119
backend/app/routers/worklogs.py
Normal file
119
backend/app/routers/worklogs.py
Normal 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),
|
||||||
|
}
|
||||||
46
backend/app/schemas/jira_schema.py
Normal file
46
backend/app/schemas/jira_schema.py
Normal 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
|
||||||
105
backend/app/services/jira_service.py
Normal file
105
backend/app/services/jira_service.py
Normal 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)
|
||||||
96
backend/app/services/tempo_service.py
Normal file
96
backend/app/services/tempo_service.py
Normal 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
|
||||||
75
backend/app/services/worklog_service.py
Normal file
75
backend/app/services/worklog_service.py
Normal 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()
|
||||||
@@ -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
90
frontend/src/api/jira.ts
Normal 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;
|
||||||
|
}
|
||||||
62
frontend/src/api/worklogs.ts
Normal file
62
frontend/src/api/worklogs.ts
Normal 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;
|
||||||
|
}
|
||||||
274
frontend/src/components/JiraLinkPanel.tsx
Normal file
274
frontend/src/components/JiraLinkPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
frontend/src/components/WorklogTimeline.tsx
Normal file
217
frontend/src/components/WorklogTimeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user