feat: migrate feedback endpoint to circuitforge-core router

Replaces the inline feedback block (FeedbackRequest/FeedbackResponse
models, _fb_headers, _ensure_feedback_labels, status + submit routes)
with make_feedback_router() from circuitforge-core. Removes now-unused
imports (_requests, _platform, Literal, subprocess, datetime/timezone).
Adds 7 tests covering status + submit paths via TestClient.
This commit is contained in:
pyr0ball 2026-04-05 18:18:25 -07:00
parent f7d5b20aa5
commit 5a3f9cb460
2 changed files with 142 additions and 119 deletions

View file

@ -11,12 +11,7 @@ from pathlib import Path
import csv import csv
import io import io
import platform as _platform
import subprocess
from datetime import datetime, timezone
from typing import Literal
import requests as _requests
from fastapi import Depends, FastAPI, HTTPException, UploadFile, File from fastapi import Depends, FastAPI, HTTPException, UploadFile, File
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
@ -81,6 +76,14 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
from circuitforge_core.api.feedback import make_feedback_router as _make_feedback_router # noqa: E402
_feedback_router = _make_feedback_router(
repo="Circuit-Forge/snipe",
product="snipe",
)
app.include_router(_feedback_router, prefix="/api/feedback")
@app.get("/api/health") @app.get("/api/health")
def health(): def health():
@ -645,117 +648,3 @@ async def import_blocklist(
return {"imported": imported, "errors": errors} return {"imported": imported, "errors": errors}
# ── Feedback ────────────────────────────────────────────────────────────────
# Creates Forgejo issues from in-app beta feedback.
# Silently disabled when FORGEJO_API_TOKEN is not set.
_FEEDBACK_LABEL_COLORS = {
"beta-feedback": "#0075ca",
"needs-triage": "#e4e669",
"bug": "#d73a4a",
"feature-request": "#a2eeef",
"question": "#d876e3",
}
def _fb_headers() -> dict:
token = os.environ.get("FORGEJO_API_TOKEN", "")
return {"Authorization": f"token {token}", "Content-Type": "application/json"}
def _ensure_feedback_labels(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/snipe")
resp = _requests.get(f"{base}/repos/{repo}/labels", headers=_fb_headers(), timeout=10)
existing = {lb["name"]: lb["id"] for lb in resp.json()} if resp.ok else {}
ids: list[int] = []
for name in names:
if name in existing:
ids.append(existing[name])
else:
r = _requests.post(
f"{base}/repos/{repo}/labels",
headers=_fb_headers(),
json={"name": name, "color": _FEEDBACK_LABEL_COLORS.get(name, "#ededed")},
timeout=10,
)
if r.ok:
ids.append(r.json()["id"])
return ids
class FeedbackRequest(BaseModel):
title: str
description: str
type: Literal["bug", "feature", "other"] = "other"
repro: str = ""
view: str = "unknown"
submitter: str = ""
class FeedbackResponse(BaseModel):
issue_number: int
issue_url: str
@app.get("/api/feedback/status")
def feedback_status() -> dict:
"""Return whether feedback submission is configured on this instance."""
demo = os.environ.get("DEMO_MODE", "false").lower() in ("1", "true", "yes")
return {"enabled": bool(os.environ.get("FORGEJO_API_TOKEN")) and not demo}
@app.post("/api/feedback", response_model=FeedbackResponse)
def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse:
"""File a Forgejo issue from in-app feedback."""
token = os.environ.get("FORGEJO_API_TOKEN", "")
if not token:
raise HTTPException(status_code=503, detail="Feedback disabled: FORGEJO_API_TOKEN not configured.")
if os.environ.get("DEMO_MODE", "false").lower() in ("1", "true", "yes"):
raise HTTPException(status_code=403, detail="Feedback disabled in demo mode.")
try:
version = subprocess.check_output(
["git", "describe", "--tags", "--always"],
cwd=Path(__file__).resolve().parents[1], text=True, timeout=5,
).strip()
except Exception:
version = "dev"
_TYPE_LABELS = {"bug": "🐛 Bug", "feature": "✨ Feature Request", "other": "💬 Other"}
body_lines = [
f"## {_TYPE_LABELS.get(payload.type, '💬 Other')}",
"",
payload.description,
"",
]
if payload.type == "bug" and payload.repro:
body_lines += ["### Reproduction Steps", "", payload.repro, ""]
body_lines += [
"### Context", "",
f"- **view:** {payload.view}",
f"- **version:** {version}",
f"- **platform:** {_platform.platform()}",
f"- **timestamp:** {datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')}",
"",
]
if payload.submitter:
body_lines += ["---", f"*Submitted by: {payload.submitter}*"]
labels = ["beta-feedback", "needs-triage",
{"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/snipe")
label_ids = _ensure_feedback_labels(labels)
resp = _requests.post(
f"{base}/repos/{repo}/issues",
headers=_fb_headers(),
json={"title": payload.title, "body": "\n".join(body_lines), "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"])

134
tests/test_feedback.py Normal file
View file

@ -0,0 +1,134 @@
"""Tests for the shared feedback router (circuitforge-core) mounted in snipe."""
from __future__ import annotations
from collections.abc import Callable
from unittest.mock import MagicMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from circuitforge_core.api.feedback import make_feedback_router
# ── Test app factory ──────────────────────────────────────────────────────────
def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient:
app = FastAPI()
router = make_feedback_router(
repo="Circuit-Forge/snipe",
product="snipe",
demo_mode_fn=demo_mode_fn,
)
app.include_router(router, prefix="/api/feedback")
return TestClient(app)
# ── GET /api/feedback/status ──────────────────────────────────────────────────
def test_status_disabled_when_no_token(monkeypatch):
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
monkeypatch.delenv("DEMO_MODE", raising=False)
client = _make_client(demo_mode_fn=lambda: False)
res = client.get("/api/feedback/status")
assert res.status_code == 200
assert res.json() == {"enabled": False}
def test_status_enabled_when_token_set(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
client = _make_client(demo_mode_fn=lambda: False)
res = client.get("/api/feedback/status")
assert res.status_code == 200
assert res.json() == {"enabled": True}
def test_status_disabled_in_demo_mode(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
demo = True
client = _make_client(demo_mode_fn=lambda: demo)
res = client.get("/api/feedback/status")
assert res.status_code == 200
assert res.json() == {"enabled": False}
# ── POST /api/feedback ────────────────────────────────────────────────────────
def test_submit_returns_503_when_no_token(monkeypatch):
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
client = _make_client(demo_mode_fn=lambda: False)
res = client.post("/api/feedback", json={
"title": "Test", "description": "desc", "type": "bug",
})
assert res.status_code == 503
def test_submit_returns_403_in_demo_mode(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
demo = True
client = _make_client(demo_mode_fn=lambda: demo)
res = client.post("/api/feedback", json={
"title": "Test", "description": "desc", "type": "bug",
})
assert res.status_code == 403
def test_submit_creates_issue(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
label_response = MagicMock()
label_response.ok = True
label_response.json.return_value = [
{"id": 1, "name": "beta-feedback"},
{"id": 2, "name": "needs-triage"},
{"id": 3, "name": "bug"},
]
issue_response = MagicMock()
issue_response.ok = True
issue_response.json.return_value = {
"number": 7,
"html_url": "https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/7",
}
client = _make_client(demo_mode_fn=lambda: False)
with patch("circuitforge_core.api.feedback.requests.get", return_value=label_response), \
patch("circuitforge_core.api.feedback.requests.post", return_value=issue_response):
res = client.post("/api/feedback", json={
"title": "Listing scores wrong",
"description": "Trust score shows 0 when seller has 1000 feedback",
"type": "bug",
"repro": "1. Search for anything\n2. Check trust score",
"view": "search",
})
assert res.status_code == 200
data = res.json()
assert data["issue_number"] == 7
assert data["issue_url"] == "https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/7"
def test_submit_returns_502_on_forgejo_error(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
label_response = MagicMock()
label_response.ok = True
label_response.json.return_value = [
{"id": 1, "name": "beta-feedback"},
{"id": 2, "name": "needs-triage"},
{"id": 3, "name": "question"},
]
bad_response = MagicMock()
bad_response.ok = False
bad_response.text = "internal server error"
client = _make_client(demo_mode_fn=lambda: False)
with patch("circuitforge_core.api.feedback.requests.get", return_value=label_response), \
patch("circuitforge_core.api.feedback.requests.post", return_value=bad_response):
res = client.post("/api/feedback", json={
"title": "Oops", "description": "desc", "type": "other",
})
assert res.status_code == 502