feat: feedback_api — Forgejo label management + issue filing + attachment upload

This commit is contained in:
pyr0ball 2026-03-03 12:09:11 -08:00
parent 1940cfb131
commit b77bb754af
2 changed files with 131 additions and 0 deletions

View file

@ -4,12 +4,14 @@ Called directly from app/feedback.py now; wrappable in a FastAPI route later.
"""
from __future__ import annotations
import os
import platform
import re
import subprocess
from datetime import datetime, timezone
from pathlib import Path
import requests
import yaml
_ROOT = Path(__file__).parent.parent
@ -125,3 +127,68 @@ def build_issue_body(form: dict, context: dict, attachments: dict) -> str:
lines += ["---", f"*Submitted by: {attachments['submitter']}*"]
return "\n".join(lines)
def _ensure_labels(
label_names: list[str], base_url: str, headers: dict, repo: str
) -> list[int]:
"""Look up or create Forgejo labels by name. Returns list of IDs."""
_COLORS = {
"beta-feedback": "#0075ca",
"needs-triage": "#e4e669",
"bug": "#d73a4a",
"feature-request": "#a2eeef",
"question": "#d876e3",
}
resp = requests.get(f"{base_url}/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_url}/repos/{repo}/labels",
headers=headers,
json={"name": name, "color": _COLORS.get(name, "#ededed")},
timeout=10,
)
if r.ok:
ids.append(r.json()["id"])
return ids
def create_forgejo_issue(title: str, body: str, labels: list[str]) -> dict:
"""Create a Forgejo issue. Returns {"number": int, "url": str}."""
token = os.environ.get("FORGEJO_API_TOKEN", "")
repo = os.environ.get("FORGEJO_REPO", "pyr0ball/peregrine")
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
label_ids = _ensure_labels(labels, base, headers, repo)
resp = requests.post(
f"{base}/repos/{repo}/issues",
headers=headers,
json={"title": title, "body": body, "labels": label_ids},
timeout=15,
)
resp.raise_for_status()
data = resp.json()
return {"number": data["number"], "url": data["html_url"]}
def upload_attachment(
issue_number: int, image_bytes: bytes, filename: str = "screenshot.png"
) -> str:
"""Upload a screenshot to an existing Forgejo issue. Returns attachment URL."""
token = os.environ.get("FORGEJO_API_TOKEN", "")
repo = os.environ.get("FORGEJO_REPO", "pyr0ball/peregrine")
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
headers = {"Authorization": f"token {token}"}
resp = requests.post(
f"{base}/repos/{repo}/issues/{issue_number}/assets",
headers=headers,
files={"attachment": (filename, image_bytes, "image/png")},
timeout=15,
)
resp.raise_for_status()
return resp.json().get("browser_download_url", "")

View file

@ -178,3 +178,67 @@ def test_build_issue_body_listings_shown():
body = build_issue_body(form, {}, {"listings": listings})
assert "CSM" in body
assert "Acme" in body
# ── Forgejo API ───────────────────────────────────────────────────────────────
@patch("scripts.feedback_api.requests.get")
@patch("scripts.feedback_api.requests.post")
def test_ensure_labels_uses_existing(mock_post, mock_get):
from scripts.feedback_api import _ensure_labels
mock_get.return_value.ok = True
mock_get.return_value.json.return_value = [
{"name": "beta-feedback", "id": 1},
{"name": "bug", "id": 2},
]
ids = _ensure_labels(
["beta-feedback", "bug"],
"https://example.com/api/v1", {"Authorization": "token x"}, "owner/repo"
)
assert ids == [1, 2]
mock_post.assert_not_called()
@patch("scripts.feedback_api.requests.get")
@patch("scripts.feedback_api.requests.post")
def test_ensure_labels_creates_missing(mock_post, mock_get):
from scripts.feedback_api import _ensure_labels
mock_get.return_value.ok = True
mock_get.return_value.json.return_value = []
mock_post.return_value.ok = True
mock_post.return_value.json.return_value = {"id": 99}
ids = _ensure_labels(
["needs-triage"],
"https://example.com/api/v1", {"Authorization": "token x"}, "owner/repo"
)
assert 99 in ids
@patch("scripts.feedback_api._ensure_labels", return_value=[1, 2])
@patch("scripts.feedback_api.requests.post")
def test_create_forgejo_issue_success(mock_post, mock_labels, monkeypatch):
from scripts.feedback_api import create_forgejo_issue
monkeypatch.setenv("FORGEJO_API_TOKEN", "testtoken")
monkeypatch.setenv("FORGEJO_REPO", "owner/repo")
monkeypatch.setenv("FORGEJO_API_URL", "https://example.com/api/v1")
mock_post.return_value.status_code = 201
mock_post.return_value.raise_for_status = lambda: None
mock_post.return_value.json.return_value = {"number": 42, "html_url": "https://example.com/issues/42"}
result = create_forgejo_issue("Test issue", "body text", ["beta-feedback", "bug"])
assert result["number"] == 42
assert "42" in result["url"]
@patch("scripts.feedback_api.requests.post")
def test_upload_attachment_returns_url(mock_post, monkeypatch):
from scripts.feedback_api import upload_attachment
monkeypatch.setenv("FORGEJO_API_TOKEN", "testtoken")
monkeypatch.setenv("FORGEJO_REPO", "owner/repo")
monkeypatch.setenv("FORGEJO_API_URL", "https://example.com/api/v1")
mock_post.return_value.status_code = 201
mock_post.return_value.raise_for_status = lambda: None
mock_post.return_value.json.return_value = {
"uuid": "abc", "browser_download_url": "https://example.com/assets/abc"
}
url = upload_attachment(42, b"\x89PNG", "screenshot.png")
assert url == "https://example.com/assets/abc"