bugs: vendored bugs.sulkta.com SDK + /bugs page

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.
This commit is contained in:
Kayos 2026-05-02 20:41:12 -07:00
parent 9f261e6b9e
commit d0d3c67a60
10 changed files with 706 additions and 0 deletions

View file

@ -62,3 +62,11 @@ CAULDRON_BEHIND_TLS=false
# X-Forwarded-* from a peer outside this list gets dropped before # X-Forwarded-* from a peer outside this list gets dropped before
# ProxyFix sees it. # ProxyFix sees it.
CAULDRON_TRUSTED_PROXIES= 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

View file

@ -66,6 +66,14 @@ class Config:
# and have request.is_secure return True even on plain HTTP. # and have request.is_secure return True even on plain HTTP.
trusted_proxies: tuple[str, ...] 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: def load() -> Config:
return Config( return Config(
@ -106,4 +114,6 @@ def load() -> Config:
for s in os.environ.get("CAULDRON_TRUSTED_PROXIES", "").split(",") for s in os.environ.get("CAULDRON_TRUSTED_PROXIES", "").split(",")
if s.strip() 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("/"),
) )

View file

@ -2718,6 +2718,90 @@ def create_app() -> Flask:
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 502 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 return app

View file

@ -501,6 +501,7 @@ button { font-family: inherit; }
<a href="/plan" class="{% if active == 'plan' %}active{% endif %}">plan</a> <a href="/plan" class="{% if active == 'plan' %}active{% endif %}">plan</a>
<a href="/list" class="{% if active == 'list' %}active{% endif %}">list</a> <a href="/list" class="{% if active == 'list' %}active{% endif %}">list</a>
{% if is_admin %}<a href="/discover" class="{% if active == 'discover' %}active{% endif %}">discover</a>{% endif %} {% if is_admin %}<a href="/discover" class="{% if active == 'discover' %}active{% endif %}">discover</a>{% endif %}
<a href="/bugs" class="{% if active == 'bugs' %}active{% endif %}">bugs</a>
<a href="/me" class="{% if active == 'me' %}active{% endif %}">me</a> <a href="/me" class="{% if active == 'me' %}active{% endif %}">me</a>
</nav> </nav>
<div class="topmeta"> <div class="topmeta">

View file

