249 lines
11 KiB
Python
249 lines
11 KiB
Python
"""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
|
|
|
|
|
|
_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:
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
with sqlite3.connect(self.path, timeout=self.timeout) 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 sqlite3.connect(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}
|