From 9cf177b1988cd5d7d31171576f8866de1860ec22 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 22:48:20 +0200 Subject: [PATCH] feat(diagnostics): send a bug report to a private Gitea issue tracker (E16 S4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin config group "Bug reports": Gitea address, repository, token (secret), and "Check the connection" — the repository must answer, take issues and be PRIVATE (a report carries its reporter's log); checked on every send too - POST /api/debug-sessions/{id}/issue sends the SAVED draft: create the issue, upload each file on its own request (the measured API), then show the screenshots inline; progress is kept in the draft, so a send that stops half-way keeps its issue and the retry sends only what is missing; 5 new issues an hour per user; audited as issue_create - the composer: Create issue (only where a tracker is set up, naming the repository), a partial-send banner with retry, and a Preview — the tracker's own rendering in a sandboxed frame, or the zip's markdown without one - the recorder's refusals answer in the localized family (debug_session_*, screenshot_*, issue(s)_*), not bare codes the UI could only call generic - a tracker refusal logs the status only: its message can quote the title back --- backend/app/audit.py | 3 + backend/app/config.py | 7 + backend/app/debugsession.py | 2 + backend/app/issues/gitea.py | 194 ++++++++++++++++ backend/app/routes/_common.py | 23 ++ backend/app/routes/config.py | 16 +- backend/app/routes/debug_sessions.py | 95 +++++++- backend/app/routes/me.py | 3 + backend/app/sysconfig.py | 4 + backend/tests/test_debug_sessions.py | 214 +++++++++++++++++- frontend/src/components/ConfigPanel.tsx | 24 ++ .../src/components/DebugRecorder.dom.test.tsx | 1 + .../src/components/IssueComposer.dom.test.tsx | 68 ++++++ frontend/src/components/IssueComposer.tsx | 137 ++++++++++- .../src/i18n/locales/en/auditActions.json | 3 +- frontend/src/i18n/locales/en/config.json | 21 +- frontend/src/i18n/locales/en/errors.json | 17 +- frontend/src/i18n/locales/en/recorder.json | 14 +- .../src/i18n/locales/hu/auditActions.json | 3 +- frontend/src/i18n/locales/hu/config.json | 21 +- frontend/src/i18n/locales/hu/errors.json | 17 +- frontend/src/i18n/locales/hu/recorder.json | 14 +- frontend/src/lib/api/account.ts | 2 + frontend/src/lib/api/admin.ts | 3 + frontend/src/lib/api/diagnostics.ts | 8 + .../src/lib/diagnostics.session.dom.test.ts | 1 + 26 files changed, 883 insertions(+), 32 deletions(-) create mode 100644 backend/app/issues/gitea.py diff --git a/backend/app/audit.py b/backend/app/audit.py index 8a87f95c..6dfba3d1 100644 --- a/backend/app/audit.py +++ b/backend/app/audit.py @@ -58,6 +58,9 @@ class AuditAction: DOWNLOAD_CREDENTIAL_DELETE = "download_credential_delete" # The log itself was cleared (a single row survives the clear as tamper-evidence) AUDIT_CLEAR = "audit_clear" + # E16: a user sent a debug session's issue to the tracker — the one act that takes their log + # off this instance, so it is recorded (target = the session, summary = the issue's URL). + ISSUE_CREATE = "issue_create" def record( diff --git a/backend/app/config.py b/backend/app/config.py index 8933fd62..8be0a0bd 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -351,6 +351,13 @@ class Settings(BaseSettings): tmdb_api_base: str = "https://api.themoviedb.org/3" # Metadata language for TMDB fetches (keyword/overview locale). Stored EN by default. tmdb_language: str = "en-US" + + # --- Bug reports (E16): where a debug session's issue goes. All three empty = no tracker; the + # composer still exports a zip. The repository must be PRIVATE — an issue carries the reporter's + # log — and app/issues/gitea.py refuses a public one. Runtime knobs (sysconfig, admin UI). --- + issues_gitea_url: str = "" + issues_gitea_repo: str = "" + issues_gitea_token: str = "" # How often the TMDB enrichment job fetches a batch of not-yet-enriched Plex titles, in minutes. tmdb_enrich_interval_min: int = 120 # Titles fetched per TMDB enrichment run (TMDB is fast + generously rate-limited). diff --git a/backend/app/debugsession.py b/backend/app/debugsession.py index cb385ff8..b7dabaff 100644 --- a/backend/app/debugsession.py +++ b/backend/app/debugsession.py @@ -145,6 +145,8 @@ def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None "ended_at": session.ended_at.isoformat() if session.ended_at else None, "ended_reason": session.ended_reason, "issue_url": session.issue_url, + # A send that stopped half-way (app/issues/gitea.py): the files still to go. + "pending_files": ((session.draft or {}).get("delivery") or {}).get("pending") or [], "screenshots": screenshots, "draft": session.draft, } diff --git a/backend/app/issues/gitea.py b/backend/app/issues/gitea.py new file mode 100644 index 00000000..ea0da7af --- /dev/null +++ b/backend/app/issues/gitea.py @@ -0,0 +1,194 @@ +"""The Gitea issue tracker a debug session's issue goes to (E16 S4) — measured against Gitea 1.27.1 +before a line of this was written (plans/e16-debug-issue.md, S0). + +Configured by an admin (sysconfig `issues_gitea_url` / `_repo` / `_token`, the token encrypted at +rest). The repository must be PRIVATE: an issue carries its reporter's log — page titles, filter +values, at Teljes what they typed into search — and a public repository would publish it. That is +checked on every send, not only in the admin's "test connection", because a repository can be made +public after the fact. + +What an issue is made of comes from `bundle.files` (the same files the zip export carries). Sending +is three kinds of call, measured: + * `POST /repos/{repo}/issues` — the title and the body the user wrote; + * `POST /repos/{repo}/issues/{n}/assets?name=…` — ONE file per request (field `attachment`); + * `PATCH /repos/{repo}/issues/{n}` — the body again, with the screenshots shown inline once their + links exist. +A private repository's attachment answers 404 to a browser not signed in to Gitea, which is why the +composer's own preview shows our copies, never these links. + +Delivery keeps its progress in the session's draft (`delivery`: the issue number, the files still to +upload, the links of those uploaded), so a failure half-way keeps the issue that exists and a retry +sends only what is missing — never a second issue. Every step flushes; the caller commits, also +when a step fails, so what did happen is kept. +""" + +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Literal + +import httpx +from sqlalchemy.orm import Session + +from app import logs, sysconfig +from app.issues import bundle +from app.models import DebugSession + +log = logs.get_logger(__name__) + +TIMEOUT_SECONDS = 20.0 + +Reason = Literal[ + "not_configured", # url, repository or token missing + "unreachable", # no answer, or a 5xx + "bad_token", # 401/403: the token is wrong or lacks write:issue / read:repository + "repo_not_found", # 404 on the repository + "public_repo", # the repository is public — an issue would publish the reporter's log + "issues_disabled", # the repository has its issue tracker switched off + "rejected", # any other 4xx (a file type the instance does not allow, …) +] + + +class GiteaError(Exception): + def __init__(self, reason: Reason) -> None: + super().__init__(reason) + self.reason: Reason = reason + + +def _settings(db: Session) -> tuple[str, str, str]: + return ( + sysconfig.get_str(db, "issues_gitea_url").strip().rstrip("/"), + sysconfig.get_str(db, "issues_gitea_repo").strip().strip("/"), + sysconfig.get_str(db, "issues_gitea_token").strip(), + ) + + +def target(db: Session) -> dict | None: + """Where a report goes, for the composer to say so — the repository — or None when no tracker is + configured (the report can then only be downloaded).""" + url, repo, token = _settings(db) + return {"repo": repo} if url and repo and token else None + + +@contextmanager +def _client(db: Session) -> Iterator[tuple[httpx.Client, str]]: + url, repo, token = _settings(db) + if not (url and repo and token): + raise GiteaError("not_configured") + with httpx.Client( + base_url=f"{url}/api/v1", + headers={"Authorization": f"token {token}", "Accept": "application/json"}, + timeout=TIMEOUT_SECONDS, + ) as client: + yield client, repo + + +def _call(client: httpx.Client, method: str, path: str, **kwargs) -> httpx.Response: + try: + r = client.request(method, path, **kwargs) + except httpx.HTTPError as exc: + log.warning( + "the issue tracker did not answer", + extra=logs.event("issues.gitea.unreachable", path=path, error=type(exc).__name__), + ) + raise GiteaError("unreachable") from None + if r.status_code < 400: + return r + # The status only: Gitea's message can quote back what was sent — the user's own title. + log.warning( + "the issue tracker refused a call", + extra=logs.event("issues.gitea.refused", path=path, status=r.status_code), + ) + if r.status_code in (401, 403): + raise GiteaError("bad_token") + if r.status_code == 404: + raise GiteaError("repo_not_found") + if r.status_code >= 500: + raise GiteaError("unreachable") + raise GiteaError("rejected") + + +def _private_repo(client: httpx.Client, repo: str) -> dict: + data = _call(client, "GET", f"/repos/{repo}").json() + if not data.get("private"): + raise GiteaError("public_repo") + if not data.get("has_issues"): + raise GiteaError("issues_disabled") + return data + + +def check(db: Session) -> dict: + """The admin's "test connection": the repository answers, is private and takes issues.""" + with _client(db) as (client, repo): + data = _private_repo(client, repo) + return {"repo": data.get("full_name") or repo, "url": data.get("html_url")} + + +def preview(db: Session, text: str) -> str: + """The body as Gitea itself will render it (its markdown flavour, its sanitiser).""" + with _client(db) as (client, repo): + r = _call(client, "POST", "/markdown", json={"Text": text, "Mode": "gfm", "Context": repo}) + return r.text + + +def _save(session: DebugSession, state: dict) -> None: + # A new dict, so the JSONB column sees the change. + session.draft = {**(session.draft or {}), "delivery": state} + + +def deliver(db: Session, session: DebugSession) -> str: + """Send the session's issue, or finish sending it; returns its URL. Flushes after every step.""" + state = dict((session.draft or {}).get("delivery") or {}) + files = bundle.files(db, session) + attachments = [f for f in files if f.name != "issue.md"] + with _client(db) as (client, repo): + _private_repo(client, repo) + if "number" not in state: + created = _call( + client, + "POST", + f"/repos/{repo}/issues", + json={"title": bundle.title_of(session), "body": bundle.body_of(session)}, + ).json() + state = { + "number": created["number"], + "pending": [f.name for f in attachments], + "links": {}, + } + session.issue_url = created["html_url"] + _save(session, state) + db.flush() + log.info( + "issue created", + extra=logs.event("issues.gitea.created", number=created["number"]), + ) + number = state["number"] + for item in attachments: + if item.name not in state["pending"]: + continue + asset = _call( + client, + "POST", + f"/repos/{repo}/issues/{number}/assets", + params={"name": item.name}, + files={"attachment": (item.name, item.data, item.mime)}, + ).json() + state = { + **state, + "pending": [n for n in state["pending"] if n != item.name], + "links": {**state["links"], item.name: asset["browser_download_url"]}, + } + _save(session, state) + db.flush() + shots = [n for n in state["links"] if n.startswith("screenshot-")] + if shots and not state.get("images_shown"): + images = "\n".join(f"![{n}]({state['links'][n]})" for n in sorted(shots)) + body = f"{bundle.body_of(session)}\n\n---\n\n{images}" + _call(client, "PATCH", f"/repos/{repo}/issues/{number}", json={"body": body}) + _save(session, {**state, "images_shown": True}) + db.flush() + return session.issue_url or "" + + +def pending(session: DebugSession) -> list[str]: + """The files a half-finished delivery still has to send.""" + return list(((session.draft or {}).get("delivery") or {}).get("pending") or []) diff --git a/backend/app/routes/_common.py b/backend/app/routes/_common.py index e8c530a1..b7e99a55 100644 --- a/backend/app/routes/_common.py +++ b/backend/app/routes/_common.py @@ -13,6 +13,8 @@ from sqlalchemy.orm import Session from app.db import Base from app.models import User +from app.issues import gitea +from app.refusal import refused from app.youtube.client import YouTubeError, refusal_reason M = TypeVar("M", bound=Base) @@ -75,3 +77,24 @@ async def capped_body(request: Request, limit: int) -> bytes: if len(body) > limit: raise HTTPException(status_code=413, detail={"code": "too_large"}) return bytes(body) + + +def issue_refused(exc: gitea.GiteaError) -> HTTPException: + """The refusal an issue-tracker failure (E16) answers as — the send, the preview and the + admin's connection test alike. Literal reasons, one per case: the frontend's contract test reads + them to require a sentence for each in both languages.""" + match exc.reason: + case "not_configured": + return refused(400, "issues_not_configured") + case "unreachable": + return refused(422, "issues_unreachable") + case "bad_token": + return refused(422, "issues_bad_token") + case "repo_not_found": + return refused(422, "issues_repo_not_found") + case "public_repo": + return refused(422, "issues_public_repo") + case "issues_disabled": + return refused(422, "issues_disabled") + case _: + return refused(422, "issues_rejected") diff --git a/backend/app/routes/config.py b/backend/app/routes/config.py index fbbc0e24..e5d82112 100644 --- a/backend/app/routes/config.py +++ b/backend/app/routes/config.py @@ -18,7 +18,8 @@ from app.auth import admin_user from app.db import get_db from app.models import User from app.refusal import refused -from app.routes._common import not_found +from app.issues import gitea +from app.routes._common import issue_refused, not_found from app.refusal import setting_refused router = APIRouter(prefix="/api/admin/config", tags=["admin"]) @@ -131,3 +132,16 @@ def send_test_email( if not sent: raise refused(422, "smtp_failed") return {"ok": True, "sent": True, "to": user.email} + + +@router.post("/test-issues") +def test_issue_tracker( + _: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + """Check the configured issue tracker (E16): the repository answers with this token, takes + issues, and is PRIVATE — the one it must be, since an issue carries its reporter's log.""" + try: + return {"ok": True, **gitea.check(db)} + except gitea.GiteaError as exc: + raise issue_refused(exc) from None diff --git a/backend/app/routes/debug_sessions.py b/backend/app/routes/debug_sessions.py index 06f8834a..413f8862 100644 --- a/backend/app/routes/debug_sessions.py +++ b/backend/app/routes/debug_sessions.py @@ -20,12 +20,15 @@ from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session -from app import debugsession, logs, logsink +from app import audit, debugsession, logs, logsink +from app.audit import AuditAction from app.auth import require_human from app.db import get_db -from app.issues import bundle +from app.issues import bundle, gitea +from app.ratelimit import RateLimiter +from app.refusal import refused from app.models import DebugScreenshot, DebugSession, User -from app.routes._common import capped_body, not_found, owned_or_404 +from app.routes._common import capped_body, issue_refused, not_found, owned_or_404 from app.utils import iso router = APIRouter(prefix="/api/debug-sessions", tags=["diagnostics"]) @@ -58,7 +61,7 @@ def start_session( db.rollback() session = debugsession.open_session(db, user) if session is None: - raise HTTPException(status_code=409, detail={"code": "session_conflict"}) + raise refused(409, "debug_session_conflict") from None # The rest of THIS request is part of the session too. level, until = debugsession.level_for(user, session) logs.set_user(user.id, level, until, session.id) @@ -139,7 +142,7 @@ def save_draft( session = owned_or_404(db, DebugSession, sid, user, what="debug_session") if session.issue_url is not None: # Sent: the issue is on the tracker now, and a draft edited after it would say otherwise. - raise HTTPException(status_code=409, detail={"code": "already_sent"}) + raise refused(409, "issue_already_sent") if len(json.dumps(body.form, ensure_ascii=False).encode()) > FORM_MAX_BYTES: raise HTTPException(status_code=413, detail={"code": "too_large"}) debugsession.save_draft(session, body.model_dump()) @@ -167,6 +170,72 @@ def export_session( ) +# --- the issue tracker (S4) ----------------------------------------------------------------- +# Issues a user may OPEN per hour — finishing a half-sent one is not counted. The composer sends one +# at a time; this is room for a bad afternoon, not a pace anything legitimate keeps up. +_new_issues = RateLimiter(max_events=5, window_seconds=3600) + + +@router.post("/{sid}/issue") +def send_issue( + sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) +) -> dict: + """Send the session's issue to the tracker — from the SAVED draft, as the export is built — or + finish a send that stopped half-way (the issue exists; only what is missing is sent).""" + session = owned_or_404(db, DebugSession, sid, user, what="debug_session") + new = session.issue_url is None + if not new and not gitea.pending(session): + return {"ok": True, "session": _view(db, session)} + if new: + if not ((session.draft or {}).get("title") or "").strip(): + raise refused(422, "issue_title_required") + if not _new_issues.allow(str(user.id)): + raise refused(429, "issues_rate_limited") + try: + gitea.deliver(db, session) + except gitea.GiteaError as exc: + # Keep what did happen: an issue created before an upload failed stays linked, and the + # retry sends only the files still missing. + _record_created(db, user, session, new) + db.commit() + raise issue_refused(exc) from None + _record_created(db, user, session, new) + db.commit() + return {"ok": True, "session": _view(db, session)} + + +def _record_created(db: Session, user: User, session: DebugSession, new: bool) -> None: + if new and session.issue_url: + audit.record( + db, + user.id, + AuditAction.ISSUE_CREATE, + target_type="debug_session", + target_id=session.id, + summary=session.issue_url, + ) + + +class PreviewIn(BaseModel): + text: str = Field(max_length=50_000) + + +@router.post("/{sid}/preview") +def preview_issue( + sid: str, + body: PreviewIn, + user: User = Depends(require_human), + db: Session = Depends(get_db), +) -> dict: + """The body as the tracker will render it. Gitea sanitises its own output; the composer still + shows it in a sandboxed frame, never in the app's own document.""" + owned_or_404(db, DebugSession, sid, user, what="debug_session") + try: + return {"ok": True, "html": gitea.preview(db, body.text)} + except gitea.GiteaError as exc: + raise issue_refused(exc) from None + + # --- screenshots --------------------------------------------------------------------------- def _shot_view(shot: DebugScreenshot) -> dict: return {"id": shot.id, "mime": shot.mime, "size": shot.size, "created_at": iso(shot.created_at)} @@ -201,13 +270,23 @@ def add_screenshot( try: shot = debugsession.add_screenshot(db, session, data) except debugsession.ScreenshotRefused as exc: - raise HTTPException( - status_code=422, detail={"code": "screenshot_refused", "reason": exc.code} - ) from exc + raise _screenshot_refused(exc.code) from None db.commit() return {"ok": True, "screenshot": _shot_view(shot)} +def _screenshot_refused(code: str) -> HTTPException: + # Literal reasons, one per case: the frontend's contract test reads them to require a sentence + # for each in both languages. + if code == "already_sent": + return refused(409, "screenshot_already_sent") + if code == "too_many": + return refused(422, "screenshot_too_many", limit=debugsession.SCREENSHOTS_MAX) + if code == "empty": + return refused(422, "screenshot_empty") + return refused(422, "screenshot_not_an_image") + + def _session_screenshot(db: Session, sid: str, shot_id: int, user: User) -> DebugScreenshot: session = owned_or_404(db, DebugSession, sid, user, what="debug_session") shot = db.get(DebugScreenshot, shot_id) diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index 34bf9cc0..4a211d32 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -18,6 +18,7 @@ from app.auth import ( purge_user, ) from app import debugsession, diagnostics +from app.issues import gitea from app import email as email_mod from app import logs from app import sysconfig @@ -160,6 +161,8 @@ def get_me( "diagnostics": diagnostics.state(db, user), # The live debug session, if one is recording (E16) — a reload resumes it. "debug_session": _live_session(db, user), + # Where a bug report goes (E16 S4): the tracker's repository, or None — download only. + "issues": gitea.target(db), } diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index fb3c175f..1c9f1668 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -192,6 +192,10 @@ SPECS: tuple[ConfigSpec, ...] = ( # --- TMDB enrichment (E5 S2; optional) — the API key is the only secret --- ConfigSpec("tmdb_api_key", "str", "tmdb", "tmdb_api_key", secret=True), ConfigSpec("tmdb_language", "str", "tmdb", "tmdb_language"), + # --- Bug reports (E16): the Gitea issue tracker a debug session's issue goes to --- + ConfigSpec("issues_gitea_url", "str", "issues", "issues_gitea_url"), + ConfigSpec("issues_gitea_repo", "str", "issues", "issues_gitea_repo"), + ConfigSpec("issues_gitea_token", "str", "issues", "issues_gitea_token", secret=True), # (tmdb_enrich_interval_min is a scheduler job, tuned from the Scheduler dashboard; tmdb_enrich_batch # is a config.py default — both stay out of SPECS, like plex_sync_interval_min.) ) diff --git a/backend/tests/test_debug_sessions.py b/backend/tests/test_debug_sessions.py index 2ca2b60a..2c3c9911 100644 --- a/backend/tests/test_debug_sessions.py +++ b/backend/tests/test_debug_sessions.py @@ -10,15 +10,17 @@ Four parts: * screenshots: judged by their bytes, capped in size and count, served back only to their owner. """ +import json import logging from datetime import datetime, timedelta, timezone +import httpx import pytest from fastapi import BackgroundTasks from sqlalchemy import select from app import debugsession, logs -from app.models import DebugScreenshot, DebugSession, LogEvent, User +from app.models import AuditLog, DebugScreenshot, DebugSession, LogEvent, User from app.routes import diagnostics as diagnostics_routes _PW = "correct horse battery" @@ -406,13 +408,14 @@ def test_a_screenshot_is_kept_as_what_its_bytes_are(recording, data, mime): @pytest.mark.parametrize( - ("data", "reason"), [(b"", "not_an_image"), (b"", "empty")] + ("data", "reason"), + [(b"", "screenshot_not_an_image"), (b"", "screenshot_empty")], ) def test_what_is_not_an_image_is_refused(recording, data, reason): client, sid = recording r = _upload(client, sid, data) assert r.status_code == 422 - assert r.json()["detail"] == {"code": "screenshot_refused", "reason": reason} + assert r.json()["detail"] == {"code": "refused", "reason": reason} def test_screenshots_are_capped_in_size_and_count(recording, monkeypatch): @@ -422,7 +425,11 @@ def test_screenshots_are_capped_in_size_and_count(recording, monkeypatch): for _ in range(debugsession.SCREENSHOTS_MAX): assert _upload(client, sid, _PNG).status_code == 200 r = _upload(client, sid, _PNG) - assert r.status_code == 422 and r.json()["detail"]["reason"] == "too_many" + assert r.status_code == 422 and r.json()["detail"] == { + "code": "refused", + "reason": "screenshot_too_many", + "limit": debugsession.SCREENSHOTS_MAX, + } assert ( client.get(f"/api/debug-sessions/{sid}").json()["screenshots"] == debugsession.SCREENSHOTS_MAX @@ -434,7 +441,7 @@ def test_a_sent_session_takes_no_more_screenshots(db, recording): db.get(DebugSession, sid).issue_url = "https://git.example/o/r/issues/1" db.commit() r = _upload(client, sid, _PNG) - assert r.status_code == 422 and r.json()["detail"]["reason"] == "already_sent" + assert r.status_code == 409 and r.json()["detail"]["reason"] == "screenshot_already_sent" def test_a_screenshot_can_be_removed_and_is_then_gone(recording): @@ -486,7 +493,7 @@ def test_a_sent_session_keeps_its_draft(db, recording): db.get(DebugSession, sid).issue_url = "https://git.example/o/r/issues/1" db.commit() r = client.put(f"/api/debug-sessions/{sid}/draft", json={"title": "t", "body": "b"}) - assert r.status_code == 409 and r.json()["detail"]["code"] == "already_sent" + assert r.status_code == 409 and r.json()["detail"]["reason"] == "issue_already_sent" def test_the_export_is_the_saved_draft_the_evidence_and_the_screenshots(db, make_user, as_user): @@ -543,3 +550,198 @@ def test_the_export_is_the_saved_draft_the_evidence_and_the_screenshots(db, make assert "kept click" in web and "private search" not in web assert "GET /api/x 200" in api and all(m != "kept click" for m in api) assert z.read("screenshot-1.png") == _PNG + + +# --- the issue tracker (S4) ----------------------------------------------------------------- + + +class FakeGitea: + """Gitea 1.27 as measured (plans/e16 S0): a private repository, one file per asset upload, and + a way to make one upload fail.""" + + def __init__(self): + self.calls: list[tuple[str, str]] = [] + self.private = True + self.has_issues = True + self.token_ok = True + self.fail_asset: str | None = None + self.patched_body: str | None = None + + def handler(self, request: httpx.Request) -> httpx.Response: + path = request.url.path.removeprefix("/api/v1") + self.calls.append((request.method, path)) + if request.headers.get("authorization") != "token t0ken" or not self.token_ok: + return httpx.Response(401, json={"message": "invalid username, password or token"}) + if request.method == "GET" and path == "/repos/o/r": + return httpx.Response( + 200, + json={ + "full_name": "o/r", + "private": self.private, + "has_issues": self.has_issues, + "html_url": "https://git.example/o/r", + }, + ) + if request.method == "POST" and path == "/repos/o/r/issues": + return httpx.Response( + 201, json={"number": 7, "html_url": "https://git.example/o/r/issues/7"} + ) + if request.method == "POST" and path == "/repos/o/r/issues/7/assets": + name = request.url.params["name"] + if name == self.fail_asset: + self.fail_asset = None + return httpx.Response(500, json={"message": "boom"}) + return httpx.Response( + 201, json={"name": name, "browser_download_url": f"https://git.example/a/{name}"} + ) + if request.method == "PATCH" and path == "/repos/o/r/issues/7": + self.patched_body = json.loads(request.content)["body"] + return httpx.Response(201, json={"number": 7}) + if request.method == "POST" and path == "/markdown": + return httpx.Response(200, text="

Steps

") + return httpx.Response(404, json={"message": "not found"}) + + def uploads(self) -> list[str]: + return [p for m, p in self.calls if p.endswith("/assets")] + + +@pytest.fixture +def gitea_fake(monkeypatch): + from app.config import settings + from app.issues import gitea + from app.routes import debug_sessions as routes + + fake = FakeGitea() + real = httpx.Client + monkeypatch.setattr( + gitea.httpx, "Client", lambda **kw: real(transport=httpx.MockTransport(fake.handler), **kw) + ) + monkeypatch.setattr(settings, "issues_gitea_url", "https://git.example/") + monkeypatch.setattr(settings, "issues_gitea_repo", "o/r") + monkeypatch.setattr(settings, "issues_gitea_token", "t0ken") + routes._new_issues.reset() + yield fake + routes._new_issues.reset() + + +def _ready(client, sid, title="Spinner stays"): + r = client.put(f"/api/debug-sessions/{sid}/draft", json={"title": title, "body": "It spins."}) + assert r.status_code == 200 + + +def test_without_a_tracker_the_report_can_only_be_downloaded(db, client, make_user): + _signed_in(client, make_user) + assert client.get("/api/me").json()["issues"] is None + sid = _start(client)["id"] + _ready(client, sid) + r = client.post(f"/api/debug-sessions/{sid}/issue") + assert r.status_code == 400 and r.json()["detail"]["reason"] == "issues_not_configured" + + +def test_an_issue_is_created_with_every_file_and_the_screenshots_shown( + gitea_fake, db, client, make_user +): + _signed_in(client, make_user) + assert client.get("/api/me").json()["issues"] == {"repo": "o/r"} + sid = _start(client)["id"] + _upload(client, sid, _PNG) + _ready(client, sid) + r = client.post(f"/api/debug-sessions/{sid}/issue") + assert r.status_code == 200, r.text + session = r.json()["session"] + assert session["issue_url"] == "https://git.example/o/r/issues/7" + assert session["pending_files"] == [] + # One file per request (the measured API), each by its name. + assert gitea_fake.uploads() == ["/repos/o/r/issues/7/assets"] * 3 + assert "![screenshot-1.png](https://git.example/a/screenshot-1.png)" in gitea_fake.patched_body + assert gitea_fake.patched_body.startswith("It spins.") + row = db.scalar(select(AuditLog).where(AuditLog.action == "issue_create")) + assert (row.target_id, row.summary) == (sid, "https://git.example/o/r/issues/7") + # Sent: the draft is final, and sending again sends nothing. + assert ( + client.put(f"/api/debug-sessions/{sid}/draft", json={"title": "x", "body": "y"}).status_code + == 409 + ) + calls = len(gitea_fake.calls) + assert client.post(f"/api/debug-sessions/{sid}/issue").status_code == 200 + assert len(gitea_fake.calls) == calls + + +def test_a_public_repository_gets_nothing(gitea_fake, db, client, make_user): + gitea_fake.private = False + _signed_in(client, make_user) + sid = _start(client)["id"] + _ready(client, sid) + r = client.post(f"/api/debug-sessions/{sid}/issue") + assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_public_repo" + assert [m for m, _ in gitea_fake.calls] == ["GET"] + + +def test_a_send_that_stops_half_way_keeps_the_issue_and_the_retry_finishes_it( + gitea_fake, db, client, make_user +): + gitea_fake.fail_asset = "server.log" + _signed_in(client, make_user) + sid = _start(client)["id"] + _ready(client, sid) + r = client.post(f"/api/debug-sessions/{sid}/issue") + assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_unreachable" + session = client.get(f"/api/debug-sessions/{sid}").json() + assert session["issue_url"] == "https://git.example/o/r/issues/7" + assert session["pending_files"] == ["server.log"] + r = client.post(f"/api/debug-sessions/{sid}/issue") + assert r.status_code == 200 and r.json()["session"]["pending_files"] == [] + # One issue, and the retry sent only what was missing. + assert [p for m, p in gitea_fake.calls if (m, p) == ("POST", "/repos/o/r/issues")] == [ + "/repos/o/r/issues" + ] + assert len(gitea_fake.uploads()) == 3 # client.log, the failed server.log, then server.log + + +def test_a_report_needs_a_title_and_a_wrong_token_says_so(gitea_fake, db, client, make_user): + _signed_in(client, make_user) + sid = _start(client)["id"] + r = client.post(f"/api/debug-sessions/{sid}/issue") + assert r.status_code == 422 and r.json()["detail"]["reason"] == "issue_title_required" + _ready(client, sid) + gitea_fake.token_ok = False + r = client.post(f"/api/debug-sessions/{sid}/issue") + assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_bad_token" + assert client.get(f"/api/debug-sessions/{sid}").json()["issue_url"] is None + + +def test_new_issues_are_paced_per_user(gitea_fake, db, make_user, as_user, monkeypatch): + from app.ratelimit import RateLimiter + from app.routes import debug_sessions as routes + + monkeypatch.setattr(routes, "_new_issues", RateLimiter(max_events=1, window_seconds=3600)) + client = as_user(make_user()) + first = _start(client)["id"] + _ready(client, first) + assert client.post(f"/api/debug-sessions/{first}/issue").status_code == 200 + client.post(f"/api/debug-sessions/{first}/stop") + second = _start(client)["id"] + _ready(client, second) + r = client.post(f"/api/debug-sessions/{second}/issue") + assert r.status_code == 429 and r.json()["detail"]["reason"] == "issues_rate_limited" + + +def test_the_preview_is_the_trackers_own_rendering(gitea_fake, db, make_user, as_user): + owner, stranger = as_user(make_user()), as_user(make_user()) + sid = _start(owner)["id"] + r = owner.post(f"/api/debug-sessions/{sid}/preview", json={"text": "### Steps"}) + assert r.json() == {"ok": True, "html": "

Steps

"} + assert ( + stranger.post(f"/api/debug-sessions/{sid}/preview", json={"text": "x"}).status_code == 404 + ) + assert stranger.post(f"/api/debug-sessions/{sid}/issue").status_code == 404 + + +def test_the_admin_connection_test_names_what_is_wrong(gitea_fake, db, make_user, as_user): + admin = as_user(make_user(role="admin")) + r = admin.post("/api/admin/config/test-issues") + assert r.json() == {"ok": True, "repo": "o/r", "url": "https://git.example/o/r"} + gitea_fake.has_issues = False + r = admin.post("/api/admin/config/test-issues") + assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_disabled" + assert as_user(make_user()).post("/api/admin/config/test-issues").status_code == 403 diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index fba20a40..027476d1 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -134,6 +134,14 @@ export default function ConfigPanel() { notifyActionError(e, t("config.plexTestFailed")); }, }); + // Bug reports' "Check the connection" (E16): the repository answers, takes issues, is private. + const testIssues = useMutation({ + mutationFn: () => api.testIssues(), + onSuccess: (r) => + notify({ level: "success", message: t("config.issuesTestOk", { repo: r.repo }) }), + onError: (e) => notifyActionError(e, t("config.issuesTestFailed")), + }); + const plexLibs = (draft["plex_libraries"] ?? "") .split(",") .map((s) => s.trim()) @@ -218,6 +226,22 @@ export default function ConfigPanel() { )} + {activeGroup === "issues" && ( +
+ + testIssues.mutate()} + disabled={testIssues.isPending || dirty} + className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition" + > + + {t("config.issuesTest")} + + + {dirty &&

{t("config.plexTestDirty")}

} +
+ )} + {activeGroup === "plex" && (
diff --git a/frontend/src/components/DebugRecorder.dom.test.tsx b/frontend/src/components/DebugRecorder.dom.test.tsx index b6130bca..c9704a5b 100644 --- a/frontend/src/components/DebugRecorder.dom.test.tsx +++ b/frontend/src/components/DebugRecorder.dom.test.tsx @@ -34,6 +34,7 @@ const { order, session, startDebugSession, stopDebugSession } = vi.hoisted(() => ended_at: null, ended_reason: null, issue_url: null, + pending_files: [], screenshots: 0, draft: null, ...over, diff --git a/frontend/src/components/IssueComposer.dom.test.tsx b/frontend/src/components/IssueComposer.dom.test.tsx index 5f206f8a..b0a9466f 100644 --- a/frontend/src/components/IssueComposer.dom.test.tsx +++ b/frontend/src/components/IssueComposer.dom.test.tsx @@ -9,6 +9,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { DebugDraft, DebugSession, ServerLogRow } from "../lib/api"; +import { qk } from "../lib/queryKeys"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); @@ -28,6 +29,8 @@ const api = vi.hoisted(() => ({ saved: [] as DebugDraft[], uploaded: [] as Blob[], failSession: false, + sent: 0, + previewed: [] as string[], })); vi.mock("../lib/api", async (importOriginal) => { const actual = await importOriginal(); @@ -51,6 +54,19 @@ vi.mock("../lib/api", async (importOriginal) => { }); }, debugExportUrl: () => "/api/debug-sessions/0123456789abcdef/export", + sendDebugIssue: () => { + api.sent += 1; + api.session = { + ...api.session, + issue_url: "https://git.example/o/r/issues/7", + pending_files: [], + }; + return Promise.resolve({ ok: true, session: api.session }); + }, + previewDebugIssue: (_id: string, text: string) => { + api.previewed.push(text); + return Promise.resolve({ ok: true, html: "

Steps

" }); + }, }, }; }); @@ -84,6 +100,7 @@ const baseSession = (over: Partial = {}): DebugSession => ({ ended_at: "2026-09-19T14:16:00.000Z", ended_reason: "stopped", issue_url: null, + pending_files: [], screenshots: 0, draft: null, ...over, @@ -99,14 +116,20 @@ beforeEach(() => { api.saved = []; api.uploaded = []; api.failSession = false; + api.sent = 0; + api.previewed = []; + tracker = null; }); afterEach(() => { cleanup(); vi.useRealTimers(); }); +let tracker: { repo: string } | null = null; + function mount() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + qc.setQueryData(qk.me(), { id: 1, issues: tracker }); render( @@ -222,4 +245,49 @@ describe("the issue composer", () => { const message = await screen.findByText("recorder.composer.loadFailed"); expect(within(message.closest('[role="alert"]')!).getByRole("button")).not.toBeNull(); }); + + it("offers the tracker only where one is set up, and sends the saved draft", async () => { + mount(); + await screen.findByRole("button", { name: "recorder.composer.export" }); + expect(screen.queryByRole("button", { name: "recorder.composer.send" })).toBeNull(); + cleanup(); + + tracker = { repo: "o/r" }; + mount(); + expect(await screen.findByText("recorder.composer.target:o/r")).not.toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "recorder.composer.send" })); + await waitFor(() => expect(api.sent).toBe(1)); + expect(api.saved).toHaveLength(1); // saved first: the issue is built from what was saved + expect(await screen.findByRole("link", { name: /recorder.composer.openIssue/ })).not.toBeNull(); + }); + + it("finishes a send that stopped half-way, and says what is missing", async () => { + tracker = { repo: "o/r" }; + api.session = baseSession({ + issue_url: "https://git.example/o/r/issues/7", + pending_files: ["server.log"], + }); + mount(); + expect(await screen.findByText("recorder.composer.partial:server.log")).not.toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "recorder.composer.retry" })); + await waitFor(() => expect(api.sent).toBe(1)); + expect(api.saved).toHaveLength(0); // sent: nothing to save, only the rest to send + }); + + it("previews through the tracker in a frame where no script runs", async () => { + tracker = { repo: "o/r" }; + mount(); + fireEvent.click(await screen.findByRole("button", { name: "recorder.composer.preview" })); + const frame = (await screen.findByTitle("recorder.composer.previewTitle")) as HTMLIFrameElement; + expect(frame.getAttribute("sandbox")).not.toContain("allow-scripts"); + expect(frame.getAttribute("srcdoc")).toContain("

Steps

"); + expect(api.previewed[0]).toContain("1. Resume"); + }); + + it("previews the zip's own text where there is no tracker", async () => { + mount(); + fireEvent.click(await screen.findByRole("button", { name: "recorder.composer.preview" })); + expect(screen.getByText(/1\. Resume/).tagName).toBe("PRE"); + expect(api.previewed).toHaveLength(0); + }); }); diff --git a/frontend/src/components/IssueComposer.tsx b/frontend/src/components/IssueComposer.tsx index 2f44a867..d6e1a3b1 100644 --- a/frontend/src/components/IssueComposer.tsx +++ b/frontend/src/components/IssueComposer.tsx @@ -5,6 +5,8 @@ import { ExternalLink, FileText, Plus, Server, X } from "lucide-react"; import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../lib/api"; import { closeComposer } from "../lib/debugRecorder"; +import { notify } from "../lib/notifications"; +import { useMe } from "../lib/useMe"; import { draftSteps, environment, @@ -20,6 +22,7 @@ import { LEVEL_TAG, logClock } from "./logLineParts"; import { Button } from "./ui/Button"; import Modal from "./ui/Modal"; import { LoadingState, StateMessage } from "./ui/QueryState"; +import SegmentedToggle from "./ui/SegmentedToggle"; import { inputCls } from "./ui/form"; // The issue composer (E16 S3, D4 A): one dialog — the draft on the left (title, description, the @@ -165,6 +168,25 @@ function ComposerBody({ setForm(next); }; + // The issue (S4), like the export, is built from the SAVED draft: save what is on screen first. + // A send that stopped half-way is finished by the same call, which sends only what is missing. + const target = useMe()?.issues ?? null; + const pending = session.pending_files; + const send = useMutation({ + mutationFn: async () => { + if (!sent) await save.mutateAsync(); + return api.sendDebugIssue(session.id); + }, + meta: { failTitle: "recorder.composer.sendFailed" }, + onSuccess: (r) => { + qc.setQueryData(qk.debugSession(session.id), r.session); + notify({ level: "success", message: t("recorder.composer.sentOk") }); + }, + // A failure half-way still created the issue: show what the server now has. + onSettled: () => void qc.invalidateQueries({ queryKey: qk.debugSessions() }), + }); + const [mode, setMode] = useState<"edit" | "preview">("edit"); + // The export is built from the SAVED draft: save what is on screen first. const exportZip = async () => { if (!sent) await save.mutateAsync(); @@ -191,6 +213,19 @@ function ComposerBody({ level: t(`settings.diagnostics.levels.${session.level}`), })}

+ {sent && pending.length > 0 && ( +
+ {t("recorder.composer.partial", { files: pending.join(", ") })} + +
+ )} {sent && (
{t("recorder.composer.sent")} @@ -205,7 +240,26 @@ function ComposerBody({
)} - {reviewing ? ( + {!reviewing && ( + + )} + {mode === "preview" && !reviewing ? ( + + ) : reviewing ? ( sourceOf(r) === reviewing)} name={t( @@ -239,12 +293,17 @@ function ComposerBody({
)}
- - {save.isPending - ? t("recorder.composer.saving") - : save.isSuccess - ? t("recorder.composer.saved") - : ""} + + {target && {t("recorder.composer.target", { repo: target.repo })}} + + {send.isPending + ? t("recorder.composer.sending") + : save.isPending + ? t("recorder.composer.saving") + : save.isSuccess + ? t("recorder.composer.saved") + : ""} + + {target && !sent && ( + + )}
); @@ -563,3 +627,62 @@ function ReviewList({ ); } + +/** The report as it will read. Through the tracker, its OWN rendering (Gitea's markdown and + * sanitiser) — drawn in a sandboxed frame: no script runs in it, and nothing it holds reaches the + * app's document. Without a tracker, the markdown itself: it is the zip's issue.md. */ +function IssuePreview({ + sessionId, + text, + viaTracker, +}: { + sessionId: string; + text: string; + viaTracker: boolean; +}) { + const { t } = useTranslation(); + const q = useQuery({ + queryKey: [...qk.debugSession(sessionId), "preview", text], + queryFn: () => api.previewDebugIssue(sessionId, text), + enabled: viaTracker, + }); + const frame = useRef(null); + const [height, setHeight] = useState(240); + if (!viaTracker) + return ( +
+

{t("recorder.composer.previewLocal")}

+
+          {text}
+        
+
+ ); + if (q.isError && !q.data) + return ( + void q.refetch()}> + {t("recorder.composer.previewFailed")} + + ); + if (!q.data) return ; + const css = getComputedStyle(document.documentElement); + const color = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback; + const doc = `${q.data.html}`; + return ( +