+ 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"),
+ )