feat: incident ticket export — Notion and Jira integration (#12)
- 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
This commit is contained in:
parent
b8f766fb74
commit
6039ab2464
5 changed files with 672 additions and 2 deletions
56
app/rest.py
56
app/rest.py
|
|
@ -307,6 +307,13 @@ class SettingsBody(BaseModel):
|
||||||
pihole_api_key: str | None = None
|
pihole_api_key: str | None = None
|
||||||
router_source_ids: str | None = None
|
router_source_ids: str | None = None
|
||||||
device_names: 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):
|
class IncidentCreate(BaseModel):
|
||||||
|
|
@ -618,6 +625,20 @@ def patch_settings(body: SettingsBody) -> dict:
|
||||||
prefs["router_source_ids"] = body.router_source_ids
|
prefs["router_source_ids"] = body.router_source_ids
|
||||||
if body.device_names is not None:
|
if body.device_names is not None:
|
||||||
prefs["device_names"] = body.device_names
|
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)
|
_save_prefs(prefs)
|
||||||
return 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
|
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")
|
@router.post("/api/bundles")
|
||||||
def receive_bundle(bundle: dict) -> dict:
|
def receive_bundle(bundle: dict) -> dict:
|
||||||
record = store_bundle(INCIDENTS_DB_PATH, bundle)
|
record = store_bundle(INCIDENTS_DB_PATH, bundle)
|
||||||
|
|
|
||||||
213
app/services/ticket_export.py
Normal file
213
app/services/ticket_export.py
Normal file
|
|
@ -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}")
|
||||||
224
tests/test_ticket_export.py
Normal file
224
tests/test_ticket_export.py
Normal file
|
|
@ -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"
|
||||||
|
|
@ -86,6 +86,29 @@
|
||||||
{{ sending ? 'Sending…' : 'Send Bundle' }}
|
{{ sending ? 'Sending…' : 'Send Bundle' }}
|
||||||
</button>
|
</button>
|
||||||
<span v-if="sendStatus" :class="sendStatus.ok ? 'text-green-500' : 'text-sev-error'" class="text-xs">{{ sendStatus.msg }}</span>
|
<span v-if="sendStatus" :class="sendStatus.ok ? 'text-green-500' : 'text-sev-error'" class="text-xs">{{ sendStatus.msg }}</span>
|
||||||
|
<!-- Export to ticket tracker -->
|
||||||
|
<div class="relative" ref="exportMenuRef">
|
||||||
|
<button
|
||||||
|
@click="exportMenuOpen = !exportMenuOpen"
|
||||||
|
:disabled="exporting"
|
||||||
|
class="px-3 py-1.5 text-xs rounded border border-surface-border text-text-muted hover:text-accent hover:border-accent transition-colors disabled:opacity-40"
|
||||||
|
>{{ exporting ? 'Exporting…' : 'Export ticket ▾' }}</button>
|
||||||
|
<div
|
||||||
|
v-if="exportMenuOpen"
|
||||||
|
class="absolute right-0 top-full mt-1 w-32 bg-surface border border-surface-border rounded shadow-lg z-10"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="target in exportTargets"
|
||||||
|
:key="target.key"
|
||||||
|
@click="exportTicket(selected!.id, target.key)"
|
||||||
|
class="block w-full text-left px-3 py-2 text-xs text-text-primary hover:bg-surface-raised transition-colors"
|
||||||
|
>{{ target.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-if="exportStatus" :class="exportStatus.ok ? 'text-green-400' : 'text-sev-error'" class="text-xs">
|
||||||
|
<a v-if="exportStatus.url" :href="exportStatus.url" target="_blank" rel="noopener" class="underline">{{ exportStatus.msg }}</a>
|
||||||
|
<span v-else>{{ exportStatus.msg }}</span>
|
||||||
|
</span>
|
||||||
<button @click="selected = null" class="text-text-dim hover:text-text-primary text-xs ml-auto sm:ml-0">✕ close</button>
|
<button @click="selected = null" class="text-text-dim hover:text-text-primary text-xs ml-auto sm:ml-0">✕ close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -149,7 +172,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import IncidentTimeline from '@/components/IncidentTimeline.vue'
|
import IncidentTimeline from '@/components/IncidentTimeline.vue'
|
||||||
|
|
||||||
|
|
@ -238,6 +261,47 @@ async function sendBundle(id: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ticket export ─────────────────────────────────────────────
|
||||||
|
const exportTargets = [
|
||||||
|
{ key: 'notion', label: 'Notion' },
|
||||||
|
{ key: 'jira', label: 'Jira' },
|
||||||
|
]
|
||||||
|
const exporting = ref(false)
|
||||||
|
const exportMenuOpen = ref(false)
|
||||||
|
const exportMenuRef = ref<HTMLElement | null>(null)
|
||||||
|
const exportStatus = ref<{ ok: boolean; msg: string; url?: string } | null>(null)
|
||||||
|
|
||||||
|
function handleExportClickOutside(e: MouseEvent) {
|
||||||
|
if (exportMenuRef.value && !exportMenuRef.value.contains(e.target as Node)) {
|
||||||
|
exportMenuOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(() => { document.addEventListener('click', handleExportClickOutside) })
|
||||||
|
onBeforeUnmount(() => { document.removeEventListener('click', handleExportClickOutside) })
|
||||||
|
|
||||||
|
async function exportTicket(incident_id: string, target: string) {
|
||||||
|
exportMenuOpen.value = false
|
||||||
|
exporting.value = true
|
||||||
|
exportStatus.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}/api/incidents/${incident_id}/export`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ target }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
exportStatus.value = { ok: true, msg: `Created ${data.ticket_id} →`, url: data.url }
|
||||||
|
} else {
|
||||||
|
exportStatus.value = { ok: false, msg: data.detail ?? 'Export failed' }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
exportStatus.value = { ok: false, msg: 'Network error' }
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── timeline interaction ──────────────────────────────────────
|
// ── timeline interaction ──────────────────────────────────────
|
||||||
const highlightIdx = ref<number | null>(null)
|
const highlightIdx = ref<number | null>(null)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -394,6 +394,88 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Ticket Trackers -->
|
||||||
|
<div>
|
||||||
|
<h2 class="text-text-primary text-sm font-semibold mb-1">Ticket Trackers</h2>
|
||||||
|
<p class="text-text-dim text-xs mb-4">
|
||||||
|
Connect external issue trackers to export incidents with one click from the Incidents view.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Notion -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-text-primary text-xs font-medium mb-2">Notion</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="block text-xs text-text-dim mb-1">Integration token</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input v-model="prefs.notion_token" :type="showNotionToken ? 'text' : 'password'"
|
||||||
|
placeholder="secret_xxxxxxxxxxxx"
|
||||||
|
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent pr-14" />
|
||||||
|
<button @click="showNotionToken = !showNotionToken"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-text-dim hover:text-accent">
|
||||||
|
{{ showNotionToken ? 'hide' : 'show' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="block text-xs text-text-dim mb-1">Database ID</label>
|
||||||
|
<input v-model="prefs.notion_database_id" type="text"
|
||||||
|
placeholder="8-4-4-4-12 UUID from the database URL"
|
||||||
|
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jira -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-text-primary text-xs font-medium mb-2">Jira</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="block text-xs text-text-dim mb-1">Jira URL</label>
|
||||||
|
<input v-model="prefs.jira_url" type="url"
|
||||||
|
placeholder="https://yourorg.atlassian.net"
|
||||||
|
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-text-dim mb-1">Account email</label>
|
||||||
|
<input v-model="prefs.jira_email" type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-text-dim mb-1">API token</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input v-model="prefs.jira_api_token" :type="showJiraToken ? 'text' : 'password'"
|
||||||
|
placeholder="Atlassian API token"
|
||||||
|
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent pr-14" />
|
||||||
|
<button @click="showJiraToken = !showJiraToken"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-text-dim hover:text-accent">
|
||||||
|
{{ showJiraToken ? 'hide' : 'show' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-text-dim mb-1">Project key</label>
|
||||||
|
<input v-model="prefs.jira_project_key" type="text"
|
||||||
|
placeholder="OPS"
|
||||||
|
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-text-dim mb-1">Issue type</label>
|
||||||
|
<input v-model="prefs.jira_issue_type" type="text"
|
||||||
|
placeholder="Bug"
|
||||||
|
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="saveTicketTrackers"
|
||||||
|
class="px-4 py-2 bg-accent text-surface text-sm rounded font-medium hover:opacity-90 transition-opacity">
|
||||||
|
Save tracker settings
|
||||||
|
</button>
|
||||||
|
<span v-if="ticketSaveStatus" :class="ticketSaveStatus.ok ? 'text-green-400' : 'text-sev-error'" class="text-xs ml-3">{{ ticketSaveStatus.msg }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="saveStatus"
|
v-if="saveStatus"
|
||||||
role="status"
|
role="status"
|
||||||
|
|
@ -432,6 +514,13 @@ interface Prefs {
|
||||||
pihole_api_key: string
|
pihole_api_key: string
|
||||||
router_source_ids: string
|
router_source_ids: string
|
||||||
device_names: string
|
device_names: string
|
||||||
|
notion_token: string
|
||||||
|
notion_database_id: string
|
||||||
|
jira_url: string
|
||||||
|
jira_email: string
|
||||||
|
jira_api_token: string
|
||||||
|
jira_project_key: string
|
||||||
|
jira_issue_type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SshTarget {
|
interface SshTarget {
|
||||||
|
|
@ -481,12 +570,15 @@ async function setTechLevel(level: 'homelab' | 'sysadmin' | 'executive') {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefs = ref<Prefs>({ entry_point_style: 'topbar', llm_url: '', llm_model: '', llm_api_key: '', tech_level: 'sysadmin', severity_overrides: [], pihole_url: '', pihole_version: 'v6', pihole_api_key: '', router_source_ids: '', device_names: '' })
|
const prefs = ref<Prefs>({ entry_point_style: 'topbar', llm_url: '', llm_model: '', llm_api_key: '', tech_level: 'sysadmin', severity_overrides: [], pihole_url: '', pihole_version: 'v6', pihole_api_key: '', router_source_ids: '', device_names: '', notion_token: '', notion_database_id: '', jira_url: '', jira_email: '', jira_api_token: '', jira_project_key: '', jira_issue_type: 'Bug' })
|
||||||
const saveStatus = ref<{ ok: boolean; msg: string } | null>(null)
|
const saveStatus = ref<{ ok: boolean; msg: string } | null>(null)
|
||||||
const showAddOverride = ref(false)
|
const showAddOverride = ref(false)
|
||||||
const showApiKey = ref(false)
|
const showApiKey = ref(false)
|
||||||
const showPiholeKey = ref(false)
|
const showPiholeKey = ref(false)
|
||||||
|
const showNotionToken = ref(false)
|
||||||
|
const showJiraToken = ref(false)
|
||||||
const piholeStatus = ref<{ ok: boolean; msg: string } | null>(null)
|
const piholeStatus = ref<{ ok: boolean; msg: string } | null>(null)
|
||||||
|
const ticketSaveStatus = ref<{ ok: boolean; msg: string } | null>(null)
|
||||||
const newRule = ref<SeverityOverride>({ name: '', pattern: '', override_severity: 'WARN', enabled: true })
|
const newRule = ref<SeverityOverride>({ name: '', pattern: '', override_severity: 'WARN', enabled: true })
|
||||||
|
|
||||||
// SSH targets
|
// SSH targets
|
||||||
|
|
@ -633,6 +725,27 @@ async function testPihole() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Ticket tracker settings ---
|
||||||
|
|
||||||
|
async function saveTicketTrackers() {
|
||||||
|
ticketSaveStatus.value = null
|
||||||
|
try {
|
||||||
|
await patch({
|
||||||
|
notion_token: prefs.value.notion_token,
|
||||||
|
notion_database_id: prefs.value.notion_database_id,
|
||||||
|
jira_url: prefs.value.jira_url,
|
||||||
|
jira_email: prefs.value.jira_email,
|
||||||
|
jira_api_token: prefs.value.jira_api_token,
|
||||||
|
jira_project_key: prefs.value.jira_project_key,
|
||||||
|
jira_issue_type: prefs.value.jira_issue_type,
|
||||||
|
})
|
||||||
|
ticketSaveStatus.value = { ok: true, msg: 'Tracker settings saved' }
|
||||||
|
setTimeout(() => { ticketSaveStatus.value = null }, 2000)
|
||||||
|
} catch {
|
||||||
|
ticketSaveStatus.value = { ok: false, msg: 'Save failed — check server connection' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- SSH target management ---
|
// --- SSH target management ---
|
||||||
|
|
||||||
async function loadSshTargets() {
|
async function loadSshTargets() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue