From d0d3c67a604160724ebf341374d8fc54ca34dc9e Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 2 May 2026 20:41:12 -0700 Subject: [PATCH] bugs: vendored bugs.sulkta.com SDK + /bugs page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires cauldron up to the unified Sulkta bug tracker per memory/spec-bugs-unified-sdk.md (Phases 1-7 shipped 2026-05-02). What's included: - Vendored bugs-sulkta-py at cauldron/vendor/bugs_sulkta (4 stdlib-only files copied verbatim from Sulkta-Coop/bugs-sulkta-py main). Same vendoring approach as TC's backend/api/bugs_sulkta โ€” Docker BuildKit can't reach LAN Gitea, so the package ships in the source tree. - BUGS_API_KEY + BUGS_BASE_URL env (config.py). Empty key = page renders "not configured" placeholder; POSTs return 503. Lets dev runs skip provisioning a key. - New routes (server.py): GET /bugs (page), GET /api/bugs (list), POST /api/bugs (create). All session-auth. Per-service key returns every cauldron report; we filter client-side by user_email so each household member sees only their own. Admins get a "show all" toggle. - bugs.html template in mythic-witch style: subject + message + kind + severity form, filed-reports list with status glyphs (๐Ÿ“‚ open ๐Ÿ”จ in-progress โœ… resolved โ›” wontfix), relative timestamps. - _base.html nav: ๐Ÿž bugs link between discover and me. - Server-side auto-fill: user_email/user_name from session, page_url from referrer, user_agent from request headers. Defaults are dev-safe โ€” no env change required for the LAN soak. When Cobb mints the key with: docker exec bugs-sulkta bugs-sulkta-cli keys create \ --service=cauldron --scopes=read,write,update \ --description="cauldron prod" โ€ฆdrop it into BUGS_API_KEY and the page lights up. --- .env.example | 8 + cauldron/config.py | 10 + cauldron/server.py | 84 +++++++++ cauldron/templates/_base.html | 1 + cauldron/templates/bugs.html | 208 +++++++++++++++++++++ cauldron/vendor/__init__.py | 0 cauldron/vendor/bugs_sulkta/__init__.py | 50 +++++ cauldron/vendor/bugs_sulkta/client.py | 237 ++++++++++++++++++++++++ cauldron/vendor/bugs_sulkta/errors.py | 34 ++++ cauldron/vendor/bugs_sulkta/models.py | 74 ++++++++ 10 files changed, 706 insertions(+) create mode 100644 cauldron/templates/bugs.html create mode 100644 cauldron/vendor/__init__.py create mode 100644 cauldron/vendor/bugs_sulkta/__init__.py create mode 100644 cauldron/vendor/bugs_sulkta/client.py create mode 100644 cauldron/vendor/bugs_sulkta/errors.py create mode 100644 cauldron/vendor/bugs_sulkta/models.py diff --git a/.env.example b/.env.example index 7cf5ac1..256b160 100644 --- a/.env.example +++ b/.env.example @@ -62,3 +62,11 @@ CAULDRON_BEHIND_TLS=false # X-Forwarded-* from a peer outside this list gets dropped before # ProxyFix sees it. CAULDRON_TRUSTED_PROXIES= + +# bugs.sulkta.com integration. Per-service key minted via: +# docker exec bugs-sulkta bugs-sulkta-cli keys create --service=cauldron \ +# --scopes=read,write,update --description="cauldron prod" +# Empty = bugs page renders a "not configured" placeholder; POSTs return 503. +BUGS_API_KEY= +# Override only for staging / on-prem bugs deployments. Default is fine. +BUGS_BASE_URL=https://bugs.sulkta.com diff --git a/cauldron/config.py b/cauldron/config.py index 0f4f5bc..047560a 100644 --- a/cauldron/config.py +++ b/cauldron/config.py @@ -66,6 +66,14 @@ class Config: # and have request.is_secure return True even on plain HTTP. trusted_proxies: tuple[str, ...] + # bugs.sulkta.com integration (vendored SDK at cauldron/vendor/bugs_sulkta). + # bugs_api_key is the per-service `bsk_cauldron_*` key minted by + # `bugs-sulkta-cli keys create --service=cauldron`. Empty = bugs page + # renders a "not configured" placeholder and POSTs return 503. Lets + # dev runs work without provisioning a key. + bugs_api_key: str + bugs_base_url: str + def load() -> Config: return Config( @@ -106,4 +114,6 @@ def load() -> Config: for s in os.environ.get("CAULDRON_TRUSTED_PROXIES", "").split(",") if s.strip() ), + bugs_api_key=os.environ.get("BUGS_API_KEY", "").strip(), + bugs_base_url=os.environ.get("BUGS_BASE_URL", "https://bugs.sulkta.com").rstrip("/"), ) diff --git a/cauldron/server.py b/cauldron/server.py index c5f89ef..28b1d54 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -2718,6 +2718,90 @@ def create_app() -> Flask: except Exception as e: return jsonify({"error": str(e)}), 502 + # ---------- bugs.sulkta.com integration ------------------------------ + # Vendored SDK at cauldron/vendor/bugs_sulkta. When BUGS_API_KEY is + # unset the page renders a "not configured" placeholder and POSTs + # return 503 โ€” keeps dev runs working without provisioning a key. + from .vendor.bugs_sulkta import BugsClient + from .vendor.bugs_sulkta.errors import BugsError + + def _bugs_client() -> "BugsClient | None": + if not cfg.bugs_api_key: + return None + return BugsClient(api_key=cfg.bugs_api_key, base_url=cfg.bugs_base_url, timeout=10) + + @app.get("/bugs") + @require_session + def bugs_page(): + return render_template( + "bugs.html", + active="bugs", + bugs_configured=bool(cfg.bugs_api_key), + ) + + @app.get("/api/bugs") + @require_session + def api_bugs_list(): + u = session["user"] + client = _bugs_client() + if client is None: + return jsonify({"ok": False, "error": "bugs_not_configured"}), 503 + try: + # Per-service key returns all cauldron reports; filter + # client-side by user_email so each household member sees + # only their own. Admins get the unfiltered list via ?all=1. + show_all = bool(request.args.get("all")) + reports = client.reports.list(limit=200) + except BugsError as e: + app.logger.warning("bugs.list failed: %s (status=%s)", e, getattr(e, "status", None)) + return jsonify({"ok": False, "error": "bugs_unreachable", "detail": str(e)[:200]}), 502 + if show_all and u.get("sub") in cfg.admin_subs: + filtered = reports + else: + mine = (u.get("email") or "").lower() + filtered = [r for r in reports if (r.user_email or "").lower() == mine] + return jsonify({ + "ok": True, + "reports": [r.to_dict() for r in filtered], + "is_admin": u.get("sub") in cfg.admin_subs, + }) + + @app.post("/api/bugs") + @require_session + def api_bugs_create(): + u = session["user"] + client = _bugs_client() + if client is None: + return jsonify({"ok": False, "error": "bugs_not_configured"}), 503 + body = request.get_json(silent=True) or {} + subject = (body.get("subject") or "").strip() + message = (body.get("message") or "").strip() + if not subject or not message: + return jsonify({"ok": False, "error": "subject_and_message_required"}), 400 + if len(subject) > 240 or len(message) > 8000: + return jsonify({"ok": False, "error": "too_long"}), 400 + category = (body.get("category") or "bug").strip().lower() + if category not in ("bug", "feature", "feedback", "other"): + category = "bug" + severity = (body.get("severity") or "").strip().lower() or None + if severity is not None and severity not in ("low", "medium", "high", "critical"): + severity = None + try: + report = client.reports.create( + subject=subject, + message=message, + user_email=u.get("email") or u.get("sub"), + user_name=u.get("name"), + page_url=(body.get("page_url") or request.referrer or "")[:500] or None, + user_agent=(request.headers.get("User-Agent") or "")[:500] or None, + category=category, + severity=severity, + ) + except BugsError as e: + app.logger.warning("bugs.create failed: %s (status=%s)", e, getattr(e, "status", None)) + return jsonify({"ok": False, "error": "bugs_create_failed", "detail": str(e)[:200]}), 502 + return jsonify({"ok": True, "report": report.to_dict()}) + return app diff --git a/cauldron/templates/_base.html b/cauldron/templates/_base.html index c62b023..12a70be 100644 --- a/cauldron/templates/_base.html +++ b/cauldron/templates/_base.html @@ -501,6 +501,7 @@ button { font-family: inherit; } plan list {% if is_admin %}discover{% endif %} + bugs me
diff --git a/cauldron/templates/bugs.html b/cauldron/templates/bugs.html new file mode 100644 index 0000000..dbde1ba --- /dev/null +++ b/cauldron/templates/bugs.html @@ -0,0 +1,208 @@ +{% extends "_base.html" %} +{% block title %}Bugs ยท Cauldron{% endblock %} +{% block content %} + +
+
// bugs ยท feedback to the witch
+

