Round 2 made the step prompt a blocking layer so that `j` and the `g` chords would not reach the page behind it. The dialog it sits in gave up its Tab trap because of that, while the prompt's own replacement trap covered only its own two controls — so Tab from anywhere else in the dialog (the bar's Screenshot or Stop, the dialog's own buttons) walked out behind the scrim. `layerStack` now separates the two questions it had folded into one flag: a layer can own the keyboard and still leave the Tab trap to the dialog below it. The mirror case, a dialog opening over the prompt, falls out of the same split. A send's claim is now a lease with an owner. Every progress report re-stamps it, so a send slower than the lease cannot lose it while it is still running, and only the send that took a claim can give it back. The issue_create audit row is staged inside the send and rides the same checkpoint as `issue_url`, so the trail cannot be missing for an issue that exists. The attachment-name prefix has one definition, shared by the producer and both readers. A running send is reported to the UI. A reload or a second tab used to render it exactly like a send that STOPPED, and offered a retry the server was certain to refuse; the composer now says the report is being sent, holds the draft, and puts both the retry and Create issue out. The past-sessions list says the same for the same reason — it is the list a user opens to check on a send, and both "draft" and "half sent" describe one that died. `/preview` and the retry path got a pace of their own, and the per-user byte cap took the user-scoped lock that makes it a cap instead of a guess. Verified in a real browser on localdev: Tab cycles the whole dialog with the prompt open, the prompt still swallows the page's keys, and the issue link is focusable where the composer puts it. All 24 regression tests mutation-checked.
244 lines
10 KiB
Python
244 lines
10 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 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, so no row lock and no read snapshot is
|
|
held across them. The caller claims the session for the send first (`debugsession.claim_send`),
|
|
which is what keeps a second send, an autosave or a screenshot out of the way; the claim is renewed
|
|
by every checkpoint. `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(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 < 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(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 deliver(
|
|
db: Session,
|
|
session: DebugSession,
|
|
*,
|
|
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.
|
|
|
|
`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"]
|
|
# 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.
|
|
checkpoint()
|
|
with _client(db) 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, not in place of it: the caller's claim on this
|
|
# send lives there, and replacing it would let a draft or a screenshot change the issue
|
|
# under the uploads that follow.
|
|
state = {
|
|
**state,
|
|
"number": created["number"],
|
|
"pending": [f.name for f in attachments],
|
|
"links": {},
|
|
}
|
|
session.issue_url = created["html_url"]
|
|
debugsession.set_delivery(session, state)
|
|
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(session, state)
|
|
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(session, {**state, "images_shown": True})
|
|
checkpoint()
|
|
return session.issue_url or ""
|