From 5a3f9cb4602f91ad6424890a8d20b13cb9d12346 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 5 Apr 2026 18:18:25 -0700 Subject: [PATCH] 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. --- api/main.py | 127 +++----------------------------------- tests/test_feedback.py | 134 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 119 deletions(-) create mode 100644 tests/test_feedback.py diff --git a/api/main.py b/api/main.py index fc94c6e..3fbf552 100644 --- a/api/main.py +++ b/api/main.py @@ -11,12 +11,7 @@ from pathlib import Path import csv 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.responses import StreamingResponse from pydantic import BaseModel @@ -81,6 +76,14 @@ app.add_middleware( 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") def health(): @@ -645,117 +648,3 @@ async def import_blocklist( 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"]) diff --git a/tests/test_feedback.py b/tests/test_feedback.py new file mode 100644 index 0000000..60f5512 --- /dev/null +++ b/tests/test_feedback.py @@ -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