diff --git a/app/rest.py b/app/rest.py index 7d53672..35c5e2d 100644 --- a/app/rest.py +++ b/app/rest.py @@ -307,6 +307,13 @@ class SettingsBody(BaseModel): pihole_api_key: str | None = None router_source_ids: str | None = None device_names: str | None = None + notion_token: str | None = None + notion_database_id: str | None = None + jira_url: str | None = None + jira_email: str | None = None + jira_api_token: str | None = None + jira_project_key: str | None = None + jira_issue_type: str | None = None class IncidentCreate(BaseModel): @@ -618,6 +625,20 @@ def patch_settings(body: SettingsBody) -> dict: prefs["router_source_ids"] = body.router_source_ids if body.device_names is not None: prefs["device_names"] = body.device_names + if body.notion_token is not None: + prefs["notion_token"] = body.notion_token + if body.notion_database_id is not None: + prefs["notion_database_id"] = body.notion_database_id + if body.jira_url is not None: + prefs["jira_url"] = body.jira_url + if body.jira_email is not None: + prefs["jira_email"] = body.jira_email + if body.jira_api_token is not None: + prefs["jira_api_token"] = body.jira_api_token + if body.jira_project_key is not None: + prefs["jira_project_key"] = body.jira_project_key + if body.jira_issue_type is not None: + prefs["jira_issue_type"] = body.jira_issue_type _save_prefs(prefs) return prefs @@ -1325,6 +1346,41 @@ def send_incident_bundle(incident_id: str, sanitize: bool = False) -> dict: raise HTTPException(status_code=502, detail=f"Send failed: {exc}") from exc +class TicketExportRequest(BaseModel): + target: str # "notion" | "jira" + + +@router.post("/api/incidents/{incident_id}/export") +def export_incident_ticket(incident_id: str, body: TicketExportRequest) -> dict: + """Push an incident to an external ticket tracker (Notion or Jira).""" + from app.services.ticket_export import export_incident, available_targets + incident = get_incident(INCIDENTS_DB_PATH, incident_id) + if not incident: + raise HTTPException(status_code=404, detail="Incident not found") + if body.target not in available_targets(): + raise HTTPException(status_code=422, detail=f"Unknown target. Supported: {available_targets()}") + + prefs = _load_prefs() + config = {k: prefs.get(k, "") for k in ( + "notion_token", "notion_database_id", + "jira_url", "jira_email", "jira_api_token", "jira_project_key", "jira_issue_type", + )} + + from app.services.incidents import get_incident_entries + raw_entries = get_incident_entries(DB_PATH, incident) + entries = [dataclasses.asdict(e) for e in raw_entries] + incident_dict = dataclasses.asdict(incident) + + try: + result = export_incident(body.target, incident_dict, entries, config) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + return {"target": body.target, "url": result["url"], "ticket_id": result["ticket_id"]} + + @router.post("/api/bundles") def receive_bundle(bundle: dict) -> dict: record = store_bundle(INCIDENTS_DB_PATH, bundle) diff --git a/app/services/ticket_export.py b/app/services/ticket_export.py new file mode 100644 index 0000000..0b7e4bb --- /dev/null +++ b/app/services/ticket_export.py @@ -0,0 +1,213 @@ +"""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}") diff --git a/tests/test_ticket_export.py b/tests/test_ticket_export.py new file mode 100644 index 0000000..66cd646 --- /dev/null +++ b/tests/test_ticket_export.py @@ -0,0 +1,224 @@ +"""Tests for ticket_export service — Notion and Jira exporters.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch +import pytest + + +INCIDENT = { + "id": "inc-1", + "label": "Redis OOM — key eviction flood", + "issue_type": "memory", + "started_at": "2026-06-01T03:00:00Z", + "ended_at": "2026-06-01T03:45:00Z", + "notes": "Triggered by batch job at 03:00", + "severity": "high", +} + +ENTRIES = [ + {"entry_id": "e1", "source_id": "host:redis", "severity": "ERROR", "text": "maxmemory reached, evicting keys"}, + {"entry_id": "e2", "source_id": "host:app", "severity": "WARN", "text": "Redis NOEVICTION response"}, +] + + +def _mock_response(status_code: int, body: dict): + resp = MagicMock() + resp.is_success = (status_code < 400) + resp.status_code = status_code + resp.json.return_value = body + resp.text = str(body) + return resp + + +# --------------------------------------------------------------------------- +# available_targets +# --------------------------------------------------------------------------- + +def test_available_targets_lists_known_integrations(): + from app.services.ticket_export import available_targets + targets = available_targets() + assert "notion" in targets + assert "jira" in targets + + +# --------------------------------------------------------------------------- +# export_incident dispatch +# --------------------------------------------------------------------------- + +def test_export_incident_raises_for_unknown_target(): + from app.services.ticket_export import export_incident + with pytest.raises(ValueError, match="Unknown ticket target"): + export_incident("linear", INCIDENT, ENTRIES, {}) + + +# --------------------------------------------------------------------------- +# Notion exporter +# --------------------------------------------------------------------------- + +class TestNotionExport: + def test_successful_export_returns_url_and_id(self): + from app.services.ticket_export import export_incident + page_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + mock_resp = _mock_response(200, {"id": page_id, "url": f"https://notion.so/{page_id}"}) + with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post: + result = export_incident("notion", INCIDENT, ENTRIES, { + "notion_token": "secret_abc123", + "notion_database_id": "db-id-xyz", + }) + assert result["ticket_id"] == page_id + assert "notion.so" in result["url"] + mock_post.assert_called_once() + + def test_raises_value_error_when_not_configured(self): + from app.services.ticket_export import export_incident + with pytest.raises(ValueError, match="Notion not configured"): + export_incident("notion", INCIDENT, ENTRIES, { + "notion_token": "", + "notion_database_id": "db-id", + }) + + def test_raises_value_error_when_database_id_missing(self): + from app.services.ticket_export import export_incident + with pytest.raises(ValueError, match="Notion not configured"): + export_incident("notion", INCIDENT, ENTRIES, { + "notion_token": "secret_abc", + "notion_database_id": "", + }) + + def test_raises_runtime_error_on_api_error(self): + from app.services.ticket_export import export_incident + mock_resp = _mock_response(401, {"message": "Unauthorized"}) + with patch("app.services.ticket_export.httpx.post", return_value=mock_resp): + with pytest.raises(RuntimeError, match="Notion API error 401"): + export_incident("notion", INCIDENT, ENTRIES, { + "notion_token": "bad-token", + "notion_database_id": "db-id", + }) + + def test_sends_correct_database_id(self): + from app.services.ticket_export import export_incident + db_id = "my-database-uuid" + mock_resp = _mock_response(200, {"id": "page-id", "url": "https://notion.so/page-id"}) + with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post: + export_incident("notion", INCIDENT, ENTRIES, { + "notion_token": "secret_abc123", + "notion_database_id": db_id, + }) + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("json") or call_kwargs.args[1] if len(call_kwargs.args) > 1 else call_kwargs.kwargs["json"] + assert payload["parent"]["database_id"] == db_id + + def test_incident_label_becomes_page_title(self): + from app.services.ticket_export import export_incident + mock_resp = _mock_response(200, {"id": "pid", "url": "https://notion.so/pid"}) + with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post: + export_incident("notion", INCIDENT, ENTRIES, { + "notion_token": "tok", + "notion_database_id": "dbid", + }) + payload = mock_post.call_args.kwargs["json"] + title_text = payload["properties"]["title"]["title"][0]["text"]["content"] + assert INCIDENT["label"] in title_text + + def test_url_falls_back_to_constructed_url(self): + from app.services.ticket_export import export_incident + page_id = "abc123" + mock_resp = _mock_response(200, {"id": page_id}) # no 'url' in response + with patch("app.services.ticket_export.httpx.post", return_value=mock_resp): + result = export_incident("notion", INCIDENT, ENTRIES, { + "notion_token": "tok", + "notion_database_id": "dbid", + }) + assert "notion.so" in result["url"] or page_id in result["url"] + + def test_long_text_truncated_to_notion_limit(self): + from app.services.ticket_export import export_incident + mock_resp = _mock_response(200, {"id": "pid", "url": "https://notion.so/pid"}) + long_entries = [{"entry_id": f"e{i}", "source_id": "host:svc", "severity": "ERROR", + "text": "x" * 300} for i in range(60)] + with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post: + export_incident("notion", INCIDENT, long_entries, { + "notion_token": "tok", + "notion_database_id": "dbid", + }) + payload = mock_post.call_args.kwargs["json"] + for block in payload.get("children", []): + for rt in block.get("bulleted_list_item", {}).get("rich_text", []): + assert len(rt["text"]["content"]) <= 2000 + + +# --------------------------------------------------------------------------- +# Jira exporter +# --------------------------------------------------------------------------- + +class TestJiraExport: + _config = { + "jira_url": "https://myorg.atlassian.net", + "jira_email": "ops@example.com", + "jira_api_token": "ATATT3xFfGF0abc123", + "jira_project_key": "OPS", + "jira_issue_type": "Bug", + } + + def test_successful_export_returns_url_and_key(self): + from app.services.ticket_export import export_incident + mock_resp = _mock_response(201, {"id": "10042", "key": "OPS-42", "self": "https://myorg.atlassian.net/rest/api/3/issue/10042"}) + with patch("app.services.ticket_export.httpx.post", return_value=mock_resp): + result = export_incident("jira", INCIDENT, ENTRIES, self._config) + assert result["ticket_id"] == "OPS-42" + assert "OPS-42" in result["url"] + assert "myorg.atlassian.net" in result["url"] + + def test_raises_value_error_when_not_configured(self): + from app.services.ticket_export import export_incident + with pytest.raises(ValueError, match="Jira not configured"): + export_incident("jira", INCIDENT, ENTRIES, { + "jira_url": "", + "jira_email": "a@b.com", + "jira_api_token": "tok", + "jira_project_key": "OPS", + }) + + def test_raises_runtime_error_on_api_error(self): + from app.services.ticket_export import export_incident + mock_resp = _mock_response(403, {"errorMessages": ["Forbidden"]}) + with patch("app.services.ticket_export.httpx.post", return_value=mock_resp): + with pytest.raises(RuntimeError, match="Jira API error 403"): + export_incident("jira", INCIDENT, ENTRIES, self._config) + + def test_sends_basic_auth_header(self): + from app.services.ticket_export import export_incident + import base64 + mock_resp = _mock_response(201, {"key": "OPS-1", "id": "1"}) + with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post: + export_incident("jira", INCIDENT, ENTRIES, self._config) + call_kwargs = mock_post.call_args.kwargs + auth_header = call_kwargs["headers"]["Authorization"] + assert auth_header.startswith("Basic ") + decoded = base64.b64decode(auth_header[6:]).decode() + assert "ops@example.com" in decoded + + def test_uses_correct_project_key(self): + from app.services.ticket_export import export_incident + mock_resp = _mock_response(201, {"key": "OPS-7", "id": "7"}) + with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post: + export_incident("jira", INCIDENT, ENTRIES, self._config) + payload = mock_post.call_args.kwargs["json"] + assert payload["fields"]["project"]["key"] == "OPS" + + def test_incident_label_becomes_summary(self): + from app.services.ticket_export import export_incident + mock_resp = _mock_response(201, {"key": "OPS-8", "id": "8"}) + with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post: + export_incident("jira", INCIDENT, ENTRIES, self._config) + payload = mock_post.call_args.kwargs["json"] + assert payload["fields"]["summary"] == INCIDENT["label"] + + def test_default_issue_type_is_bug(self): + from app.services.ticket_export import export_incident + config = {k: v for k, v in self._config.items() if k != "jira_issue_type"} + mock_resp = _mock_response(201, {"key": "OPS-9", "id": "9"}) + with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post: + export_incident("jira", INCIDENT, ENTRIES, config) + payload = mock_post.call_args.kwargs["json"] + assert payload["fields"]["issuetype"]["name"] == "Bug" diff --git a/web/src/views/IncidentsView.vue b/web/src/views/IncidentsView.vue index f1e8d6d..d954edd 100644 --- a/web/src/views/IncidentsView.vue +++ b/web/src/views/IncidentsView.vue @@ -86,6 +86,29 @@ {{ sending ? 'Sending…' : 'Send Bundle' }} {{ sendStatus.msg }} + +
+ +
+ +
+
+ + {{ exportStatus.msg }} + {{ exportStatus.msg }} + @@ -149,7 +172,7 @@