"""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 [])