"""What an issue from a debug session carries, as files โ€” one definition for the zip export and the Gitea upload, so the two cannot drift apart. * `issue.md` โ€” the title and the body the user wrote and reviewed in the composer (rendered there, in their language; the server does not rewrite it); * `client.log` / `server.log` โ€” the session's evidence (`debugsession.bundle`) minus the rows the user took out, one JSON object per line, browser rows and server rows apart. `.log`, not `.jsonl`: Gitea refuses that extension (measured, plans/e16 S0); * the screenshots, in the order they were taken. Every row is already redacted twice (at capture, and again when stored), and nothing here adds a field the review list did not show. """ import io import json import zipfile from dataclasses import dataclass from sqlalchemy import select from sqlalchemy.orm import Session from app import debugsession, logsink from app.models import DebugScreenshot, DebugSession @dataclass(frozen=True) class IssueFile: name: str data: bytes mime: str def title_of(session: DebugSession) -> str: # Not a sentence of ours: the composer always saves a title in the user's language, and a # session exported before it ever saved is named by its date alone, in no language at all. title = ((session.draft or {}).get("title") or "").strip() return title or f"{session.started_at:%Y-%m-%d %H:%M} ยท {session.id}" def body_of(session: DebugSession) -> str: return ((session.draft or {}).get("body") or "").strip() def _lines(rows) -> bytes: return "".join( json.dumps(logsink.serialize(r), ensure_ascii=False, default=str) + "\n" for r in rows ).encode() def files(db: Session, session: DebugSession) -> list[IssueFile]: """The session's issue as files: the text, the two logs, the screenshots.""" rows, _ = debugsession.bundle(db, session, drop_excluded=True) text = f"# {title_of(session)}\n\n{body_of(session)}\n" out = [ IssueFile("issue.md", text.encode(), "text/markdown"), IssueFile("client.log", _lines(r for r in rows if r.source == "web"), "text/plain"), IssueFile("server.log", _lines(r for r in rows if r.source != "web"), "text/plain"), ] shots = db.scalars( select(DebugScreenshot) .where(DebugScreenshot.session_id == session.id) .order_by(DebugScreenshot.id) ) for n, shot in enumerate(shots, start=1): ext = debugsession.IMAGE_TYPES[shot.mime] name = f"{debugsession.SCREENSHOT_PREFIX}{n}.{ext}" out.append(IssueFile(name, shot.data, shot.mime)) return out def zipped(items: list[IssueFile]) -> bytes: buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: for item in items: # The images are compressed already: deflating them again costs time and wins nothing. method = zipfile.ZIP_STORED if item.mime.startswith("image/") else zipfile.ZIP_DEFLATED z.writestr(item.name, item.data, compress_type=method) return buf.getvalue()