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:
parent
9f261e6b9e
commit
d0d3c67a60
10 changed files with 706 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("/"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -501,6 +501,7 @@ button { font-family: inherit; }
|
|||
<a href="/plan" class="{% if active == 'plan' %}active{% endif %}">plan</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 %}
|
||||
<a href="/bugs" class="{% if active == 'bugs' %}active{% endif %}">bugs</a>
|
||||
<a href="/me" class="{% if active == 'me' %}active{% endif %}">me</a>
|
||||
</nav>
|
||||
<div class="topmeta">
|
||||
|
|
|
|||
208
cauldron/templates/bugs.html
Normal file
208
cauldron/templates/bugs.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
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
0
cauldron/vendor/__init__.py
vendored
Normal file
50
cauldron/vendor/bugs_sulkta/__init__.py
vendored
Normal file
50
cauldron/vendor/bugs_sulkta/__init__.py
vendored
Normal 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
237
cauldron/vendor/bugs_sulkta/client.py
vendored
Normal 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
34
cauldron/vendor/bugs_sulkta/errors.py
vendored
Normal 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
74
cauldron/vendor/bugs_sulkta/models.py
vendored
Normal 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"),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue