- app/services/ticket_export.py: plugin-dispatch architecture; Notion
exporter (Notion API v1, blocks-based, 50 entry cap, 2000-char
truncation per block); Jira exporter (REST API v3, Basic Auth, ADF
description, configurable issue type defaulting to Bug)
- app/rest.py: POST /api/incidents/{id}/export endpoint; Notion/Jira
credential fields added to SettingsBody and PATCH /api/settings handler
- web/src/views/IncidentsView.vue: "Export ticket ▾" dropdown in
incident detail drawer — click-outside close, inline URL link on success
- web/src/views/SettingsView.vue: Ticket Trackers section with Notion
token + database ID, Jira URL/email/token/project/issue-type; show/hide
for secret fields
- tests/test_ticket_export.py: 17 tests covering dispatch, Notion
success/error/config/payload/truncation paths, Jira success/error/
auth/project/summary/default-issue-type
213 lines
7.2 KiB
Python
213 lines
7.2 KiB
Python
"""Incident ticket export — push Turnstone incidents to external trackers.
|
|
|
|
Supported targets: "notion", "jira"
|
|
|
|
Each exporter receives the incident dict and a list of log entry dicts,
|
|
and returns {"url": str, "ticket_id": str}.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Notion exporter
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _notion_export(
|
|
incident: dict[str, Any],
|
|
entries: list[dict[str, Any]],
|
|
token: str,
|
|
database_id: str,
|
|
) -> dict[str, str]:
|
|
"""Create a Notion page in *database_id* from an incident.
|
|
|
|
Notion block types used: heading_2, bulleted_list_item, paragraph.
|
|
Rich text max length is 2000 chars per block.
|
|
"""
|
|
if not token or not database_id:
|
|
raise ValueError("Notion not configured — set notion_token and notion_database_id in Settings")
|
|
|
|
def _text(s: str, bold: bool = False) -> dict:
|
|
chunk: dict[str, Any] = {"type": "text", "text": {"content": s[:2000]}}
|
|
if bold:
|
|
chunk["annotations"] = {"bold": True}
|
|
return chunk
|
|
|
|
log_blocks: list[dict] = []
|
|
for e in entries[:50]: # Notion has page size limits
|
|
line = f"[{e.get('severity') or '?'}] {e.get('source_id', '')} — {e.get('text', '')[:160]}"
|
|
log_blocks.append({"object": "block", "type": "bulleted_list_item",
|
|
"bulleted_list_item": {"rich_text": [_text(line)]}})
|
|
|
|
sev = incident.get("severity", "medium").upper()
|
|
issue_type = incident.get("issue_type") or "—"
|
|
window = f"{incident.get('started_at') or '?'} → {incident.get('ended_at') or 'ongoing'}"
|
|
|
|
children: list[dict] = [
|
|
{"object": "block", "type": "heading_2",
|
|
"heading_2": {"rich_text": [_text("Incident Details", bold=True)]}},
|
|
{"object": "block", "type": "paragraph",
|
|
"paragraph": {"rich_text": [
|
|
_text("Severity: ", bold=True), _text(sev),
|
|
_text(" Type: ", bold=True), _text(issue_type),
|
|
_text(" Window: ", bold=True), _text(window),
|
|
]}},
|
|
]
|
|
if incident.get("notes"):
|
|
children.append({"object": "block", "type": "paragraph",
|
|
"paragraph": {"rich_text": [_text("Notes: ", bold=True), _text(incident["notes"])]}})
|
|
|
|
children.append({"object": "block", "type": "heading_2",
|
|
"heading_2": {"rich_text": [_text("Log Evidence")]}})
|
|
children.extend(log_blocks)
|
|
|
|
payload = {
|
|
"parent": {"database_id": database_id},
|
|
"properties": {
|
|
"title": {"title": [_text(incident.get("label", "Unnamed Incident"))]},
|
|
},
|
|
"children": children,
|
|
}
|
|
|
|
resp = httpx.post(
|
|
"https://api.notion.com/v1/pages",
|
|
headers={
|
|
"Authorization": f"Bearer {token}",
|
|
"Notion-Version": "2022-06-28",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json=payload,
|
|
timeout=15,
|
|
)
|
|
if not resp.is_success:
|
|
raise RuntimeError(f"Notion API error {resp.status_code}: {resp.text[:300]}")
|
|
|
|
page = resp.json()
|
|
page_id = page["id"]
|
|
url = page.get("url") or f"https://notion.so/{page_id.replace('-', '')}"
|
|
return {"url": url, "ticket_id": page_id}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Jira exporter
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _jira_export(
|
|
incident: dict[str, Any],
|
|
entries: list[dict[str, Any]],
|
|
jira_url: str,
|
|
email: str,
|
|
api_token: str,
|
|
project_key: str,
|
|
issue_type: str = "Bug",
|
|
) -> dict[str, str]:
|
|
"""Create a Jira issue via REST API v3 (cloud or Server 8.4+)."""
|
|
if not jira_url or not email or not api_token or not project_key:
|
|
raise ValueError("Jira not configured — set jira_url, jira_email, jira_api_token, and jira_project_key in Settings")
|
|
|
|
base = jira_url.rstrip("/")
|
|
sev = incident.get("severity", "medium").upper()
|
|
inc_type = incident.get("issue_type") or "incident"
|
|
window = f"{incident.get('started_at') or '?'} → {incident.get('ended_at') or 'ongoing'}"
|
|
|
|
log_lines = "\n".join(
|
|
f"[{e.get('severity') or '?'}] {e.get('source_id', '')} — {e.get('text', '')[:160]}"
|
|
for e in entries[:40]
|
|
)
|
|
description = (
|
|
f"*Severity:* {sev} | *Type:* {inc_type} | *Window:* {window}\n\n"
|
|
+ (f"*Notes:* {incident['notes']}\n\n" if incident.get("notes") else "")
|
|
+ "h2. Log Evidence\n\n{{code}}\n" + log_lines + "\n{{code}}"
|
|
)
|
|
|
|
# Jira REST v3 uses Atlassian Document Format for description
|
|
adf_body = {
|
|
"type": "doc",
|
|
"version": 1,
|
|
"content": [
|
|
{"type": "paragraph", "content": [{"type": "text", "text": description}]},
|
|
],
|
|
}
|
|
|
|
payload = {
|
|
"fields": {
|
|
"project": {"key": project_key},
|
|
"summary": incident.get("label", "Unnamed Incident"),
|
|
"issuetype": {"name": issue_type},
|
|
"description": adf_body,
|
|
}
|
|
}
|
|
|
|
import base64 as _b64
|
|
creds = _b64.b64encode(f"{email}:{api_token}".encode()).decode()
|
|
resp = httpx.post(
|
|
f"{base}/rest/api/3/issue",
|
|
headers={
|
|
"Authorization": f"Basic {creds}",
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
},
|
|
json=payload,
|
|
timeout=15,
|
|
)
|
|
if not resp.is_success:
|
|
raise RuntimeError(f"Jira API error {resp.status_code}: {resp.text[:300]}")
|
|
|
|
data = resp.json()
|
|
issue_key = data["key"]
|
|
url = f"{base}/browse/{issue_key}"
|
|
return {"url": url, "ticket_id": issue_key}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_EXPORTERS = {
|
|
"notion": _notion_export,
|
|
"jira": _jira_export,
|
|
}
|
|
|
|
|
|
def available_targets() -> list[str]:
|
|
return list(_EXPORTERS.keys())
|
|
|
|
|
|
def export_incident(
|
|
target: str,
|
|
incident: dict[str, Any],
|
|
entries: list[dict[str, Any]],
|
|
config: dict[str, str],
|
|
) -> dict[str, str]:
|
|
"""Dispatch to the appropriate exporter.
|
|
|
|
*config* is pulled from the settings pref dict — callers pass the relevant
|
|
subset so this service stays stateless and testable.
|
|
|
|
Returns {"url": str, "ticket_id": str}.
|
|
Raises ValueError for unknown target or missing config.
|
|
Raises RuntimeError on API-level failures.
|
|
"""
|
|
if target not in _EXPORTERS:
|
|
raise ValueError(f"Unknown ticket target: {target!r}. Supported: {list(_EXPORTERS)}")
|
|
|
|
if target == "notion":
|
|
return _notion_export(
|
|
incident, entries,
|
|
token=config.get("notion_token", ""),
|
|
database_id=config.get("notion_database_id", ""),
|
|
)
|
|
if target == "jira":
|
|
return _jira_export(
|
|
incident, entries,
|
|
jira_url=config.get("jira_url", ""),
|
|
email=config.get("jira_email", ""),
|
|
api_token=config.get("jira_api_token", ""),
|
|
project_key=config.get("jira_project_key", ""),
|
|
issue_type=config.get("jira_issue_type", "Bug"),
|
|
)
|
|
raise ValueError(f"Unhandled target: {target!r}")
|