"""Encrypted, account-scoped registry of explicitly followed Gitea issues.""" import re import sqlite3 from pathlib import Path from src.private_state import connect_private_sqlite from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config _REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") _STATES = {"open", "closed"} _KINDS = {"issue", "pull"} class FollowingStore: def __init__( self, path: str | Path, *, limit: int = 50, timeout: float = 1.0, encryption_key: bytes | None = None, ): self.path = Path(path) self.limit = limit self.timeout = timeout self._cipher = PrivateStateCipher( encryption_key if encryption_key is not None else private_state_encryption_config(), store="following", ) self._initialize() def _connect(self) -> sqlite3.Connection: return connect_private_sqlite(self.path, timeout=self.timeout) def _initialize(self) -> None: with self._connect() as connection: connection.execute("PRAGMA journal_mode=WAL") connection.execute( """ CREATE TABLE IF NOT EXISTS following_issues ( login TEXT PRIMARY KEY, revision INTEGER NOT NULL, items TEXT NOT NULL ) """ ) @staticmethod def _login(login: str) -> str: normalized = str(login).strip().lower() if not normalized: raise ValueError("login is required") return normalized def _snapshot(self, row, login: str) -> tuple[dict, bool]: if row is None: return {"revision": 0, "items": []}, False items, legacy = self._cipher.open(row[1], binding=f"items:{login}") if not isinstance(items, list): raise PrivateStateEncryptionError("private state could not be decrypted") return {"revision": int(row[0]), "items": items}, legacy def _seal(self, login: str, items: list[dict]) -> str: return self._cipher.seal(items, binding=f"items:{login}") @staticmethod def _normalize_item(raw: dict) -> dict: if not isinstance(raw, dict): raise ValueError("following item must be an object") repository = raw.get("repository") if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository): raise ValueError("repository is invalid") number = raw.get("number") if not isinstance(number, int) or isinstance(number, bool) or number < 1: raise ValueError("number is invalid") kind = raw.get("kind", "issue") if kind not in _KINDS: raise ValueError("kind is invalid") title = raw.get("title") if not isinstance(title, str) or not title.strip() or len(title.strip()) > 300: raise ValueError("title is invalid") state = raw.get("state") if state not in _STATES: raise ValueError("state is invalid") updated_at = raw.get("updated_at") if not isinstance(updated_at, str) or not updated_at or len(updated_at) > 64: raise ValueError("updated_at is invalid") url = raw.get("url") if not isinstance(url, str) or not url.startswith(("http://", "https://")) or len(url) > 2048: raise ValueError("url is invalid") last_seen_updated_at = raw.get("last_seen_updated_at", updated_at) if ( not isinstance(last_seen_updated_at, str) or not last_seen_updated_at or len(last_seen_updated_at) > 64 ): raise ValueError("last seen update is invalid") item = { "repository": repository, "kind": kind, "number": number, "title": title.strip(), "state": state, "updated_at": updated_at, "url": url, "last_seen_updated_at": last_seen_updated_at, } kept_updated_at = raw.get("kept_updated_at") if kept_updated_at is not None: if not isinstance(kept_updated_at, str) or not kept_updated_at or len(kept_updated_at) > 64: raise ValueError("kept update is invalid") item["kept_updated_at"] = kept_updated_at reviewed_title = raw.get("reviewed_title") if reviewed_title is not None: if not isinstance(reviewed_title, str) or not reviewed_title.strip() or len(reviewed_title.strip()) > 300: raise ValueError("reviewed title is invalid") item["reviewed_title"] = reviewed_title.strip() reviewed_state = raw.get("reviewed_state") if reviewed_state is not None: if reviewed_state not in _STATES: raise ValueError("reviewed state is invalid") item["reviewed_state"] = reviewed_state return item @classmethod def _present(cls, snapshot: dict) -> dict: changed = [] unchanged = [] for raw in snapshot["items"]: stored = cls._normalize_item(raw) unseen = (stored["updated_at"] != stored["last_seen_updated_at"] or stored.get("kept_updated_at") == stored["updated_at"]) item = {key: value for key, value in stored.items() if key not in {"last_seen_updated_at", "kept_updated_at", "reviewed_title", "reviewed_state"}} item["has_unseen_change"] = unseen if unseen: item["reviewed_at"] = stored["last_seen_updated_at"] if stored.get("reviewed_state") == "open" and stored["state"] == "closed": item["change_summary"] = "Closed since last review" elif stored.get("reviewed_state") == "closed" and stored["state"] == "open": item["change_summary"] = "Reopened since last review" elif stored.get("reviewed_title") not in {None, stored["title"]}: item["change_summary"] = "Title changed since last review" else: item["change_summary"] = "New activity" (changed if unseen else unchanged).append(item) changed.sort(key=lambda item: item["updated_at"], reverse=True) return {"revision": snapshot["revision"], "items": changed + unchanged} @staticmethod def _identity(item: dict) -> tuple[str, str, int]: return item.get("kind", "issue"), item["repository"].lower(), item["number"] def get(self, login: str) -> dict: login = self._login(login) with self._connect() as connection: row = connection.execute( "SELECT revision, items FROM following_issues WHERE login = ?", (login,) ).fetchone() snapshot, legacy = self._snapshot(row, login) snapshot["items"] = [self._normalize_item(item) for item in snapshot["items"]] if row is not None and legacy: connection.execute( "UPDATE following_issues SET items = ? WHERE login = ? AND items = ?", (self._seal(login, snapshot["items"]), login, row[1]), ) return self._present(snapshot) def preflight(self, login: str, raw_item: dict, watching: bool) -> dict: """Validate a requested change and capacity without mutating the registry.""" login = self._login(login) item = self._normalize_item(raw_item) if not isinstance(watching, bool): raise ValueError("watching is invalid") if not watching: return item identity = self._identity(item) with self._connect() as connection: row = connection.execute( "SELECT revision, items FROM following_issues WHERE login = ?", (login,) ).fetchone() current, _legacy = self._snapshot(row, login) if ( len(current["items"]) >= self.limit and not any(self._identity(candidate) == identity for candidate in current["items"]) ): raise ValueError(f"following is limited to {self.limit} issues") return item def set_watching(self, login: str, raw_item: dict, watching: bool) -> dict: login = self._login(login) item = self._normalize_item(raw_item) if not isinstance(watching, bool): raise ValueError("watching is invalid") identity = self._identity(item) with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") row = connection.execute( "SELECT revision, items FROM following_issues WHERE login = ?", (login,) ).fetchone() current, _legacy = self._snapshot(row, login) items = [self._normalize_item(candidate) for candidate in current["items"]] index = next( (position for position, candidate in enumerate(items) if self._identity(candidate) == identity), None, ) if watching: if index is None: if len(items) >= self.limit: raise ValueError(f"following is limited to {self.limit} issues") items.insert(0, item) else: item["last_seen_updated_at"] = items[index]["last_seen_updated_at"] for key in ("kept_updated_at", "reviewed_title", "reviewed_state"): if key in items[index]: item[key] = items[index][key] if items[index] == item: return self._present({"revision": current["revision"], "items": items}) items.pop(index) items.insert(0, item) elif index is None: return self._present({"revision": current["revision"], "items": items}) else: items.pop(index) revision = current["revision"] + 1 connection.execute( "INSERT INTO following_issues(login, revision, items) VALUES (?, ?, ?) " "ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, items=excluded.items", (login, revision, self._seal(login, items)), ) return self._present({"revision": revision, "items": items}) def refresh(self, login: str, raw_items: list[dict]) -> dict: """Merge successful upstream snapshots while preserving seen revisions.""" login = self._login(login) fresh = {self._identity(item): self._normalize_item(item) for item in raw_items} with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") row = connection.execute( "SELECT revision, items FROM following_issues WHERE login = ?", (login,) ).fetchone() current, _legacy = self._snapshot(row, login) items = [self._normalize_item(candidate) for candidate in current["items"]] changed = False for index, item in enumerate(items): update = fresh.get(self._identity(item)) if update is None: continue update["last_seen_updated_at"] = item["last_seen_updated_at"] for key in ("kept_updated_at", "reviewed_title", "reviewed_state"): if key in item: update[key] = item[key] if update["updated_at"] != item["updated_at"]: update.setdefault("reviewed_title", item["title"]) update.setdefault("reviewed_state", item["state"]) if update != item: items[index] = update changed = True revision = current["revision"] if changed: revision += 1 connection.execute( "UPDATE following_issues SET revision = ?, items = ? WHERE login = ?", (revision, self._seal(login, items), login), ) return self._present({"revision": revision, "items": items}) def acknowledge( self, login: str, repository: str, number: int, updated_at: str, *, kind: str = "issue", ) -> dict: """Mark only the exact upstream revision successfully opened by the operator.""" login = self._login(login) if kind not in _KINDS: raise ValueError("kind is invalid") identity = (kind, str(repository).lower(), number) with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") row = connection.execute( "SELECT revision, items FROM following_issues WHERE login = ?", (login,) ).fetchone() current, _legacy = self._snapshot(row, login) items = [self._normalize_item(candidate) for candidate in current["items"]] revision = current["revision"] for item in items: if self._identity(item) != identity or item["updated_at"] != updated_at: continue if (item["last_seen_updated_at"] != updated_at or item.get("kept_updated_at") == updated_at): item["last_seen_updated_at"] = updated_at item.pop("kept_updated_at", None) item.pop("reviewed_title", None) item.pop("reviewed_state", None) revision += 1 connection.execute( "UPDATE following_issues SET revision = ?, items = ? WHERE login = ?", (revision, self._seal(login, items), login), ) break return self._present({"revision": revision, "items": items}) def keep_unseen( self, login: str, repository: str, number: int, updated_at: str, *, kind: str = "issue", ) -> dict: """Restore only the exact loaded revision to the unseen review queue.""" login = self._login(login) if kind not in _KINDS: raise ValueError("kind is invalid") identity = (kind, str(repository).lower(), number) with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") row = connection.execute( "SELECT revision, items FROM following_issues WHERE login = ?", (login,) ).fetchone() current, _legacy = self._snapshot(row, login) items = [self._normalize_item(candidate) for candidate in current["items"]] revision = current["revision"] for item in items: if self._identity(item) != identity or item["updated_at"] != updated_at: continue if item.get("kept_updated_at") != updated_at: item["kept_updated_at"] = updated_at revision += 1 connection.execute( "UPDATE following_issues SET revision = ?, items = ? WHERE login = ?", (revision, self._seal(login, items), login), ) break return self._present({"revision": revision, "items": items})