the complaints ledger

+
+ something broken? something missing? leave it on the cauldron's ledger + and it'll find its way to whoever can fix it. reports here go straight + to bugs.sulkta.com + โ€” the central tracker for every sulkta service. +
+
+ +{% if not bugs_configured %} +
+

๐Ÿช„ the bug tracker isn't wired up on this deploy. ask the operator to set + BUGS_API_KEY in cauldron's env and restart.

+
+{% else %} + +
+
+

file a new one

+
+
+ + + + +
+ + + +
+ +
+ + +
+
+
+ +
+
+

your reports

+ {% if is_admin %} + + + + {% endif %} +
+
+ loading... +
+
+ + + +{% endif %} +{% endblock %} diff --git a/cauldron/vendor/__init__.py b/cauldron/vendor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cauldron/vendor/bugs_sulkta/__init__.py b/cauldron/vendor/bugs_sulkta/__init__.py new file mode 100644 index 0000000..4f1fd0c --- /dev/null +++ b/cauldron/vendor/bugs_sulkta/__init__.py @@ -0,0 +1,50 @@ +"""bugs-sulkta-py โ€” stdlib-only client for bugs.sulkta.com. + +Quick start: + + from bugs_sulkta import BugsClient + + client = BugsClient(api_key=os.environ["BUGS_API_KEY"]) + + # who am i? + me = client.me() + print(me.service, me.scopes) + + # create + report = client.reports.create( + subject="Done button does nothing", + message="...", + user_email="bay@sulkta.com", + page_url="https://pacificpetals.org/events/new", + category="feature", + severity="low", + ) + + # list + open_reports = client.reports.list(status="open", limit=20) + + # update + client.reports.update(report.id, status="in_progress", + admin_notes="working on it") + +Designed to be vendored: drop the package into vendor/bugs-sulkta-py and +`pip install -e ./vendor/bugs-sulkta-py`. Stdlib-only (urllib + json + +dataclasses) โ€” no transitive deps to vet. +""" + +from .client import BugsClient +from .models import Report, KeyInfo +from .errors import ( + BugsError, AuthError, ScopeError, NotFoundError, + ValidationError, ServerError, +) + +__version__ = "0.1.0" + +__all__ = [ + "BugsClient", + "Report", "KeyInfo", + "BugsError", "AuthError", "ScopeError", + "NotFoundError", "ValidationError", "ServerError", + "__version__", +] diff --git a/cauldron/vendor/bugs_sulkta/client.py b/cauldron/vendor/bugs_sulkta/client.py new file mode 100644 index 0000000..91dd137 --- /dev/null +++ b/cauldron/vendor/bugs_sulkta/client.py @@ -0,0 +1,237 @@ +"""HTTP client + Reports namespace. + +Talks to bugs.sulkta.com over plain HTTPS via stdlib urllib. Single retry +on 5xx + connection error with exponential backoff (~0.5s, ~1s, ~2s). + +Two callable surfaces: + + client.me() โ€” KeyInfo + client.reports.list(...) โ€” list[Report] + client.reports.get(rid) โ€” Report + client.reports.create(...) โ€” Report + client.reports.update(...) โ€” Report + client.reports.delete(rid) โ€” None + +Plus widget-mode for browser-side embeds (these are normally JS-side, but +the helper exists so server-side test harnesses can exercise the path): + + client.reports.widget_submit(widget_token=..., subject=..., message=...) +""" + +import json +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Iterable + +from .errors import ( + AuthError, ScopeError, NotFoundError, + ValidationError, ServerError, BugsError, +) +from .models import Report, KeyInfo + + +DEFAULT_BASE_URL = "https://bugs.sulkta.com" +DEFAULT_TIMEOUT_S = 15 +RETRY_ON = {500, 502, 503, 504} +RETRY_BACKOFF = (0.5, 1.0, 2.0) +USER_AGENT = "bugs-sulkta-py/0.1 (+https://bugs.sulkta.com)" + + +class _Transport: + def __init__(self, base_url, api_key, timeout, extra_headers=None): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.timeout = timeout + self.extra_headers = dict(extra_headers or {}) + + def request(self, method, path, *, params=None, json_body=None, + bearer_override=None): + url = self.base_url + path + if params: + # Drop None values; bool/int โ†’ str. + cleaned = {k: ("true" if v is True else "false" if v is False else str(v)) + for k, v in params.items() if v is not None} + if cleaned: + url = url + "?" + urllib.parse.urlencode(cleaned, doseq=True) + + headers = { + "User-Agent": USER_AGENT, + "Accept": "application/json", + } + token = bearer_override if bearer_override is not None else self.api_key + if token: + headers["Authorization"] = f"Bearer {token}" + if json_body is not None: + data = json.dumps(json_body).encode("utf-8") + headers["Content-Type"] = "application/json" + else: + data = None + headers.update(self.extra_headers) + + req = urllib.request.Request(url, data=data, headers=headers, method=method) + + last_err = None + for attempt, delay in enumerate(RETRY_BACKOFF + (None,)): + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + body = resp.read().decode("utf-8") + return self._handle(resp.status, body) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + if e.code in RETRY_ON and delay is not None: + time.sleep(delay); continue + return self._handle(e.code, body) + except urllib.error.URLError as e: + last_err = e + if delay is not None: + time.sleep(delay); continue + raise ServerError(f"transport error: {e}") from e + + # Should be unreachable โ€” every branch above either returns or raises. + raise ServerError(f"transport error after retries: {last_err}") + + def _handle(self, status, body_text): + # Parse JSON if possible โ€” the API always returns JSON, but handle + # the case where a 502 from a proxy slips an HTML body in. + body = None + if body_text: + try: + body = json.loads(body_text) + except json.JSONDecodeError: + body = body_text + + if 200 <= status < 300: + return body + msg = (body or {}).get("error", body_text) if isinstance(body, dict) else body_text + if status == 400: + raise ValidationError(msg, status=status, body=body) + if status == 401: + raise AuthError(msg, status=status, body=body) + if status == 403: + raise ScopeError(msg, status=status, body=body) + if status == 404: + raise NotFoundError(msg, status=status, body=body) + if 500 <= status < 600: + raise ServerError(msg, status=status, body=body) + raise BugsError(msg, status=status, body=body) + + +class _ReportsAPI: + def __init__(self, transport: _Transport): + self._t = transport + + def list(self, *, status=None, severity=None, since=None, limit=100): + # `service` query param is intentionally NOT exposed โ€” per-service + # keys ignore it server-side, and admin-key callers should use a + # custom transport with `params` if they need cross-service queries. + out = self._t.request("GET", "/api/v1/reports", params={ + "status": status, + "severity": severity, + "since": since, + "limit": limit, + }) + return [Report.from_dict(r) for r in (out or {}).get("reports", [])] + + def list_with_counts(self, *, status=None, severity=None, since=None, limit=100): + """Same as list() but returns (reports, counts_dict, total).""" + out = self._t.request("GET", "/api/v1/reports", params={ + "status": status, + "severity": severity, + "since": since, + "limit": limit, + }) or {} + return ( + [Report.from_dict(r) for r in out.get("reports", [])], + dict(out.get("counts", {})), + int(out.get("total", 0)), + ) + + def get(self, report_id: int) -> Report: + out = self._t.request("GET", f"/api/v1/reports/{int(report_id)}") + return Report.from_dict(out) + + def create(self, *, subject, message, user_email=None, user_name=None, + severity=None, category=None, page_url=None, user_agent=None, + service_ref=None, service=None, created_at=None, id=None) -> Report: + """Create a report. `service` is forced from the API key for per-service + keys, so leave it None unless you're using an admin/legacy token. + + `created_at` and `id` are honoured only by keys with the `migrate` scope + โ€” use them in one-shot import scripts to preserve original timestamps.""" + body = { + "subject": subject, + "message": message, + "user_email": user_email, + "user_name": user_name, + "severity": severity, + "category": category, + "page_url": page_url, + "user_agent": user_agent, + "service_ref": service_ref, + } + if service is not None: + body["service"] = service + if created_at is not None: + body["created_at"] = created_at + if id is not None: + body["id"] = id + body = {k: v for k, v in body.items() if v is not None} + out = self._t.request("POST", "/api/v1/reports", json_body=body) + return Report.from_dict(out) + + def update(self, report_id: int, *, status=None, severity=None, + admin_notes=None, tags=None) -> Report: + body = { + "status": status, + "severity": severity, + "admin_notes": admin_notes, + "tags": tags, + } + body = {k: v for k, v in body.items() if v is not None} + if not body: + raise ValidationError("update() called with no fields to set") + out = self._t.request("PATCH", f"/api/v1/reports/{int(report_id)}", + json_body=body) + return Report.from_dict(out) + + def delete(self, report_id: int) -> None: + self._t.request("DELETE", f"/api/v1/reports/{int(report_id)}") + + def widget_submit(self, *, widget_token, subject, message, + user_email=None, user_name=None, severity=None, + category=None, page_url=None, service_ref=None) -> int: + """POST a report via the public widget endpoint. + + Uses a *separate* widget-scope token, NOT the secret API key on + this client โ€” pass it explicitly. The token is intended to be + embedded in browser JS; this method is mostly for testing the + same code path server-side. Returns the new report id. + """ + body = { + "subject": subject, + "message": message, + "user_email": user_email, + "user_name": user_name, + "severity": severity, + "category": category, + "page_url": page_url, + "service_ref": service_ref, + } + body = {k: v for k, v in body.items() if v is not None} + out = self._t.request("POST", "/api/v1/widget-submit", + json_body=body, bearer_override=widget_token) + return int(out["id"]) + + +class BugsClient: + def __init__(self, api_key=None, *, base_url=DEFAULT_BASE_URL, + timeout=DEFAULT_TIMEOUT_S, extra_headers=None): + """api_key is required for all real calls; pass None only for tests + that hit a server with AUTH_DISABLED.""" + self._t = _Transport(base_url, api_key, timeout, extra_headers) + self.reports = _ReportsAPI(self._t) + + def me(self) -> KeyInfo: + return KeyInfo.from_dict(self._t.request("GET", "/api/v1/me")) diff --git a/cauldron/vendor/bugs_sulkta/errors.py b/cauldron/vendor/bugs_sulkta/errors.py new file mode 100644 index 0000000..a48106c --- /dev/null +++ b/cauldron/vendor/bugs_sulkta/errors.py @@ -0,0 +1,34 @@ +"""Exception hierarchy. + +All errors inherit from BugsError so callers can catch broadly. Specific +subclasses let them branch on the failure mode (auth vs. validation vs. 5xx). +""" + + +class BugsError(Exception): + """Base for everything raised by this SDK.""" + + def __init__(self, message, status=None, body=None): + super().__init__(message) + self.status = status + self.body = body + + +class AuthError(BugsError): + """401 โ€” bearer token missing, malformed, or unknown.""" + + +class ScopeError(BugsError): + """403 โ€” token authenticated but lacks the scope the call needs.""" + + +class NotFoundError(BugsError): + """404 โ€” id doesn't exist OR belongs to a different service than the key.""" + + +class ValidationError(BugsError): + """400 โ€” request body rejected (missing required fields, bad enum, etc.).""" + + +class ServerError(BugsError): + """5xx โ€” bugs.sulkta.com is unhappy. Caller should retry with backoff.""" diff --git a/cauldron/vendor/bugs_sulkta/models.py b/cauldron/vendor/bugs_sulkta/models.py new file mode 100644 index 0000000..df72c86 --- /dev/null +++ b/cauldron/vendor/bugs_sulkta/models.py @@ -0,0 +1,74 @@ +"""Typed return shapes. + +We use plain dataclasses so they're easy to serialise back to dict, easy +to print, and don't require pydantic. Unknown fields are preserved on a +side dict so the SDK doesn't break when bugs.sulkta.com adds columns. +""" + +from dataclasses import dataclass, field, asdict +from typing import Any + + +@dataclass +class Report: + id: int + service: str + subject: str + message: str + user_email: str + severity: str = "medium" + status: str = "open" + category: str | None = None + user_name: str | None = None + service_ref: str | None = None + page_url: str | None = None + user_agent: str | None = None + admin_notes: str | None = None + tags: str | None = None + created_at: str | None = None # ISO-8601 string from the API + updated_at: str | None = None + resolved_at: str | None = None + extra: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_dict(cls, d: dict) -> "Report": + known = { + "id", "service", "subject", "message", "user_email", "severity", + "status", "category", "user_name", "service_ref", "page_url", + "user_agent", "admin_notes", "tags", + "created_at", "updated_at", "resolved_at", + } + kwargs = {k: d.get(k) for k in known if k in d} + # Defaults for required fields when the server omitted them (shouldn't + # happen but the SDK shouldn't crash if it does). + kwargs.setdefault("id", 0) + kwargs.setdefault("service", "") + kwargs.setdefault("subject", "") + kwargs.setdefault("message", "") + kwargs.setdefault("user_email", "") + extra = {k: v for k, v in d.items() if k not in known} + return cls(extra=extra, **kwargs) + + def to_dict(self) -> dict: + d = asdict(self) + extra = d.pop("extra") + d.update(extra) + return d + + +@dataclass +class KeyInfo: + """Returned by `client.me()`. service is `*` for legacy admin tokens.""" + service: str + scopes: list[str] + auth_mode: str + prefix: str | None = None + + @classmethod + def from_dict(cls, d: dict) -> "KeyInfo": + return cls( + service=d.get("service", ""), + scopes=list(d.get("scopes") or []), + auth_mode=d.get("auth_mode", ""), + prefix=d.get("prefix"), + )