The composer's preview did not fail a call — it killed the Gitea process, and
with it the git server and the container registry. `POST /api/v1/markdown` with
`{"Mode": "gfm", "Context": "<repo>"}` makes Gitea resolve every hex-shaped word
as a commit link, and on the API's render path the commit checker it asks is nil:
panic: runtime error: invalid memory address or nil pointer dereference
renderhelper.(*commitChecker).IsCommitIDExisting(0x0)
<- markup.hashCurrentPatternProcessor (html_commit.go:217)
Every report carries hex — a tab id, a request id — so this was not an edge case
but the only case: the preview answered "the tracker isn't answering" every time,
because the tracker had just died.
Measured on throwaway instances, not argued. Same body, same call:
gfm + Context + "5645b1c0ae01" -> 1.26.4 CRASH, 1.27.1 CRASH, 1.27.3 200
gfm + Context, no hex -> 200
gfm, no Context -> 200, and the HTML keeps its tables, task
lists and chroma-highlighted code blocks
Upstream fixed it in 1.27.2 ("fix: avoid markup render panic", #38698), but a
self-hoster runs whatever version they run, so the payload is what gets fixed.
Sending was never affected: the created issue's own page renders the same body
fine (measured) — a real repo context has a real commit checker. The only loss is
that a hash or a `#123` is not yet a link in the preview; the issue page links it.
The test pins the payload dict, because the defect is in what we send.
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.
|
|
# noqa 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 ""
|