ruff gains RUF100, PLW1510 and flake8-bugbear, every hit fixed rather than ignored. B008 stays on with FastAPI's Depends/Query declared immutable. basedpyright moves to standard mode, pytest to --strict-markers, and a hand run of pytest now requires the database unless SIFTLODE_TEST_REQUIRE_DB=0. PLW1510 found a real bug: the three ffprobe probes in plex/stream.py never looked at the exit status, so a probe that failed was read as the file's truth (an HDR source sent down the HW-safe path, a multi-audio file collapsed to one track, a subtitle offset taken from garbage) and the warning each except branch promises never fired. They now run with check=True.
289 lines
14 KiB
Python
289 lines
14 KiB
Python
"""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 (TRACE) 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, whether the body shows the screenshots yet), so a failure
|
|
half-way keeps the issue that exists and a retry does only what is missing — never a second issue.
|
|
Every step is checkpointed (the caller's `db.commit`), so what did happen is kept whatever fails
|
|
next — and the first checkpoint comes before the first call, with the tracker's settings read ABOVE
|
|
it, so neither a row lock nor a read snapshot nor a pooled connection is held across the calls. The
|
|
caller claims the session for the send first (`debugsession.claim_send`) and passes the claim's
|
|
token in, which is what keeps a second send, an autosave or a screenshot out of the way; the claim
|
|
is renewed by every progress report, and a report whose claim is gone raises rather than writing
|
|
over the send that took it. `debugsession.not_uploaded` / `images_pending` read that progress.
|
|
"""
|
|
|
|
from collections.abc import Callable, Iterator
|
|
from contextlib import contextmanager
|
|
from typing import Any, Literal
|
|
|
|
import httpx
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import debugsession, 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, …), or an answer that is
|
|
# not the API's (a redirect — an http:// address behind https —, a page that is not JSON)
|
|
]
|
|
|
|
|
|
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(settings: tuple[str, str, str]) -> Iterator[tuple[httpx.Client, str]]:
|
|
# Takes the settings already read, not the session: reading them HERE opened a transaction one
|
|
# line after `deliver` had ended one, and held it across the calls below (deep-review round 4).
|
|
url, repo, token = settings
|
|
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 < 300:
|
|
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 _json(r: httpx.Response, *keys: str) -> dict[str, Any]:
|
|
"""The answer as the API's JSON, holding `keys` — anything else is refused, not a crash, so a
|
|
step after the issue was created cannot take down the request that holds its record.
|
|
|
|
The CREATE call is the one place this cannot keep that record: its answer IS the record. An
|
|
answer it cannot read there leaves the session unsent, and a retry opens another issue — the
|
|
price of not being able to tell a refused create from an unreadable one."""
|
|
try:
|
|
data = r.json()
|
|
# silent-ok: not JSON is one of the answers refused (and logged) just below.
|
|
except ValueError:
|
|
data = None
|
|
if not isinstance(data, dict) or any(k not in data for k in keys):
|
|
log.warning(
|
|
"the issue tracker answered something that is not its API",
|
|
extra=logs.event("issues.gitea.unexpected", status=r.status_code),
|
|
)
|
|
raise GiteaError("rejected")
|
|
return data
|
|
|
|
|
|
def _private_repo(client: httpx.Client, repo: str) -> dict:
|
|
data = _json(_call(client, "GET", f"/repos/{repo}"))
|
|
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(_settings(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).
|
|
|
|
**No `Context`, deliberately — a repository context here does not fail the call, it kills the
|
|
tracker.** With one, Gitea's post-processor turns every hex-shaped word into a commit link, and
|
|
on the API's render path the commit checker it asks is nil: the whole Gitea process segfaults
|
|
and restarts, taking the git server (and, here, the container registry) down with it. Every
|
|
report carries hex — a tab id, a request id — so this was not an edge case but the only case.
|
|
Measured on throwaway instances (2026-09-20): `{"Mode": "gfm", "Context": "<repo>"}` with
|
|
`5645b1c0ae01` in the text crashes 1.26.4 and 1.27.1 and is fixed in 1.27.3 — but a self-hoster
|
|
runs whatever version they run, so the payload stays safe here rather than relying on theirs.
|
|
Dropping `Context` is not a downgrade of the preview: measured against the same body, the HTML
|
|
keeps its tables, its task lists and its highlighted code blocks. It only stops resolving
|
|
repo-relative links (a hash, a `#123`), which the issue page itself still resolves.
|
|
"""
|
|
with _client(_settings(db)) as (client, _repo):
|
|
r = _call(client, "POST", "/markdown", json={"Text": text, "Mode": "gfm"})
|
|
return r.text
|
|
|
|
|
|
def deliver(
|
|
db: Session,
|
|
session: DebugSession,
|
|
*,
|
|
token: str,
|
|
checkpoint: Callable[[], None],
|
|
before_create: Callable[[], None] | None = None,
|
|
on_created: Callable[[], None] | None = None,
|
|
) -> str:
|
|
"""Send the session's issue, or finish sending it; returns its URL.
|
|
|
|
`token` is the caller's claim on this session (`debugsession.claim_send`). Every progress report
|
|
renews it and checks the row still holds it, so a send that outlived its lease stops here rather
|
|
than writing over the one that took over (`debugsession.SendClaimLost`).
|
|
|
|
`checkpoint` persists what has happened so far and is called after every step (the caller's
|
|
commit — this module does not decide when a transaction ends). `before_create` runs right before
|
|
the tracker is asked to open a NEW issue, after the checks that can refuse it without asking,
|
|
and may raise to stop it; `on_created` runs right after one was opened, in time to be committed
|
|
by the SAME checkpoint that first writes `issue_url` — anything the caller must not be able to
|
|
lose separately from the link belongs there.
|
|
"""
|
|
state = debugsession.delivery(session)
|
|
files = bundle.files(db, session)
|
|
attachments = [f for f in files if f.name != "issue.md"]
|
|
settings = _settings(db)
|
|
# Reading the bundle and the tracker's settings opened a transaction; end it before the first
|
|
# call, so nothing holds a snapshot (and an idle connection) across minutes of tracker I/O.
|
|
# The settings are read HERE and handed to `_client`, not read inside it: reading them there
|
|
# was a SELECT one line after this commit, which reopened the transaction and held it — idle,
|
|
# with its snapshot pinned — across the repository check and the create (deep-review round 4
|
|
# measured it in pg_stat_activity). A comment saying otherwise sat right here.
|
|
checkpoint()
|
|
with _client(settings) as (client, repo):
|
|
_private_repo(client, repo)
|
|
if "number" not in state:
|
|
if before_create is not None:
|
|
before_create()
|
|
created = _json(
|
|
_call(
|
|
client,
|
|
"POST",
|
|
f"/repos/{repo}/issues",
|
|
json={"title": bundle.title_of(session), "body": bundle.body_of(session)},
|
|
),
|
|
"number",
|
|
"html_url",
|
|
)
|
|
# Built ON the state read at the start so nothing already in it is dropped. The claim
|
|
# is NOT among those things, whatever this comment used to say: `set_delivery` strips
|
|
# any `sending` passed to it and stamps the row's own from `token` (round 4 — removing
|
|
# the spread left the whole suite green, because the claim never travelled here).
|
|
state = {
|
|
**state,
|
|
"number": created["number"],
|
|
"pending": [f.name for f in attachments],
|
|
"links": {},
|
|
}
|
|
session.issue_url = created["html_url"]
|
|
try:
|
|
debugsession.set_delivery(db, session, state, token)
|
|
except debugsession.SendClaimLost:
|
|
# The one step where losing the claim costs something irreversible: the issue is
|
|
# already open on the tracker and this is what would have recorded its link. We
|
|
# still must not write — another send owns the row and is opening its own issue —
|
|
# so say which issue was left behind, because nothing else will.
|
|
# TRY400: the traceback is one SendClaimLost carrying the session id and
|
|
# nothing else; the issue number below is the whole news.
|
|
log.error( # noqa: TRY400
|
|
"an issue was created by a send that had already lost its claim",
|
|
extra=logs.event(
|
|
"issues.gitea.orphaned",
|
|
number=created["number"],
|
|
session_id=session.id,
|
|
),
|
|
)
|
|
raise
|
|
if on_created is not None:
|
|
on_created()
|
|
checkpoint()
|
|
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 = _json(
|
|
_call(
|
|
client,
|
|
"POST",
|
|
f"/repos/{repo}/issues/{number}/assets",
|
|
params={"name": item.name},
|
|
files={"attachment": (item.name, item.data, item.mime)},
|
|
),
|
|
"browser_download_url",
|
|
)
|
|
state = {
|
|
**state,
|
|
"pending": [n for n in state["pending"] if n != item.name],
|
|
"links": {**state["links"], item.name: asset["browser_download_url"]},
|
|
}
|
|
debugsession.set_delivery(db, session, state, token)
|
|
checkpoint()
|
|
shots = [n for n in state["links"] if n.startswith(debugsession.SCREENSHOT_PREFIX)]
|
|
if shots and not state.get("images_shown"):
|
|
images = "\n".join(f"" 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})
|
|
debugsession.set_delivery(db, session, {**state, "images_shown": True}, token)
|
|
checkpoint()
|
|
return session.issue_url or ""
|