From f23839f2b904c9146d058d3dda28436fe704a154 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 00:07:32 +0000 Subject: [PATCH] feat: resume fully planned drafts across devices (Closes #859) --- frontend/create-issue-sheet.js | 14 +++- frontend/dashboard.js | 30 ++++----- frontend/draft-capacity-dialog.js | 8 +-- frontend/draft-filing-session.js | 3 +- frontend/unfiled-captures.js | 82 +++++++++++++++++++++++- src/main.py | 35 ++++++++++ src/unfiled_draft_store.py | 71 ++++++++++++++++++++ tests/test_my_work.py | 20 ++++++ tests/test_unfiled_captures.py | 103 +++++++++++++++++++++++++++++- tests/test_unfiled_draft_store.py | 44 ++++++++++++- 10 files changed, 380 insertions(+), 30 deletions(-) diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js index 69b853f..07c6efa 100644 --- a/frontend/create-issue-sheet.js +++ b/frontend/create-issue-sheet.js @@ -152,6 +152,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null; const safeDueDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) ? String(value) : ''; const safeAssignee = value => /^[A-Za-z0-9_.-]+$/.test(String(value || '')) ? String(value) : ''; + const safeEstimate = value => Number.isInteger(Number(value)) && Number(value) >= 5 && Number(value) <= 1440 ? + Number(value) : null; function loadStored() { try { @@ -179,6 +181,11 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp const dueDate = safeDueDate(parsed.dueDate); if (milestoneId !== null) draft.milestoneId = milestoneId; if (dueDate) draft.dueDate = dueDate; + const estimateMinutes = safeEstimate(parsed.estimateMinutes); + if (estimateMinutes !== null) draft.estimateMinutes = estimateMinutes; + if (['create', 'create-and-start'].includes(parsed.completionIntent)) { + draft.completionIntent = parsed.completionIntent; + } const blockers = safeBlockers(parsed.blockers); if (blockers.length) draft.blockers = blockers; return draft; @@ -215,10 +222,15 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp const dueDate = safeDueDate(draft?.dueDate); if (milestoneId !== null) safe.milestoneId = milestoneId; if (dueDate) safe.dueDate = dueDate; + const estimateMinutes = safeEstimate(draft?.estimateMinutes); + if (estimateMinutes !== null) safe.estimateMinutes = estimateMinutes; + if (['create', 'create-and-start'].includes(draft?.completionIntent)) { + safe.completionIntent = draft.completionIntent; + } const blockers = safeBlockers(draft?.blockers); if (blockers.length) safe.blockers = blockers; const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName', 'unassigned', - 'templateName', 'templateId', 'capturedBody'] + 'templateName', 'templateId', 'capturedBody', 'estimateMinutes', 'completionIntent'] .every(key => (previous[key] || '') === (safe[key] || '')) && JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) && JSON.stringify(previous.blockers || []) === JSON.stringify(safe.blockers || []); diff --git a/frontend/dashboard.js b/frontend/dashboard.js index cc6b4c7..c76417e 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1603,7 +1603,7 @@ qs('#create-issue-estimate-status').textContent = createAndStart.capacityMessage(result); return result; } - qs('#create-issue-estimate').addEventListener('input', renderCreateStartCapacity); + qs('#create-issue-estimate').addEventListener('input', () => { renderCreateStartCapacity(); saveIssueCaptureDraft(); }); const queueToday = createQueueToday({ todayWork, @@ -2502,7 +2502,7 @@ function listDrafts() { const unfiled = unfiledCaptures.list().map(item => ({ - id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', label:'Needs filing', + id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', ...unfiledDraftSummary(item), title:item.title, preview:item.body + (item.hasAttachment ? ' · Screenshot attached' : ''), hasAttachment:item.hasAttachment, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'), updated_at:item.savedAt, quarantined:item.quarantined, @@ -3712,15 +3712,13 @@ let issueCaptureBlockers = []; function saveIssueCaptureDraft() { - if (issueCapture) issueCapture.saveDraft(issueTemplatePicker.fields( - issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers) - )); + if (issueCapture) issueCapture.saveDraft(currentIssueCaptureDraft(false)); } - function currentIssueCaptureDraft() { - return issueTemplatePicker.fields( - issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers, true) - ); + function currentIssueCaptureDraft(trim = true) { + return withFilingEstimate(issueTemplatePicker.fields( + issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers, trim) + ), qs('#create-issue-estimate').value); } let issueCaptureRepositories = []; @@ -3932,6 +3930,7 @@ setIssueFilingMode(Boolean(captureDraft.repository)); qs('#create-issue-capture-status').textContent = ''; qs('#create-issue-due-date').value = captureDraft.dueDate || ''; + qs('#create-issue-estimate').value = captureDraft.estimateMinutes || ''; issueOwnerPicker.reset(captureDraft.repository, captureDraft); renderIssueCaptureBlockers(captureDraft.blockers || []); qs('#create-issue-blocker-search').value = ''; @@ -3961,9 +3960,7 @@ } function setIssueFilingMode(enabled) { - qs('#create-issue-filing').hidden = !enabled; - qs('.create-issue-capture-actions').hidden = enabled; - qs('#create-issue-heading').textContent = enabled ? 'File issue' : 'Capture work'; + applyIssueFilingMode(qs, enabled); } let suppressCreateDraftOnHistoryClose = false; @@ -5058,11 +5055,8 @@ qs('#save-unfiled-issue').addEventListener('click', async () => { try { const evidence = await createIssueAttachmentController.serialize(); - const captureDraft = { - title: qs('#create-issue-title').value.trim(), - body: qs('#create-issue-body').value.trim(), - ...(Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}), - }; + const captureDraft = currentIssueCaptureDraft(); + Object.assign(captureDraft, Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}); if (showDraftCapacityDialog(unfiledCaptures)) return; const savedCapture = await unfiledCaptures.save(captureDraft); if (rUC) { @@ -5082,7 +5076,7 @@ savedCard?.scrollIntoView({block:'nearest'}); savedCard?.focus({preventScroll:true}); }); - qs('#my-work-action-status').textContent = 'Saved to Drafts. Choose a repository when you’re ready to file it.'; + qs('#my-work-action-status').textContent = unfiledSavedMessage(captureDraft); } catch (error) { qs('#create-issue-capture-status').textContent = error.message; qs('#create-issue-title').focus(); diff --git a/frontend/draft-capacity-dialog.js b/frontend/draft-capacity-dialog.js index 6429bcc..0401cc5 100644 --- a/frontend/draft-capacity-dialog.js +++ b/frontend/draft-capacity-dialog.js @@ -40,10 +40,10 @@ function bindDraftCapacityDialog() { const button = qs('#replace-oldest-draft'); button.disabled = true; try { - const draft = { - title:qs('#create-issue-title').value.trim(), body:qs('#create-issue-body').value.trim(), - attachment:await createIssueAttachmentController.serialize(), - }; + const draft = currentIssueCaptureDraft(); + const evidence = await createIssueAttachmentController.serialize(); + Object.assign(draft, + Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}); const saved = await unfiledCaptures.replaceOldest(draft, oldest.id); qs('#draft-capacity-sheet').hidden = true; issueCapture.clearDraft(); diff --git a/frontend/draft-filing-session.js b/frontend/draft-filing-session.js index 66e44d5..4193fb1 100644 --- a/frontend/draft-filing-session.js +++ b/frontend/draft-filing-session.js @@ -54,7 +54,8 @@ function createDraftFilingSession({list}) { dependencies.issueCapture.saveDraft(resumed); dependencies.setResumedId(captureId); await dependencies.openSheet(); - dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment); + const evidence = resumed.attachments || resumed.attachment; + dependencies.attachment[evidence ? 'restore' : 'clear'](evidence); dependencies.setFilingMode(true); session.render(); }; diff --git a/frontend/unfiled-captures.js b/frontend/unfiled-captures.js index 451ca0d..8da9c0f 100644 --- a/frontend/unfiled-captures.js +++ b/frontend/unfiled-captures.js @@ -1,3 +1,25 @@ +function withFilingEstimate(draft, value) { + draft.estimateMinutes = Number(value) || null; + return draft; +} + +function unfiledDraftSummary(item) { + return {label:item.filingPlan?.repository ? 'Ready to file' : 'Needs filing', + repository:item.filingPlan?.repository || ''}; +} + +function unfiledSavedMessage(item) { + return item.repository ? 'Saved planned Draft. Resume on any signed-in device.' : + 'Saved to Drafts. Choose a repository when you’re ready to file it.'; +} + +function applyIssueFilingMode(qs, enabled) { + qs('#create-issue-filing').hidden = !enabled; + qs('.create-issue-capture-actions').hidden = false; + qs('#file-new-issue').hidden = enabled; + qs('#create-issue-heading').textContent = enabled ? 'File issue' : 'Capture work'; +} + function createUnfiledCaptures({ storage, attachmentStore = null, @@ -37,6 +59,56 @@ function createUnfiledCaptures({ return {full:items.length >= maxItems, count:items.length, maxItems, oldest:items.at(-1) || null}; } + function filingPlan(value = {}, remote = false) { + const get = (local, wire) => value?.[remote ? wire : local]; + const repository = String(get('repository', 'repository') || '').trim(); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return null; + const labelIds = Array.from(new Set((Array.isArray(get('labelIds', 'label_ids')) ? + get('labelIds', 'label_ids') : []).filter(id => Number.isInteger(id) && id > 0))).slice(0, 20); + const plan = {repository, labelIds}; + const milestoneId = Number(get('milestoneId', 'milestone_id')); + if (Number.isInteger(milestoneId) && milestoneId > 0) plan.milestoneId = milestoneId; + const dueDate = String(get('dueDate', 'due_date') || ''); + if (/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) plan.dueDate = dueDate; + const bounded = (local, wire, limit) => { + const text = String(get(local, wire) || '').trim(); + if (text) plan[local] = text.slice(0, limit); + }; + bounded('templateName', 'template_name', 80); + bounded('templateId', 'template_id', 80); + bounded('capturedBody', 'captured_body', 10000); + if (get('unassigned', 'unassigned') === true) plan.unassigned = true; + const assignee = String(get('assignee', 'assignee') || '').trim(); + if (!plan.unassigned && /^[A-Za-z0-9_.-]+$/.test(assignee)) { + plan.assignee = assignee; + bounded('assigneeName', 'assignee_name', 255); + } + const estimateMinutes = Number(get('estimateMinutes', 'estimate_minutes')); + if (Number.isInteger(estimateMinutes) && estimateMinutes >= 5 && estimateMinutes <= 1440) { + plan.estimateMinutes = estimateMinutes; + } + const completionIntent = String(get('completionIntent', 'completion_intent') || ''); + if (['create', 'create-and-start'].includes(completionIntent)) plan.completionIntent = completionIntent; + return plan; + } + + function exportFilingPlan(plan) { + if (!plan) return null; + return { + repository:plan.repository, label_ids:plan.labelIds, + ...(plan.milestoneId ? {milestone_id:plan.milestoneId} : {}), + ...(plan.dueDate ? {due_date:plan.dueDate} : {}), + ...(plan.templateName ? {template_name:plan.templateName} : {}), + ...(plan.templateId ? {template_id:plan.templateId} : {}), + ...(plan.capturedBody ? {captured_body:plan.capturedBody} : {}), + ...(plan.unassigned ? {unassigned:true} : {}), + ...(plan.assignee ? {assignee:plan.assignee} : {}), + ...(plan.assigneeName ? {assignee_name:plan.assigneeName} : {}), + ...(plan.estimateMinutes ? {estimate_minutes:plan.estimateMinutes} : {}), + ...(plan.completionIntent ? {completion_intent:plan.completionIntent} : {}), + }; + } + function prepare(note) { const title = String(note?.title || '').trim().slice(0, 255); const body = String(note?.body || '').trim().slice(0, 10000); @@ -75,6 +147,7 @@ function createUnfiledCaptures({ }, []); return { title, body, ownerLogin, attachment, attachments, blockers, + filingPlan:filingPlan(note), hasAttachment:hasAttachment || validAttachments, attachmentCount:validAttachments ? attachments.length : (hasAttachment ? 1 : 0), }; @@ -87,7 +160,7 @@ function createUnfiledCaptures({ hasAttachment:true, attachmentCount:prepared.attachmentCount, } : {}), ...(prepared.blockers.length ? { blockers:prepared.blockers, blockerCount:prepared.blockers.length, - } : {}), + } : {}), ...(prepared.filingPlan ? {filingPlan:prepared.filingPlan} : {}), }; const items = [item, ...existing.filter(candidate => candidate.id !== item.id)]; const writeItems = () => { @@ -175,7 +248,7 @@ function createUnfiledCaptures({ if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) { throw new Error('Reconnect with the account that saved this capture.'); } - const draft = {repository:'', title:item.title, body:item.body, labelIds:[], + const draft = {...(item.filingPlan || {repository:'', labelIds:[]}), title:item.title, body:item.body, ...(Array.isArray(item.blockers) && item.blockers.length ? {blockers:item.blockers} : {})}; if (!item.hasAttachment) return draft; if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.'); @@ -233,6 +306,7 @@ function createUnfiledCaptures({ } exported.push({ id:item.id, title:item.title, body:item.body, saved_at:Number(item.savedAt), + ...(item.filingPlan ? {filing_plan:exportFilingPlan(item.filingPlan)} : {}), ...(Array.isArray(item.blockers) && item.blockers.length ? {blockers:item.blockers} : {}), ...(evidence.length ? {evidence} : {}), }); @@ -250,9 +324,11 @@ function createUnfiledCaptures({ if (!remote || known.has(remote.id) || imported.length + existing.length >= maxItems) continue; const evidence = Array.isArray(remote.evidence) ? remote.evidence : []; if (evidence.length && !attachmentStore) continue; + const plan = filingPlan(remote.filing_plan, true); const item = { id:String(remote.id), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''), savedAt:Number(remote.saved_at), + ...(plan ? {filingPlan:plan} : {}), ...(Array.isArray(remote.blockers) && remote.blockers.length ? { blockers:remote.blockers, blockerCount:remote.blockers.length, } : {}), @@ -286,9 +362,11 @@ function createUnfiledCaptures({ if (evidence.length && !attachmentStore) { throw new Error('The synchronized screenshots cannot be stored on this device.'); } + const plan = filingPlan(remote.filing_plan, true); const item = { id:String(remote.id || ''), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''), savedAt:Number(remote.saved_at), + ...(plan ? {filingPlan:plan} : {}), ...(Array.isArray(remote.blockers) && remote.blockers.length ? { blockers:remote.blockers, blockerCount:remote.blockers.length, } : {}), diff --git a/src/main.py b/src/main.py index 0a4b627..0f38d5a 100644 --- a/src/main.py +++ b/src/main.py @@ -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) diff --git a/src/unfiled_draft_store.py b/src/unfiled_draft_store.py index 1968af1..22961cc 100644 --- a/src/unfiled_draft_store.py +++ b/src/unfiled_draft_store.py @@ -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 {}), }) diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 5785744..a67f52c 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -4091,6 +4091,26 @@ capture.saveDraft({{repository:'o/r',title:'Backlog capture',body:'Context',labe }] +def test_issue_capture_persists_estimate_and_completion_intent_for_resumed_drafts(): + script = f""" +const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))}); +const values=new Map(); +const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}}; +const capture=createIssueCapture({{storage,fetchJson:async()=>{{}}}}); +capture.saveDraft({{ + repository:'o/r',title:'Planned work',body:'Context',labelIds:[3], + estimateMinutes:45,completionIntent:'create-and-start', +}}); +process.stdout.write(JSON.stringify(capture.loadDraft())); +""" + output = json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + assert output["estimateMinutes"] == 45 + assert output["completionIntent"] == "create-and-start" + + def test_issue_capture_applies_and_switches_repository_templates_without_losing_authored_work(): script = f""" const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))}); diff --git a/tests/test_unfiled_captures.py b/tests/test_unfiled_captures.py index fd78590..19e1fd2 100644 --- a/tests/test_unfiled_captures.py +++ b/tests/test_unfiled_captures.py @@ -38,6 +38,28 @@ process.stdout.write(JSON.stringify({{opened,skipped,afterFiled,previous}})); } +def test_draft_filing_session_restores_all_saved_evidence(): + script = f""" +const createDraftFilingSession=require({json.dumps(str(DRAFT_SESSION))}); +const evidence=[{{filename:'one.png'}},{{filename:'two.png'}}]; +const restored=[]; +const elements=new Map(); +const qs=selector=>{{ + if(!elements.has(selector)) elements.set(selector,{{hidden:false,disabled:false,textContent:'',addEventListener:()=>{{}}}}); + return elements.get(selector); +}}; +const session=createDraftFilingSession({{list:()=>[{{id:'planned'}}]}}); +session.attach(qs,{{ + captures:{{resume:async()=>({{repository:'o/r',title:'Planned',body:'',labelIds:[],attachments:evidence}})}}, + issueCapture:{{saveDraft:()=>{{}}}},attachment:{{restore:value=>restored.push(value),clear:()=>restored.push(null)}}, + getLogin:()=>'timmy',setResumedId:()=>{{}},openSheet:async()=>{{}},setFilingMode:()=>{{}}, +}}); +session.start('planned'); +(async()=>{{await session.nextCapture();process.stdout.write(JSON.stringify(restored));}})(); +""" + assert run_node(script) == [[{"filename": "one.png"}, {"filename": "two.png"}]] + + def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced(): script = f""" const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); @@ -145,6 +167,65 @@ function device() {{ assert output["blockers"] == [{"repository": "o/api", "number": 7, "title": "API"}] +def test_unfiled_captures_resume_complete_filing_plan_on_another_device(): + script = f""" +const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); +function device(id) {{ + const values=new Map(); + return createUnfiledCaptures({{ + storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}}, + getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>id,now:()=>42, + }}); +}} +(async()=>{{ + const phone=device('planned-draft'), desktop=device('unused'); + await phone.save({{ + repository:'stackchain/dashboard',title:'Restore my plan',body:'Do not make me plan twice', + labelIds:[7,3,7],milestoneId:12,dueDate:'2026-08-21', + templateName:'Bug report',templateId:'bug.yml',capturedBody:'Original field notes', + assignee:'alexander',assigneeName:'Alexander',unassigned:false, + estimateMinutes:45,completionIntent:'create-and-start', + blockers:[{{repository:'stackchain/api',number:9,title:'API rollout'}}], + }}); + const exported=await phone.exportOwned('timmy'); + await desktop.mergeRemote(exported,'timmy'); + const resumed=await desktop.resume('planned-draft','timmy'); + process.stdout.write(JSON.stringify({{exported:exported[0],resumed}})); +}})().catch(error=>{{console.error(error);process.exit(1)}}); +""" + output = run_node(script) + + assert output["exported"]["filing_plan"] == { + "repository": "stackchain/dashboard", + "label_ids": [7, 3], + "milestone_id": 12, + "due_date": "2026-08-21", + "template_name": "Bug report", + "template_id": "bug.yml", + "captured_body": "Original field notes", + "assignee": "alexander", + "assignee_name": "Alexander", + "estimate_minutes": 45, + "completion_intent": "create-and-start", + } + assert output["resumed"] == { + "repository": "stackchain/dashboard", + "title": "Restore my plan", + "body": "Do not make me plan twice", + "labelIds": [7, 3], + "milestoneId": 12, + "dueDate": "2026-08-21", + "templateName": "Bug report", + "templateId": "bug.yml", + "capturedBody": "Original field notes", + "assignee": "alexander", + "assigneeName": "Alexander", + "estimateMinutes": 45, + "completionIntent": "create-and-start", + "blockers": [{"repository": "stackchain/api", "number": 9, "title": "API rollout"}], + } + + def test_unfiled_captures_reconcile_remote_replaces_stale_content_and_evidence(): script = f""" const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); @@ -510,11 +591,11 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume assert "data-capture-id=\"' + escapeHtml(item.capture_id) + '\"" in html assert "requestAnimationFrame(() =>" in html assert "savedCard?.scrollIntoView({block:'nearest'})" in html - assert "Saved to Drafts. Choose a repository when you’re ready to file it." in html + assert "Saved to Drafts. Choose a repository when you’re ready to file it." in UNFILED.read_text() assert "await dependencies.captures.resume(captureId, dependencies.getLogin())" in DRAFT_SESSION.read_text() assert "await createIssueAttachmentController.serialize()" in html assert "createUnfiledAttachmentStore()" in html - assert "dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment)" in DRAFT_SESSION.read_text() + assert "dependencies.attachment[evidence ? 'restore' : 'clear'](evidence)" in DRAFT_SESSION.read_text() assert "await unfiledCaptures.completeResume(rUC)" in html assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html @@ -526,6 +607,22 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume assert '@media(max-width:320px)' in html +@pytest.mark.anyio +async def test_mobile_planned_draft_can_be_saved_and_resumed_without_replanning(): + html = await dashboard() + feature = UNFILED.read_text() + + assert "const captureDraft = currentIssueCaptureDraft();" in html + assert "return withFilingEstimate(issueTemplatePicker.fields(" in html + assert "draft.estimateMinutes = Number(value) || null" in feature + assert "qs('#create-issue-estimate').value = captureDraft.estimateMinutes || '';" in html + assert "qs('#file-new-issue').hidden = enabled" in feature + assert "qs('.create-issue-capture-actions').hidden = false" in feature + assert "label:item.filingPlan?.repository ? 'Ready to file' : 'Needs filing'" in feature + assert "repository:item.filingPlan?.repository || ''" in feature + assert "Saved planned Draft" in feature + + @pytest.mark.anyio async def test_mobile_drafts_expose_a_safe_sequential_filing_session(): html = await dashboard() @@ -556,6 +653,8 @@ async def test_mobile_capture_capacity_requires_an_explicit_preserving_decision( assert 'id="keep-editing-draft"' in html assert "showDraftCapacityDialog(unfiledCaptures)" in html feature = (Path(__file__).parents[1] / "frontend" / "draft-capacity-dialog.js").read_text() + assert "const draft = currentIssueCaptureDraft();" in feature + assert "Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}" in feature assert "unfiledCaptures.replaceOldest(draft, oldest.id)" in feature assert '.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh;' in html assert '.draft-capacity-actions button { min-height:44px;' in html diff --git a/tests/test_unfiled_draft_store.py b/tests/test_unfiled_draft_store.py index bf2a840..74d4323 100644 --- a/tests/test_unfiled_draft_store.py +++ b/tests/test_unfiled_draft_store.py @@ -51,6 +51,32 @@ def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path): }])]) +def test_unfiled_drafts_validate_and_round_trip_complete_filing_plan(tmp_path): + store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3") + planned = draft(evidence=[]) + planned.pop("evidence") + planned["filing_plan"] = { + "repository": "stackchain/dashboard", + "label_ids": [7, 3], + "milestone_id": 12, + "due_date": "2026-08-21", + "template_name": "Bug report", + "template_id": "bug.yml", + "captured_body": "Original field notes", + "assignee": "alexander", + "assignee_name": "Alexander", + "estimate_minutes": 45, + "completion_intent": "create-and-start", + } + + assert store.replace("timmy", 0, [planned])["drafts"] == [planned] + + invalid = draft(evidence=[]) + invalid["filing_plan"] = {**planned["filing_plan"], "repository": "not-a-repository"} + with pytest.raises(ValueError, match="filing repository is invalid"): + store.replace("timmy", 1, [invalid]) + + @pytest.mark.anyio async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_safe(monkeypatch, tmp_path): monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator") @@ -69,7 +95,21 @@ async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_s monkeypatch.setattr(main, "current_user", user) transport = httpx.ASGITransport(app=main.app) - payload = {"revision": 0, "drafts": [draft(evidence=[])]} + planned = draft(evidence=[]) + planned["filing_plan"] = { + "repository": "stackchain/dashboard", + "label_ids": [7, 3], + "milestone_id": 12, + "due_date": "2026-08-21", + "template_name": "Bug report", + "template_id": "bug.yml", + "captured_body": "Original field notes", + "assignee": "alexander", + "assignee_name": "Alexander", + "estimate_minutes": 45, + "completion_intent": "create-and-start", + } + payload = {"revision": 0, "drafts": [planned]} async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"}) forbidden = await client.put("/api/v1/unfiled-drafts", json=payload) @@ -82,7 +122,7 @@ async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_s assert forbidden.status_code == 403 assert saved.status_code == 200 - expected = draft(evidence=[]) + expected = planned.copy() expected.pop("evidence") assert saved.json() == {"revision": 1, "drafts": [expected]} assert stale.status_code == 409