diff --git a/scripts/feedback_api.py b/scripts/feedback_api.py index 7462eb8..19ac09c 100644 --- a/scripts/feedback_api.py +++ b/scripts/feedback_api.py @@ -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", "") diff --git a/tests/test_feedback_api.py b/tests/test_feedback_api.py index 03de328..8413e8b 100644 --- a/tests/test_feedback_api.py +++ b/tests/test_feedback_api.py @@ -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"