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:
parent
f7d5b20aa5
commit
5a3f9cb460
2 changed files with 142 additions and 119 deletions
127
api/main.py
127
api/main.py
|
|
@ -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
134
tests/test_feedback.py
Normal 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
|
||||||
Loading…
Reference in a new issue