fix(diagnostics): deep-review round 1 — a send that cannot race, lose or repeat itself

The send, the draft and the screenshots take the session's row lock, so an
autosave or a paste during a send waits for it and then sees it, and two sends
cannot open two issues. A save keeps the delivery's progress; a failed edit
that shows the screenshots is finished by the retry; an answer that is not the
tracker's API (a redirect, a proxy page) is refused, not a crash that loses the
issue's record. Only an attempt that opened an issue counts toward the pace.

Screenshots: deleting one after the send is refused, a user's storage is
capped across sessions, new sessions and zip exports are paced, images are
stored uncompressed in the zip. One liveness rule, one session view, one image
type table.

Logging: a session's elevation is tracked apart from the user's own, so the
browser's own requests (no header) no longer lower the floor a session needs;
Stop ends it at once.

Composer: an image pasted anywhere is taken; what is still unsaved is saved on
close, and "saved" is shown only when it is; the draft is held while it is
sent; a failed screenshot read says so instead of reading as none; focus moves
to what stays; the preview follows the text size. Pill: the step prompt and a
running Stop survive the pill moving between hosts, the prompt keeps the
dialog's Tab trap, the toast region lets clicks through, other tabs hear of a
Start or Stop, the last seconds are sent before the cap, a capture with no
frame is an error.
This commit is contained in:
2026-09-20 00:09:52 +02:00
parent 9cf177b198
commit 134ec9a68e
25 changed files with 1007 additions and 208 deletions
+57 -30
View File
@@ -17,19 +17,20 @@ A private repository's attachment answers 404 to a browser not signed in to Gite
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.
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 flushes; the caller holds the session's lock throughout (`debugsession.lock`) and commits,
also when a step fails, so what did happen is kept. `debugsession.unfinished` reads that progress.
"""
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from typing import Literal
from typing import Any, Literal
import httpx
from sqlalchemy.orm import Session
from app import logs, sysconfig
from app import debugsession, logs, sysconfig
from app.issues import bundle
from app.models import DebugSession
@@ -44,7 +45,8 @@ Reason = Literal[
"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, …)
"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)
]
@@ -91,7 +93,7 @@ def _call(client: httpx.Client, method: str, path: str, **kwargs) -> httpx.Respo
extra=logs.event("issues.gitea.unreachable", path=path, error=type(exc).__name__),
)
raise GiteaError("unreachable") from None
if r.status_code < 400:
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(
@@ -107,8 +109,25 @@ def _call(client: httpx.Client, method: str, path: str, **kwargs) -> httpx.Respo
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: a
crash after the issue was created would lose the record that it was."""
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 = _call(client, "GET", f"/repos/{repo}").json()
data = _json(_call(client, "GET", f"/repos/{repo}"))
if not data.get("private"):
raise GiteaError("public_repo")
if not data.get("has_issues"):
@@ -135,20 +154,30 @@ def _save(session: DebugSession, state: dict) -> None:
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 {})
def deliver(
db: Session, session: DebugSession, *, before_create: Callable[[], None] | None = None
) -> str:
"""Send the session's issue, or finish sending it; returns its URL. Flushes after every step.
`before_create` runs right before a NEW issue is opened — after every check that could refuse
it — and may raise to stop it (the caller's pace on new issues)."""
state = debugsession.delivery(session)
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()
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",
)
state = {
"number": created["number"],
"pending": [f.name for f in attachments],
@@ -165,13 +194,16 @@ def deliver(db: Session, session: DebugSession) -> str:
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()
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],
@@ -187,8 +219,3 @@ def deliver(db: Session, session: DebugSession) -> str:
_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 [])