"""Tickets: known problems, open until someone resolves them, worked from MCP the same as from the Tickets pages (steward.tickets.service).""" from __future__ import annotations from steward.mcp._context import current_caller from steward.models.users import UserRole from steward.tickets import service as tk from steward.timeline import config as tl_config from ._audit import audit_mcp, mcp_username from ._shape import age_s, bounded, clamp_limit, db_session, iso from .timeline import area_key, host_hint LIST_MAX = 200 def brief(t) -> dict: """A ticket in a list, or carried by another tool's reply (get_overview, get_host, get_monitor, get_container, list_alerts, list_anomalies).""" return {"ticket": t.id, "title": t.title, "status": t.status, "area": t.area, "hosts": t.hosts, "subject": t.subject, "opened_at": iso(t.opened_at), "opened_by": t.opened_by, "updated_at": iso(t.updated_at), "age_s": age_s(t.opened_at), "resolved_at": iso(t.resolved_at), "resolved_by": t.resolved_by} async def open_tickets_for(db, *, host: str = "", subject: str = "") -> list[dict]: return [brief(t) for t in await tk.for_page(db, host=host, subject=subject)] async def _full(db, t) -> dict: log = await tk.notes(db, t.id) return { **brief(t), "description": t.body, "resolution": t.resolution, "links": await tk.link_views(db, await tk.links_of(db, t.id)), "log": [{"at": iso(n.at), "kind": n.kind, "by": n.actor, "text": n.body} for n in log], "url": f"/tickets/{t.id}", } async def _ticket(db, ticket): t = await tk.get(db, ticket) if t is None: raise ValueError(f"No ticket #{str(ticket).lstrip('#')}; list_tickets lists them.") return t def _hosts(hosts) -> list[str]: if isinstance(hosts, str): hosts = hosts.split(",") return [h.strip() for h in (hosts or []) if h and h.strip()] def _links(alert_rules=None, anomalies=None) -> list[tuple[str, str]]: def ids(v): return [i.strip() for i in (v.split(",") if isinstance(v, str) else (v or [])) if i and str(i).strip()] return [("alert_rule", i) for i in ids(alert_rules)] + [("anomaly", i) for i in ids(anomalies)] async def list_tickets(status: str = "open", host: str = "", subject: str = "", area: str = "", limit: int = 50) -> dict: """Tickets: known problems someone recorded, open until resolved. Open ones come first, most recently worked on first. Read the open tickets before reporting what is wrong: an alert or anomaly an open ticket covers is already known (list_alerts and list_anomalies mark it `known_issue`), and the ticket says what has been tried. status: "open" (default), "resolved" or "all". host: a host's name, exactly; empty for every host. subject: what a ticket is about, exactly (a resource, "/", a monitor's name); empty for any. area: an area key (get_timeline's `areas`); empty for all. limit: default 50, max 200. """ if status not in (*tk.STATUSES, "all"): raise ValueError('status is "open", "resolved" or "all".') limit = clamp_limit(limit, 50, LIST_MAX) async with db_session() as db: rows = list((await db.execute(tk.query(status=status, host=host.strip(), subject=subject.strip(), area=area.strip()).limit(limit + 1))).scalars()) open_n = await tk.open_count(db) out = {**bounded([brief(t) for t in rows], limit, key="tickets"), "open_total": open_n} if out["truncated"]: out["total"] = None out["more"] = f"More than {limit} tickets. Pass a larger `limit` (max {LIST_MAX}) or filter." return out async def get_ticket(ticket: int | str) -> dict: """One ticket in full: its description, what it links to (alert rules, anomalies), its resolution, and its log of comments, edits, resolving and reopening, oldest first. ticket: its number (12 or "#12"). """ async with db_session() as db: return await _full(db, await _ticket(db, ticket)) async def open_ticket(title: str, area: str, description: str = "", hosts: list[str] | str | None = None, subject: str = "", alert_rules: list[str] | str | None = None, anomalies: list[str] | str | None = None) -> dict: """Open a ticket for a known problem that will take more than a moment to fix: a disk failing slowly, a service that keeps restarting, a backup that times out at night. Acknowledging an alert only quiets it; a ticket keeps what is known, what was tried and, once resolved, what fixed it, and it marks the alerts and anomalies it covers as a known issue. Check list_tickets first for one already open on it. title: what is wrong, one line (up to 200 characters). area: what part of the infrastructure it is about, by key or label: service, host_os, hardware, storage, network, security, monitoring (get_timeline's `areas`). description: what is seen, since when, what has been tried (optional). hosts: the host(s) it is on, by exact name (list_hosts). subject: what it is about when that is not a host as a whole: an alert rule's resource, "/", a monitor's name. An alert or anomaly on this resource reads as a known issue while it is open. alert_rules: alert rule ids to link (list_alerts' `rule_id`). anomalies: anomaly ids to link (list_anomalies' `anomaly_id`). Opening it goes on the timeline. Recorded in the audit log as " via MCP". """ caller = current_caller() async with db_session() as db: async with db.begin(): cfg = await tl_config.load(db) try: f = await tk.fields(db, cfg, title=title, area=area_key(cfg, area), body=description, hosts=_hosts(hosts), subject=subject) except ValueError as exc: raise host_hint(exc) from None links = await tk.check_links(db, _links(alert_rules, anomalies)) t = await tk.open_ticket(db, f, actor=mcp_username(caller.username), author_id=caller.user_id, links=links) out = await _full(db, t) await audit_mcp("ticket.opened", "ticket", str(t.id), {"name": t.title, "hosts": t.hosts, "links": [f"{r}:{i}" for r, i in links]}) return out async def update_ticket(ticket: int | str, title: str | None = None, area: str | None = None, description: str | None = None, hosts: list[str] | str | None = None, subject: str | None = None, link_alert_rules: list[str] | str | None = None, link_anomalies: list[str] | str | None = None, unlink_alert_rules: list[str] | str | None = None, unlink_anomalies: list[str] | str | None = None) -> dict: """Change a ticket: its title, area, description, hosts or subject, and what it links to. Only what you pass changes; an empty string clears the description or subject, an empty list clears the hosts. To add to the log, use comment_ticket; to close it, resolve_ticket. ticket: its number. The rest as open_ticket; link_*/unlink_* take ids. Recorded in the ticket's log and the audit log. """ caller = current_caller() async with db_session() as db: async with db.begin(): t = await _ticket(db, ticket) cfg = await tl_config.load(db) try: f = await tk.fields(db, cfg, title=t.title if title is None else title, area=t.area if area is None else area_key(cfg, area), body=t.body if description is None else description, hosts=t.hosts if hosts is None else _hosts(hosts), subject=t.subject if subject is None else subject) except ValueError as exc: raise host_hint(exc) from None add = await tk.check_links(db, _links(link_alert_rules, link_anomalies)) changed = await tk.edit(db, t, f, actor=mcp_username(caller.username), add_links=add, remove_links=_links(unlink_alert_rules, unlink_anomalies)) out = await _full(db, t) if changed: await audit_mcp("ticket.edited", "ticket", str(t.id), {"name": t.title, "changed": changed}) return {**out, "changed": changed} async def comment_ticket(ticket: int | str, comment: str) -> dict: """Add a comment to a ticket's log: what you found, what you tried, what happened. Works on a resolved ticket too. ticket: its number. comment: up to 8000 characters. Recorded in the audit log. """ return await _act(ticket, "ticket.commented", tk.comment, comment) async def resolve_ticket(ticket: int | str, resolution: str) -> dict: """Resolve an open ticket. `resolution` says what fixed it, or why it no longer matters; it goes on the timeline as the record of the fix, so write it for someone reading the host's history later. ticket: its number. Recorded in the audit log. """ return await _act(ticket, "ticket.resolved", tk.resolve, resolution) async def reopen_ticket(ticket: int | str, reason: str = "") -> dict: """Reopen a resolved ticket: the problem is back, or the fix did not hold. `reason` says why (optional). Goes on the timeline; recorded in the audit log. """ return await _act(ticket, "ticket.reopened", tk.reopen, reason) async def _act(ticket, action: str, fn, text: str) -> dict: caller = current_caller() async with db_session() as db: async with db.begin(): t = await _ticket(db, ticket) await fn(db, t, text, actor=mcp_username(caller.username)) out = await _full(db, t) await audit_mcp(action, "ticket", str(t.id), {"name": t.title}) return out def register(register_tool) -> None: register_tool(list_tickets, role=UserRole.viewer, read_only=True) register_tool(get_ticket, role=UserRole.viewer, read_only=True) for fn in (open_ticket, update_ticket, comment_ticket, resolve_ticket, reopen_ticket): register_tool(fn, role=UserRole.operator, read_only=False)