feat(jira): implement full ticket hierarchy for campaigns and tests
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Jira tickets now follow the correct hierarchy:
OFS-9107 (system parent)
├── Standalone test ticket (unchanged — was already working)
└── Campaign ticket (NEW — created on campaign creation)
├── Test 1 ticket (NEW — created per test)
└── Test 2 ticket (NEW — created per test)
Changes:
- jira_service: add auto_create_campaign_issue() — creates campaign
ticket as child of OFS-9107; stores JiraLink(entity_type=campaign)
- jira_service: add get_campaign_jira_key() / get_test_jira_key()
helpers to look up existing Jira links by entity
- jira_service: auto_create_test_issue() gains parent_ticket_override
param — when set, uses it as parent instead of OFS-9107
- campaigns router/create_campaign: triggers auto_create_campaign_issue
after commit
- campaigns router/from-threat-actor: triggers campaign ticket then
iterates campaign_tests and creates each test ticket under it
- campaigns router/add_test_to_campaign: if campaign has a Jira ticket
and the test has none yet, creates test ticket under campaign ticket
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user