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 from __future__ import annotations
import os
import platform import platform
import re import re
import subprocess import subprocess
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import requests
import yaml import yaml
_ROOT = Path(__file__).parent.parent _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']}*"] lines += ["---", f"*Submitted by: {attachments['submitter']}*"]
return "\n".join(lines) 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}) body = build_issue_body(form, {}, {"listings": listings})
assert "CSM" in body assert "CSM" in body
assert "Acme" 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"