feat: resume fully planned drafts across devices (Closes #859)
This commit is contained in:
1 parent
7cab4e8749
commit
f23839f2b9
10 files changed
+380
-30
No files matched your search
+35
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {}),
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user