Resume fully planned issue drafts across devices (#860)
All checks were successful
CI / lint (push) Successful in 1m56s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s

Closes #859
This commit is contained in:
timmy 2026-08-15 00:10:22 +00:00
commit ff41eb1d57
10 changed files with 380 additions and 30 deletions

View File

@ -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 || []);

View File

@ -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 youre 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();

View File

@ -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();

View File

@ -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();
};

View File

@ -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 youre 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,
} : {}),

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)

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 {}),
})

View File

@ -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))});

View File

@ -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 youre ready to file it." in html
assert "Saved to Drafts. Choose a repository when youre 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

View File

@ -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