@ -0,0 +1,208 @@
{% extends "_base.html" %}
{% block title %}Bugs · Cauldron{% endblock %}
{% block content %}
<div class="page-head">
<div class="crumb">// bugs · feedback to the witch</div>
<h1>the <span class="accent">complaints</span> ledger</h1>
<div class="lede">
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 <a href="https://bugs.sulkta.com" target="_blank" rel="noopener">bugs.sulkta.com</a>
— the central tracker for every sulkta service.
</div>
</div>
{% if not bugs_configured %}
<section class="panel">
<p>🪄 the bug tracker isn't wired up on this deploy. ask the operator to set
<code>BUGS_API_KEY</code> in cauldron's env and restart.</p>
</section>
{% else %}
<section class="panel purple">
<div class="panel-head">
<h2>file a new one</h2>
</div>
<form id="bug-form" style="display: flex; flex-direction: column; gap: 12px;">
<label style="display:flex; flex-direction:column; gap:4px;">
<span class="muted" style="font-family:var(--mono); font-size:11px; letter-spacing:.1em; text-transform:uppercase;">subject</span>
<input id="bf-subject" type="text" maxlength="240" required
placeholder="meal plan generate spins forever"
style="padding:8px 10px; background:var(--bg-1); color:var(--bone); border:1px solid var(--line); border-radius:6px; font-family:var(--serif);">
</label>
<label style="display:flex; flex-direction:column; gap:4px;">
<span class="muted" style="font-family:var(--mono); font-size:11px; letter-spacing:.1em; text-transform:uppercase;">what happened</span>
<textarea id="bf-message" rows="6" maxlength="8000" required
placeholder="hit generate, button shows 'thinking' for 2 minutes, never returns..."
style="padding:8px 10px; background:var(--bg-1); color:var(--bone); border:1px solid var(--line); border-radius:6px; font-family:var(--mono); resize:vertical;"></textarea>
</label>
<div style="display:flex; gap:14px; flex-wrap:wrap;">
<label style="display:flex; flex-direction:column; gap:4px; flex:1; min-width:140px;">
<span class="muted" style="font-family:var(--mono); font-size:11px; letter-spacing:.1em; text-transform:uppercase;">kind</span>
<select id="bf-category" style="padding:8px 10px; background:var(--bg-1); color:var(--bone); border:1px solid var(--line); border-radius:6px; font-family:var(--mono);">
<option value="bug" selected>🐞 bug</option>
<option value="feature">✨ feature</option>
<option value="feedback">💬 feedback</option>
<option value="other">🌀 other</option>
</select>
</label>
<label style="display:flex; flex-direction:column; gap:4px; flex:1; min-width:140px;">
<span class="muted" style="font-family:var(--mono); font-size:11px; letter-spacing:.1em; text-transform:uppercase;">severity</span>
<select id="bf-severity" style="padding:8px 10px; background:var(--bg-1); color:var(--bone); border:1px solid var(--line); border-radius:6px; font-family:var(--mono);">
<option value=""></option>
<option value="low">low</option>
<option value="medium" selected>medium</option>
<option value="high">high</option>
<option value="critical">critical</option>
</select>
</label>
</div>
<div style="display:flex; gap:10px; align-items:center;">
<button class="btn btn-purple" type="submit" id="bf-submit">🔮 send to the ledger</button>
<span id="bf-status" class="muted" style="font-family:var(--mono); font-size:12px;"></span>
</div>
</form>
</section>
<section class="panel green">
<div class="panel-head">
<h2 id="my-bugs-title">your reports</h2>
{% if is_admin %}
<span class="ctx">
<button class="btn" type="button" id="toggle-all" style="font-size:11px; padding:.3em .8em;">show all cauldron bugs</button>
</span>
{% endif %}
</div>
<div id="bug-list" class="muted"
style="display:flex; flex-direction:column; gap:8px; font-family:var(--mono); font-size:12px;">
loading...
</div>
</section>
<script>
const _esc = (s) => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
const _ago = (iso) => {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const s = Math.floor((Date.now() - d.getTime()) / 1000);
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s/60) + 'm ago';
if (s < 86400) return Math.floor(s/3600) + 'h ago';
return Math.floor(s/86400) + 'd ago';
};
const STATUS_GLYPH = {
'open': '📂',
'in_progress': '🔨',
'resolved': '✅',
'wontfix': '⛔',
'duplicate': '🌀',
'invalid': '✗',
};
let showAll = false;
async function loadBugs() {
const list = document.getElementById('bug-list');
list.textContent = 'loading...';
const url = '/api/bugs' + (showAll ? '?all=1' : '');
try {
const r = await fetch(url);
const d = await r.json();
if (!r.ok || !d.ok) {
list.textContent = 'couldn\'t load bugs: ' + (d.error || r.status);
return;
}
const reports = d.reports || [];
const title = document.getElementById('my-bugs-title');
title.textContent = showAll ? 'all cauldron bugs' : 'your reports';
if (!reports.length) {
list.innerHTML = showAll
? '<em>no bugs filed yet against cauldron. impressive or suspicious.</em>'
: '<em>you haven\'t filed anything yet. nothing to see here.</em>';
return;
}
list.innerHTML = reports.map(r => {
const glyph = STATUS_GLYPH[r.status] || '·';
const sev = r.severity ? ' · <span style="color:var(--purple-bright)">' + _esc(r.severity) + '</span>' : '';
const cat = r.category ? ' · ' + _esc(r.category) : '';
const id = r.id;
const subject = _esc(r.subject || '(untitled)');
const status = _esc(r.status || 'open');
const when = _ago(r.created_at);
const who = showAll && r.user_email ? ' · <span style="color:var(--green-bright)">' + _esc(r.user_email) + '</span>' : '';
return `<div data-id="${id}" style="padding:8px 10px; border:1px solid var(--line-soft); border-radius:6px; background:var(--bg-1);">
<div style="display:flex; justify-content:space-between; gap:10px;">
<span><strong style="color:var(--bone); font-family:var(--serif); font-size:13px;">${glyph} ${subject}</strong></span>
<span class="muted">#${id} · ${when}</span>
</div>
<div style="margin-top:4px; color:var(--muted);">
${status}${sev}${cat}${who}
</div>
</div>`;
}).join('');
} catch (e) {
list.textContent = 'request failed: ' + e;
}
}
document.getElementById('bug-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
const status = document.getElementById('bf-status');
const submit = document.getElementById('bf-submit');
const subject = document.getElementById('bf-subject').value.trim();
const message = document.getElementById('bf-message').value.trim();
const category = document.getElementById('bf-category').value;
const severity = document.getElementById('bf-severity').value;
if (!subject || !message) {
status.textContent = 'subject + what-happened are both required.';
return;
}
submit.disabled = true;
status.textContent = 'sending...';
try {
const r = await fetch('/api/bugs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject, message, category,
severity: severity || null,
page_url: document.referrer || window.location.href,
}),
});
const d = await r.json();
if (!r.ok || !d.ok) {
status.textContent = 'failed: ' + (d.detail || d.error || r.status);
} else {
status.textContent = '✓ filed as #' + d.report.id;
document.getElementById('bf-subject').value = '';
document.getElementById('bf-message').value = '';
loadBugs();
}
} catch (e) {
status.textContent = 'request failed: ' + e;
} finally {
submit.disabled = false;
}
});
const toggleBtn = document.getElementById('toggle-all');
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
showAll = !showAll;
toggleBtn.textContent = showAll ? 'show only mine' : 'show all cauldron bugs';
loadBugs();
});
}
loadBugs();
</script>
{% endif %}
{% endblock %}

0
cauldron/vendor/__init__.py vendored Normal file
View file

50
cauldron/vendor/bugs_sulkta/__init__.py vendored Normal file
View file

@ -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__",
]

237
cauldron/vendor/bugs_sulkta/client.py vendored Normal file
View file

@ -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"))

34
cauldron/vendor/bugs_sulkta/errors.py vendored Normal file
View file

@ -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."""

74
cauldron/vendor/bugs_sulkta/models.py vendored Normal file
View file

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