kiwi/app/api/endpoints/feedback.py
pyr0ball 6791ea22b2 feat: complete feedback button — status probe, requests dep, tests
- feedback.py: add GET /feedback/status endpoint (returns {enabled: bool})
  so frontend can probe on mount instead of optimistic-enable; remove
  unused get_db import
- FeedbackButton.vue: probe /feedback/status on mount, start hidden;
  drop redundant 503-hide path (status probe makes it redundant)
- pyproject.toml: declare requests>=2.31 (used by feedback.py Forgejo calls)
- tests/api/test_feedback.py: 7 tests — status endpoint (no-token, token,
  demo mode), POST 503/403, happy path with mocked Forgejo, 502 on error
2026-04-03 16:52:40 -07:00

169 lines
5.7 KiB
Python

"""
Feedback endpoint — creates Forgejo issues from in-app feedback.
Ported from peregrine/scripts/feedback_api.py; adapted for Kiwi context.
"""
from __future__ import annotations
import os
import platform
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal
import requests
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.core.config import settings
router = APIRouter()
_ROOT = Path(__file__).resolve().parents[3]
# ── Forgejo helpers ────────────────────────────────────────────────────────────
_LABEL_COLORS = {
"beta-feedback": "#0075ca",
"needs-triage": "#e4e669",
"bug": "#d73a4a",
"feature-request": "#a2eeef",
"question": "#d876e3",
}
def _forgejo_headers() -> dict:
token = os.environ.get("FORGEJO_API_TOKEN", "")
return {"Authorization": f"token {token}", "Content-Type": "application/json"}
def _ensure_labels(label_names: list[str]) -> list[int]:
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/kiwi")
headers = _forgejo_headers()
resp = requests.get(f"{base}/repos/{repo}/labels", headers=headers, timeout=10)
existing = {lb["name"]: lb["id"] for lb in resp.json()} if resp.ok else {}
ids: list[int] = []
for name in label_names:
if name in existing:
ids.append(existing[name])
else:
r = requests.post(
f"{base}/repos/{repo}/labels",
headers=headers,
json={"name": name, "color": _LABEL_COLORS.get(name, "#ededed")},
timeout=10,
)
if r.ok:
ids.append(r.json()["id"])
return ids
def _collect_context(tab: str) -> dict:
"""Collect lightweight app context: tab, version, platform, timestamp."""
try:
version = subprocess.check_output(
["git", "describe", "--tags", "--always"],
cwd=_ROOT, text=True, timeout=5,
).strip()
except Exception:
version = "dev"
return {
"tab": tab,
"version": version,
"demo_mode": settings.DEMO_MODE,
"cloud_mode": settings.CLOUD_MODE,
"platform": platform.platform(),
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
def _build_issue_body(form: dict, context: dict) -> str:
_TYPE_LABELS = {"bug": "🐛 Bug", "feature": "✨ Feature Request", "other": "💬 Other"}
lines: list[str] = [
f"## {_TYPE_LABELS.get(form.get('type', 'other'), '💬 Other')}",
"",
form.get("description", ""),
"",
]
if form.get("type") == "bug" and form.get("repro"):
lines += ["### Reproduction Steps", "", form["repro"], ""]
lines += ["### Context", ""]
for k, v in context.items():
lines.append(f"- **{k}:** {v}")
lines.append("")
if form.get("submitter"):
lines += ["---", f"*Submitted by: {form['submitter']}*"]
return "\n".join(lines)
# ── Schemas ────────────────────────────────────────────────────────────────────
class FeedbackRequest(BaseModel):
title: str
description: str
type: Literal["bug", "feature", "other"] = "other"
repro: str = ""
tab: str = "unknown"
submitter: str = "" # optional "Name <email>" attribution
class FeedbackResponse(BaseModel):
issue_number: int
issue_url: str
# ── Routes ─────────────────────────────────────────────────────────────────────
@router.get("/status")
def feedback_status() -> dict:
"""Return whether feedback submission is configured on this instance."""
return {"enabled": bool(os.environ.get("FORGEJO_API_TOKEN")) and not settings.DEMO_MODE}
@router.post("", response_model=FeedbackResponse)
def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse:
"""
File a Forgejo issue from in-app feedback.
Silently disabled when FORGEJO_API_TOKEN is not set (demo/offline mode).
"""
token = os.environ.get("FORGEJO_API_TOKEN", "")
if not token:
raise HTTPException(
status_code=503,
detail="Feedback disabled: FORGEJO_API_TOKEN not configured.",
)
if settings.DEMO_MODE:
raise HTTPException(status_code=403, detail="Feedback disabled in demo mode.")
context = _collect_context(payload.tab)
form = {
"type": payload.type,
"description": payload.description,
"repro": payload.repro,
"submitter": payload.submitter,
}
body = _build_issue_body(form, context)
labels = ["beta-feedback", "needs-triage"]
labels.append({"bug": "bug", "feature": "feature-request"}.get(payload.type, "question"))
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/kiwi")
headers = _forgejo_headers()
label_ids = _ensure_labels(labels)
resp = requests.post(
f"{base}/repos/{repo}/issues",
headers=headers,
json={"title": payload.title, "body": body, "labels": label_ids},
timeout=15,
)
if not resp.ok:
raise HTTPException(status_code=502, detail=f"Forgejo error: {resp.text[:200]}")
data = resp.json()
return FeedbackResponse(issue_number=data["number"], issue_url=data["html_url"])