"""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}")