"""Durable, account-scoped unfiled issue drafts and ordered evidence.""" import base64 import binascii import json import re import sqlite3 from datetime import datetime from pathlib import Path from src.private_state import connect_private_sqlite _DRAFT_ID = re.compile(r"^[A-Za-z0-9_-]{1,100}$") _REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") _CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp"} class UnfiledDraftConflict(ValueError): """Raised when a client attempts to replace a stale draft collection.""" def __init__(self, snapshot: dict): super().__init__("unfiled drafts changed on another device") self.snapshot = snapshot class UnfiledDraftStore: def __init__( self, path: str | Path, *, limit: int = 20, max_total_bytes: int = 12 * 1024 * 1024, timeout: float = 1.0, ): self.path = Path(path) self.limit = limit self.max_total_bytes = max_total_bytes self.timeout = timeout self._initialize() def _initialize(self) -> None: with self._connect() as connection: connection.execute("PRAGMA journal_mode=WAL") connection.execute( """CREATE TABLE IF NOT EXISTS unfiled_drafts ( login TEXT PRIMARY KEY, revision INTEGER NOT NULL, drafts TEXT NOT NULL )""" ) def _connect(self) -> sqlite3.Connection: return connect_private_sqlite(self.path, timeout=self.timeout) @staticmethod def _login(login: str) -> str: normalized = login.strip().lower() if not normalized: raise ValueError("login is required") return normalized @staticmethod def _snapshot(row) -> dict: return {"revision": 0, "drafts": []} if row is None else { "revision": int(row[0]), "drafts": json.loads(row[1]) } def get(self, login: str) -> dict: with self._connect() as connection: row = connection.execute( "SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", (self._login(login),), ).fetchone() return self._snapshot(row) @staticmethod def _filing_plan(raw: object) -> dict | None: if raw is None: return None if not isinstance(raw, dict): raise ValueError("filing plan is invalid") repository = raw.get("repository") if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository): raise ValueError("filing repository is invalid") label_ids = raw.get("label_ids", []) if not isinstance(label_ids, list) or len(label_ids) > 20: raise ValueError("filing labels are invalid") if any(not isinstance(item, int) or isinstance(item, bool) or item < 1 for item in label_ids): raise ValueError("filing labels are invalid") if len(set(label_ids)) != len(label_ids): raise ValueError("filing labels are invalid") plan = {"repository": repository, "label_ids": label_ids} for key in ("milestone_id", "estimate_minutes"): value = raw.get(key) if value is None: continue upper = 1440 if key == "estimate_minutes" else None lower = 5 if key == "estimate_minutes" else 1 if ( not isinstance(value, int) or isinstance(value, bool) or value < lower or (upper is not None and value > upper) ): raise ValueError(f"filing {key.replace('_', ' ')} is invalid") plan[key] = value due_date = raw.get("due_date") if due_date is not None: try: datetime.strptime(due_date, "%Y-%m-%d") except (TypeError, ValueError) as error: raise ValueError("filing due date is invalid") from error plan["due_date"] = due_date for key, limit in { "template_name": 80, "template_id": 80, "captured_body": 10_000, "assignee_name": 255, }.items(): value = raw.get(key) if value is not None: if not isinstance(value, str) or not value.strip() or len(value) > limit: raise ValueError(f"filing {key.replace('_', ' ')} is invalid") plan[key] = value unassigned = raw.get("unassigned", False) if not isinstance(unassigned, bool): raise ValueError("filing owner intent is invalid") assignee = raw.get("assignee") if unassigned and assignee is not None: raise ValueError("filing owner intent is invalid") if unassigned: plan["unassigned"] = True elif assignee is not None: if not isinstance(assignee, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+", assignee): raise ValueError("filing assignee is invalid") plan["assignee"] = assignee completion_intent = raw.get("completion_intent") if completion_intent is not None: if completion_intent not in {"create", "create-and-start"}: raise ValueError("filing completion intent is invalid") plan["completion_intent"] = completion_intent return plan def _normalize(self, drafts: list[dict]) -> list[dict]: if not isinstance(drafts, list): raise ValueError("drafts must be a list") if len(drafts) > self.limit: raise ValueError(f"unfiled drafts are limited to {self.limit}") normalized = [] seen = set() decoded_total = 0 for raw in drafts: if not isinstance(raw, dict): raise ValueError("draft must be an object") draft_id = raw.get("id") if not isinstance(draft_id, str) or not _DRAFT_ID.fullmatch(draft_id): raise ValueError("draft id is invalid") if draft_id in seen: raise ValueError("draft ids must be unique") title = raw.get("title") body = raw.get("body", "") saved_at = raw.get("saved_at") if not isinstance(title, str) or not title.strip() or len(title.strip()) > 255: raise ValueError("title is invalid") if not isinstance(body, str) or len(body) > 10_000: raise ValueError("body is invalid") if not isinstance(saved_at, int) or isinstance(saved_at, bool) or saved_at < 0: raise ValueError("saved_at is invalid") filing_plan = self._filing_plan(raw.get("filing_plan")) blockers = raw.get("blockers", []) if not isinstance(blockers, list) or len(blockers) > 5: raise ValueError("blockers are invalid") clean_blockers = [] for blocker in blockers: repository = blocker.get("repository") if isinstance(blocker, dict) else None number = blocker.get("number") if isinstance(blocker, dict) else None blocker_title = blocker.get("title", "") if isinstance(blocker, dict) else "" if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository): raise ValueError("blocker repository is invalid") if not isinstance(number, int) or isinstance(number, bool) or number < 1: raise ValueError("blocker number is invalid") if not isinstance(blocker_title, str) or len(blocker_title) > 255: raise ValueError("blocker title is invalid") clean_blockers.append({"repository": repository, "number": number, "title": blocker_title}) evidence = raw.get("evidence", []) if not isinstance(evidence, list) or len(evidence) > 5: raise ValueError("evidence is invalid") clean_evidence = [] for item in evidence: if not isinstance(item, dict): raise ValueError("evidence is invalid") filename = item.get("filename") content_type = item.get("content_type") note = item.get("note", "") data = item.get("data") if not isinstance(filename, str) or not filename or len(filename) > 255: raise ValueError("evidence filename is invalid") if content_type not in _CONTENT_TYPES: raise ValueError("evidence content type is invalid") if not isinstance(note, str) or len(note) > 240: raise ValueError("evidence note is invalid") if not isinstance(data, str): raise ValueError("evidence data is invalid") try: decoded_total += len(base64.b64decode(data, validate=True)) except (binascii.Error, ValueError) as error: raise ValueError("evidence data must be valid base64") from error if decoded_total > self.max_total_bytes: raise ValueError("synchronized evidence is too large") clean_evidence.append({ "filename": filename, "content_type": content_type, **({"note": note} if note else {}), "data": data, }) seen.add(draft_id) normalized.append({ "id": draft_id, "title": title.strip(), "body": body, "saved_at": saved_at, **({"filing_plan": filing_plan} if filing_plan else {}), **({"blockers": clean_blockers} if clean_blockers else {}), **({"evidence": clean_evidence} if clean_evidence else {}), }) return normalized def replace(self, login: str, expected_revision: int, drafts: list[dict]) -> dict: login = self._login(login) if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0: raise ValueError("revision is invalid") normalized = self._normalize(drafts) serialized = json.dumps(normalized, separators=(",", ":")) with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") row = connection.execute( "SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", (login,) ).fetchone() current = self._snapshot(row) if current["revision"] != expected_revision: raise UnfiledDraftConflict(current) revision = expected_revision + 1 connection.execute( "INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?) " "ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, drafts=excluded.drafts", (login, revision, serialized), ) return {"revision": revision, "drafts": normalized}