feat: resume fully planned drafts across devices (Closes #859)
This commit is contained in:
parent
7cab4e8749
commit
f23839f2b9
|
|
@ -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 || []);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
} : {}),
|
||||
|
|
|
|||
35
src/main.py
35
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user