feat: resume fully planned drafts across devices (Closes #859)
CI / lint (pull_request) Successful in 1m52s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-15 00:07:32 +00:00
1 parent 7cab4e8749
commit f23839f2b9
10 files changed
+380 -30

No files matched your search

+35
View File
@@ -646,11 +646,46 @@ class UnfiledDraftEvidence(BaseModel):
data: str = Field(max_length=14_000_000)
class UnfiledDraftFilingPlan(BaseModel):
repository: str = Field(
min_length=3,
max_length=200,
pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
)
label_ids: list[PositiveInt] = Field(default_factory=list, max_length=20)
milestone_id: PositiveInt | None = None
due_date: str | None = Field(default=None, pattern=r"^\d{4}-\d{2}-\d{2}$", max_length=10)
template_name: str | None = Field(default=None, min_length=1, max_length=80)
template_id: str | None = Field(default=None, min_length=1, max_length=80)
captured_body: str | None = Field(default=None, min_length=1, max_length=10_000)
assignee: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_.-]+$", max_length=255)
assignee_name: str | None = Field(default=None, min_length=1, max_length=255)
unassigned: bool = False
estimate_minutes: int | None = Field(default=None, ge=5, le=1440)
completion_intent: Literal["create", "create-and-start"] | None = None
@field_validator("due_date")
@classmethod
def validate_filing_due_date(cls, value: str | None) -> str | None:
if value is not None:
datetime.strptime(value, "%Y-%m-%d")
return value
@model_validator(mode="after")
def require_consistent_owner_intent(self):
if self.unassigned and self.assignee is not None:
raise ValueError("assignee and unassigned cannot be requested together")
if self.assignee is None and self.assignee_name is not None:
raise ValueError("assignee name requires an assignee")
return self
class UnfiledDraft(BaseModel):
id: str = Field(min_length=1, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
saved_at: int = Field(ge=0)
filing_plan: UnfiledDraftFilingPlan | None = None
blockers: list[UnfiledDraftBlocker] = Field(default_factory=list, max_length=5)
evidence: list[UnfiledDraftEvidence] = Field(default_factory=list, max_length=5)
+71
View File
@@ -5,6 +5,7 @@ import binascii
import json
import re
import sqlite3
from datetime import datetime
from pathlib import Path
@@ -72,6 +73,74 @@ class UnfiledDraftStore:
).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")
@@ -97,6 +166,7 @@ class UnfiledDraftStore:
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")
@@ -149,6 +219,7 @@ class UnfiledDraftStore:
"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 {}),
})