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
|
||||
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)
|
||||
|
|
|
|||
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' }}
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -149,7 +172,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
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 ──────────────────────────────────────
|
||||
const highlightIdx = ref<number | null>(null)
|
||||
|
||||
|
|
|
|||
|
|
@ -394,6 +394,88 @@
|
|||
</button>
|
||||
</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
|
||||
v-if="saveStatus"
|
||||
role="status"
|
||||
|
|
@ -432,6 +514,13 @@ interface Prefs {
|
|||
pihole_api_key: string
|
||||
router_source_ids: 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 {
|
||||
|
|
@ -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 showAddOverride = ref(false)
|
||||
const showApiKey = ref(false)
|
||||
const showPiholeKey = ref(false)
|
||||
const showNotionToken = ref(false)
|
||||
const showJiraToken = ref(false)
|
||||
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 })
|
||||
|
||||
// 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 ---
|
||||
|
||||
async function loadSshTargets() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue