diff --git a/backend/app/routers/campaigns.py b/backend/app/routers/campaigns.py index 59a2882..312b9a4 100644 --- a/backend/app/routers/campaigns.py +++ b/backend/app/routers/campaigns.py @@ -122,16 +122,28 @@ def create_campaign( tags=payload.tags, scheduled_at=payload.scheduled_at, ) + campaign_id = result["id"] log_action( db, user_id=current_user.id, action="create_campaign", entity_type="campaign", - entity_id=result["id"], + entity_id=campaign_id, details={"name": payload.name, "type": payload.type}, ) uow.commit() + # Auto-create Jira ticket for campaign under OFS-9107 (non-fatal) + try: + from app.services.jira_service import auto_create_campaign_issue + from app.models.campaign import Campaign as CampaignModel + campaign_obj = db.query(CampaignModel).filter(CampaignModel.id == campaign_id).first() + if campaign_obj: + auto_create_campaign_issue(db, campaign_obj, current_user) + db.commit() + except Exception: + logger.exception("Jira campaign ticket creation failed for campaign %s", campaign_id) + return result @@ -205,6 +217,33 @@ def add_test_to_campaign( phase=payload.phase, ) uow.commit() + + # If the campaign has a Jira ticket and the test doesn't, create a test + # ticket nested under the campaign ticket (non-fatal). + try: + from app.services.jira_service import ( + auto_create_test_issue, + get_campaign_jira_key, + get_test_jira_key, + ) + from app.models.test import Test as TestModel + campaign_jira_key = get_campaign_jira_key(db, campaign_id) + if campaign_jira_key: + existing_test_key = get_test_jira_key(db, payload.test_id) + if not existing_test_key: + test_obj = db.query(TestModel).filter(TestModel.id == payload.test_id).first() + if test_obj: + auto_create_test_issue( + db, test_obj, current_user, + parent_ticket_override=campaign_jira_key, + ) + db.commit() + except Exception: + logger.exception( + "Jira test ticket creation failed for test %s in campaign %s", + payload.test_id, campaign_id, + ) + return result @@ -336,6 +375,24 @@ def generate_campaign_from_actor( ) uow.commit() + # Auto-create Jira tickets: campaign under OFS-9107, each test under campaign ticket (non-fatal) + try: + from app.services.jira_service import auto_create_campaign_issue, auto_create_test_issue + db.refresh(campaign) + campaign_ticket = auto_create_campaign_issue(db, campaign, current_user) + if campaign_ticket: + for ct in campaign.campaign_tests: + if ct.test: + auto_create_test_issue( + db, ct.test, current_user, + parent_ticket_override=campaign_ticket, + ) + db.commit() + except Exception: + logger.exception( + "Jira ticket creation failed for auto-generated campaign %s", campaign.id + ) + return serialize_campaign(db, campaign) diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index 2b81ebc..cbd925b 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -303,12 +303,139 @@ def _build_state_comment( # --------------------------------------------------------------------------- +def _build_campaign_description(campaign) -> str: + """Build the Jira ticket description for a campaign.""" + lines = [ + "h2. Aegis Security Campaign", + "", + f"*Campaign Name:* {campaign.name}", + f"*Type:* {campaign.type}", + f"*Status:* {campaign.status}", + ] + if campaign.description: + lines += ["", "h3. Description", campaign.description] + if campaign.tags: + lines += ["", f"*Tags:* {', '.join(campaign.tags)}"] + lines += [ + "", + "----", + f"_Created via Aegis at {datetime.utcnow().strftime('%Y-%m-%d %H:%M')} UTC_", + ] + return "\n".join(lines) + + +def get_campaign_jira_key(db: Session, campaign_id) -> Optional[str]: + """Return the Jira issue key for a campaign, or None if not linked.""" + import uuid as _uuid + try: + cid = _uuid.UUID(str(campaign_id)) + except (ValueError, AttributeError): + return None + link = ( + db.query(JiraLink) + .filter( + JiraLink.entity_type == JiraLinkEntityType.campaign, + JiraLink.entity_id == cid, + ) + .first() + ) + return link.jira_issue_key if link else None + + +def get_test_jira_key(db: Session, test_id) -> Optional[str]: + """Return the Jira issue key for a test, or None if not linked.""" + import uuid as _uuid + try: + tid = _uuid.UUID(str(test_id)) + except (ValueError, AttributeError): + return None + link = ( + db.query(JiraLink) + .filter( + JiraLink.entity_type == JiraLinkEntityType.test, + JiraLink.entity_id == tid, + ) + .first() + ) + return link.jira_issue_key if link else None + + +def auto_create_campaign_issue( + db: Session, + campaign, + actor: User, +) -> Optional[str]: + """Create a Jira issue for *campaign* under the configured parent ticket. + + Returns the Jira issue key on success, or ``None`` if Jira is not + configured for *actor* or if the operation fails (non-fatal). + + Called once right after a campaign is committed to the database. + The created ticket is stored as a JiraLink with entity_type=campaign. + """ + if not has_jira_configured(actor, db): + return None + + project_key = get_jira_project_key(db) + if not project_key: + logger.warning( + "Jira project key not configured; skipping auto-create for campaign %s", + campaign.id, + ) + return None + + parent_ticket = get_jira_parent_ticket(db) + + try: + jira = get_user_jira_client(actor, db) + + fields: dict = { + "project": {"key": project_key}, + "summary": f"[Aegis Campaign] {campaign.name}", + "description": _build_campaign_description(campaign), + "issuetype": {"name": settings.JIRA_ISSUE_TYPE_TEST}, + "labels": ["aegis", "campaign"], + } + + # Always nest under the configured parent ticket (e.g. OFS-9107) + if parent_ticket: + fields["parent"] = {"key": parent_ticket} + + result = jira.issue_create(fields=fields) + issue_key = result["key"] + issue_id = result.get("id", "") + + link = JiraLink( + entity_type=JiraLinkEntityType.campaign, + entity_id=campaign.id, + jira_issue_key=issue_key, + jira_issue_id=issue_id, + jira_project_key=project_key, + sync_direction=JiraSyncDirection.aegis_to_jira, + created_by=actor.id, + ) + db.add(link) + db.flush() + + logger.info("Auto-created Jira issue %s for campaign %s", issue_key, campaign.id) + return issue_key + + except Exception as exc: + # Non-fatal: Jira failures must never break the campaign creation flow + logger.warning( + "Failed to auto-create Jira issue for campaign %s: %s", + campaign.id, exc, exc_info=True, + ) + return None + + def auto_create_test_issue( db: Session, test: Test, actor: User, *, technique: Optional[Technique] = None, + parent_ticket_override: Optional[str] = None, ) -> Optional[str]: """Create a Jira issue for *test* and store the link. @@ -316,6 +443,11 @@ def auto_create_test_issue( configured for *actor* or if the operation fails (non-fatal). Called once right after a test is committed to the database. + + Args: + parent_ticket_override: When set, use this as the Jira parent ticket + instead of the system-configured parent (e.g. OFS-9107). + Use this to nest test tickets under a campaign ticket. """ if not has_jira_configured(actor, db): return None @@ -344,9 +476,11 @@ def auto_create_test_issue( "labels": ["aegis", "security-test", mitre_id.replace(".", "-")], } - parent_ticket = get_jira_parent_ticket(db) - if parent_ticket: - fields["parent"] = {"key": parent_ticket} + # Use campaign ticket as parent when provided, otherwise fall back to + # the system-configured parent (e.g. OFS-9107) + parent = parent_ticket_override or get_jira_parent_ticket(db) + if parent: + fields["parent"] = {"key": parent} result = jira.issue_create(fields=fields) issue_key = result["key"]