From ed246ce6ddb0b0bf5a3d6c8f9c7161bc167bc1a7 Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 14 Aug 2026 23:05:59 +0000 Subject: [PATCH] feat: file mobile issues without an owner (Closes #855) --- frontend/background-issue-sync.js | 1 + frontend/create-issue-sheet.js | 35 ++++++++---- frontend/index.html | 1 + frontend/issue-filing-review.js | 7 ++- frontend/issue-outbox.js | 88 ++++++++++++++--------------- src/gitea_proxy.py | 9 ++- src/main.py | 12 +++- tests/test_background_issue_sync.py | 14 +++++ tests/test_issue_api.py | 70 +++++++++++++++++++++++ tests/test_issue_filing_review.py | 16 ++++++ tests/test_issue_outbox.py | 35 ++++++++++++ tests/test_my_work.py | 50 +++++++++++++++- 12 files changed, 275 insertions(+), 63 deletions(-) diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index 74b0fcc..f3eefce 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -446,6 +446,7 @@ function createBackgroundIssueSync({ title: item.title, body: item.body, label_ids: item.labelIds, + ...(item.unassigned ? { unassigned: true } : {}), ...(item.assignee ? { assignee: item.assignee } : {}), ...(item.milestoneId ? { milestone_id: item.milestoneId } : {}), ...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}), diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js index b9ab03b..69b853f 100644 --- a/frontend/create-issue-sheet.js +++ b/frontend/create-issue-sheet.js @@ -20,6 +20,7 @@ function normalizeSharedContent(value = {}) { } function createIssueOwnerPicker(issueCapture, documentRef, onChange) { + const NO_OWNER = '__unassigned__'; const select = documentRef.querySelector('#create-issue-assignee'); const status = documentRef.querySelector('#create-issue-assignee-status'); const getRepository = () => documentRef.querySelector('#create-issue-repository').value; @@ -31,6 +32,11 @@ function createIssueOwnerPicker(issueCapture, documentRef, onChange) { me.value = ''; me.textContent = 'Me'; select.appendChild(me); + const noOwner = documentRef.createElement('option'); + noOwner.value = NO_OWNER; + noOwner.textContent = 'No owner'; + select.appendChild(noOwner); + if (selected.unassigned === true) select.value = NO_OWNER; if (selected.assignee) { const option = documentRef.createElement('option'); option.value = selected.assignee; @@ -53,7 +59,8 @@ function createIssueOwnerPicker(issueCapture, documentRef, onChange) { const owners = await loadOwners(repository); if (request !== ownerRequest || getRepository() !== repository) return; const selectedStillEligible = owners.some(owner => owner?.login === selected); - reset(repository, selectedStillEligible ? {assignee:selected, assigneeName:selectedName} : {}); + reset(repository, selected === NO_OWNER ? {unassigned:true} : + (selectedStillEligible ? {assignee:selected, assigneeName:selectedName} : {})); owners.forEach(owner => { if (!owner?.login || owner.login === selected) return; const option = documentRef.createElement('option'); @@ -63,7 +70,8 @@ function createIssueOwnerPicker(issueCapture, documentRef, onChange) { select.appendChild(option); }); select.dataset.repository = repository; - status.textContent = owners.length ? 'Choose yourself or an eligible teammate.' : 'No eligible teammates are available.'; + status.textContent = owners.length ? 'Choose yourself, no owner, or an eligible teammate.' : + 'Choose yourself or no owner; no eligible teammates are available.'; } catch (_error) { if (request !== ownerRequest || getRepository() !== repository) return; status.textContent = 'Teammates could not be loaded. The issue will stay assigned to you.'; @@ -72,17 +80,21 @@ function createIssueOwnerPicker(issueCapture, documentRef, onChange) { function updateActions(hasRepository, hasBlockers, canStart) { const submit = documentRef.querySelector('#submit-new-issue'); const start = documentRef.querySelector('#create-and-start-issue'); - const hasTeammateOwner = Boolean(select.value); + const hasNoOwner = select.value === NO_OWNER; + const hasTeammateOwner = Boolean(select.value) && !hasNoOwner; submit.disabled = !hasRepository; - submit.textContent = hasTeammateOwner ? 'Create & assign' : 'Create & assign to me'; - start.disabled = !hasRepository || hasBlockers || hasTeammateOwner || !canStart; + submit.textContent = hasNoOwner ? 'Create unassigned' : + (hasTeammateOwner ? 'Create & assign' : 'Create & assign to me'); + start.disabled = !hasRepository || hasBlockers || hasTeammateOwner || hasNoOwner || !canStart; start.title = hasBlockers ? 'Blocked work cannot start until its blockers are complete.' : - (hasTeammateOwner ? 'Work assigned to a teammate cannot be added to your Today queue.' : ''); + (hasNoOwner ? 'No owner work cannot be added to your Today queue.' : + (hasTeammateOwner ? 'Work assigned to a teammate cannot be added to your Today queue.' : '')); } function fields() { return { - assignee: select.value, - assigneeName: select.selectedOptions?.[0]?.dataset.name || '', + assignee: select.value === NO_OWNER ? '' : select.value, + assigneeName: select.value === NO_OWNER ? '' : (select.selectedOptions?.[0]?.dataset.name || ''), + unassigned: select.value === NO_OWNER, }; } function draft(labelIds, blockers, trim = false) { @@ -152,6 +164,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp labelIds: safeLabelIds(parsed.labelIds), operationId: String(parsed.operationId || '').slice(0, 128), }; + if (parsed.unassigned === true) draft.unassigned = true; if (typeof parsed.templateName === 'string' && parsed.templateName.trim()) { draft.templateName = parsed.templateName.trim().slice(0, 80); draft.templateId = String(parsed.templateId || '').slice(0, 80); @@ -187,12 +200,13 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp body: String(draft?.body || ''), labelIds: safeLabelIds(draft?.labelIds), }; + if (draft?.unassigned === true) safe.unassigned = true; if (typeof draft?.templateName === 'string' && draft.templateName.trim()) { safe.templateName = draft.templateName.trim().slice(0, 80); safe.templateId = String(draft.templateId || '').slice(0, 80); safe.capturedBody = String(draft.capturedBody || '').slice(0, 10000); } - const assignee = safeAssignee(draft?.assignee); + const assignee = safe.unassigned ? '' : safeAssignee(draft?.assignee); if (assignee) { safe.assignee = assignee; safe.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255); @@ -203,7 +217,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp if (dueDate) safe.dueDate = dueDate; const blockers = safeBlockers(draft?.blockers); if (blockers.length) safe.blockers = blockers; - const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName', + const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName', 'unassigned', 'templateName', 'templateId', 'capturedBody'] .every(key => (previous[key] || '') === (safe[key] || '')) && JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) && @@ -677,6 +691,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp }, body: JSON.stringify({ title: saved.title, body: saved.body, label_ids: saved.labelIds, + ...(saved.unassigned ? {unassigned:true} : {}), ...(saved.assignee ? {assignee: saved.assignee} : {}), ...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}), ...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}), diff --git a/frontend/index.html b/frontend/index.html index 57887ea..d832088 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -806,6 +806,7 @@ diff --git a/frontend/issue-filing-review.js b/frontend/issue-filing-review.js index 4d4f897..2952973 100644 --- a/frontend/issue-filing-review.js +++ b/frontend/issue-filing-review.js @@ -97,16 +97,17 @@ function render(payload) { const draft = payload.draft; options.repository.textContent = draft.repository || 'No repository'; - options.intent.textContent = INTENT_LABELS[payload.intent] || payload.intent; + options.intent.textContent = draft.unassigned === true ? 'Create unassigned' : + (INTENT_LABELS[payload.intent] || payload.intent); if (options.issueType) options.issueType.textContent = draft.templateName || 'Blank issue'; options.title.textContent = draft.title; options.body.textContent = draft.body || 'No note provided.'; const labels = (draft.labels || []).map(label => typeof label === 'string' ? label : label.name); const milestone = draft.milestone?.title || draft.milestoneTitle || 'No milestone'; const dueDate = draft.dueDate || draft.due_date || 'No due date'; - const owner = draft.assignee + const owner = draft.unassigned === true ? 'Owner: No owner' : (draft.assignee ? 'Owner: ' + (draft.assigneeName || draft.assignee) + ' (@' + draft.assignee + ')' - : 'Assigned to you'; + : 'Assigned to you'); const planning = []; if (Number.isInteger(draft.estimateMinutes)) { planning.push('Estimate: ' + draft.estimateMinutes + ' min'); diff --git a/frontend/issue-outbox.js b/frontend/issue-outbox.js index bcab870..4cc3472 100644 --- a/frontend/issue-outbox.js +++ b/frontend/issue-outbox.js @@ -49,6 +49,11 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge return blockers.length ? blockers : undefined; } + function captureEstimate(value) { + const estimate = Number(value); + return Number.isInteger(estimate) && estimate >= 5 && estimate <= 1440 ? estimate : undefined; + } + function read() { try { const record = JSON.parse(storage?.getItem(storageKey) || 'null'); @@ -89,14 +94,12 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge item.assignee = assignee; item.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255); } + if (draft?.unassigned === true) item.unassigned = true; const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128); if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId; - if (draft?.completionIntent === 'create-and-start' && !assignee) { + if (draft?.completionIntent === 'create-and-start' && !assignee && !item.unassigned) { item.completionIntent = 'create-and-start'; - const estimateMinutes = Number(draft?.estimateMinutes); - if (Number.isInteger(estimateMinutes) && estimateMinutes >= 5 && estimateMinutes <= 1440) { - item.estimateMinutes = estimateMinutes; - } + item.estimateMinutes = captureEstimate(draft?.estimateMinutes); } const attachment = captureAttachment(draft?.attachment); if (attachment) item.attachment = attachment; @@ -200,62 +203,58 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge let updated = null; const items = read().map(item => { if (item.id !== id) return item; - const nextRepository = String(draft?.repository || ''); - const nextTitle = String(draft?.title || ''); - const nextBody = String(draft?.body || ''); - const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : []; - const nextAssignee = /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || '')) + const repository = String(draft?.repository || ''); + const title = String(draft?.title || ''); + const body = String(draft?.body || ''); + const labelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : []; + const unassigned = draft?.unassigned === true; + const assignee = !unassigned && /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || '')) ? String(draft.assignee) : undefined; - const nextAssigneeName = nextAssignee - ? String(draft?.assigneeName || nextAssignee).replace(/\s+/g, ' ').trim().slice(0, 255) + const assigneeName = assignee + ? String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255) : undefined; - const nextMilestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0 + const milestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0 ? Number(draft.milestoneId) : undefined; - const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || '')) + const dueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || '')) ? String(draft.dueDate) : undefined; - const nextAttachment = captureAttachment(draft?.attachment); - const nextAttachments = captureAttachments(draft?.attachments); - const nextBlockers = captureBlockers(draft?.blockers); - const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null) || - JSON.stringify(item.attachments || null) !== JSON.stringify(nextAttachments || null); - const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody - || JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds) - || item.assignee !== nextAssignee || item.assigneeName !== nextAssigneeName - || item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate - || item.estimateMinutes !== (() => { - const value = Number(draft?.estimateMinutes); - return Number.isInteger(value) && value >= 5 && value <= 1440 ? value : undefined; - })() - || attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(nextBlockers || null); + const attachment = captureAttachment(draft?.attachment); + const attachments = captureAttachments(draft?.attachments); + const blockers = captureBlockers(draft?.blockers); + const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(attachment || null) || + JSON.stringify(item.attachments || null) !== JSON.stringify(attachments || null); + const changed = item.repository !== repository || item.title !== title || item.body !== body + || JSON.stringify(item.labelIds || []) !== JSON.stringify(labelIds) + || Boolean(item.unassigned) !== unassigned + || item.assignee !== assignee || item.assigneeName !== assigneeName + || item.milestoneId !== milestoneId || item.dueDate !== dueDate + || item.estimateMinutes !== captureEstimate(draft?.estimateMinutes) + || attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(blockers || null); updated = { ...item, - repository: nextRepository, title: nextTitle, - body: nextBody, labelIds: nextLabelIds, - assignee: nextAssignee, assigneeName: nextAssigneeName, - milestoneId: nextMilestoneId, dueDate: nextDueDate, - attachment: nextAttachment, attachments:nextAttachments, blockers:nextBlockers, + repository, title, body, labelIds, + unassigned: unassigned || undefined, + assignee, assigneeName, milestoneId, dueDate, + attachment, attachments, blockers, operationId: changed ? String(operationId()).slice(0, 128) : item.operationId, status: 'queued', }; - if (draft?.completionIntent === 'create-and-start' && !nextAssignee) { + if (draft?.completionIntent === 'create-and-start' && !assignee && !unassigned) { updated.completionIntent = 'create-and-start'; - const estimateMinutes = Number(draft?.estimateMinutes); - if (Number.isInteger(estimateMinutes) && estimateMinutes >= 5 && estimateMinutes <= 1440) { - updated.estimateMinutes = estimateMinutes; - } else delete updated.estimateMinutes; + updated.estimateMinutes = captureEstimate(draft?.estimateMinutes); } else { delete updated.completionIntent; delete updated.estimateMinutes; } - if (nextAssignee === undefined) { + if (assignee === undefined) { delete updated.assignee; delete updated.assigneeName; } - if (nextMilestoneId === undefined) delete updated.milestoneId; - if (nextDueDate === undefined) delete updated.dueDate; - if (nextAttachment === undefined) delete updated.attachment; - if (nextAttachments === undefined) delete updated.attachments; - if (nextBlockers === undefined) delete updated.blockers; + if (!unassigned) delete updated.unassigned; + if (milestoneId === undefined) delete updated.milestoneId; + if (dueDate === undefined) delete updated.dueDate; + if (attachment === undefined) delete updated.attachment; + if (attachments === undefined) delete updated.attachments; + if (blockers === undefined) delete updated.blockers; if (attachmentChanged) { delete updated.attachmentMarkdown; delete updated.attachmentMarkdowns; @@ -340,6 +339,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge }, body: JSON.stringify({ title: item.title, body: item.body, label_ids: item.labelIds, + ...(item.unassigned ? { unassigned: true } : {}), ...(item.assignee ? { assignee: item.assignee } : {}), ...(item.milestoneId ? { milestone_id: item.milestoneId } : {}), ...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}), diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 822e416..7c2814e 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -1469,12 +1469,14 @@ async def create_issue( repository: str, title: str, body: str, - assignee: str, + assignee: str | None, label_ids: list[int] | None = None, milestone_id: int | None = None, due_date: str | None = None, ) -> dict: - payload: dict = {"title": title, "body": body, "assignee": assignee} + payload: dict = {"title": title, "body": body} + if assignee is not None: + payload["assignee"] = assignee if label_ids: payload["labels"] = label_ids if milestone_id is not None: @@ -1497,7 +1499,8 @@ async def create_issue( for item in assignees if isinstance(item, dict) and isinstance(item.get("login"), str) ] - if confirmed_assignees != [assignee]: + expected_assignees = [assignee] if assignee is not None else [] + if confirmed_assignees != expected_assignees: raise ValueError( "Gitea did not confirm self-assignment or exact issue assignment" ) diff --git a/src/main.py b/src/main.py index ab6fde9..0a4b627 100644 --- a/src/main.py +++ b/src/main.py @@ -742,6 +742,7 @@ def _validate_binary_attachment(filename: str, content_type: str, content: bytes class IssueCreation(BaseModel): title: str = Field(min_length=1, max_length=255) body: str = Field(default="", max_length=10_000) + unassigned: bool = False assignee: str | None = Field( default=None, pattern=r"^[A-Za-z0-9_.-]+$", max_length=255 ) @@ -766,6 +767,12 @@ class IssueCreation(BaseModel): def strip_issue_body(cls, value: str) -> str: return value.strip() + @model_validator(mode="after") + def require_one_owner_intent(self): + if self.unassigned and self.assignee is not None: + raise ValueError("assignee and unassigned cannot be requested together") + return self + @field_validator("due_date") @classmethod def validate_due_date(cls, value: str | None) -> str | None: @@ -4717,8 +4724,8 @@ async def create_assigned_issue( login = user.get("login") if isinstance(user, dict) else None if not login or accessible is None: raise HTTPException(status_code=404, detail="Repository not found") - assignee = login - if creation.assignee and creation.assignee != login: + assignee = None if creation.unassigned else login + if not creation.unassigned and creation.assignee and creation.assignee != login: eligible = { item["login"] for item in await gitea_proxy.issue_handoff_candidates(repository) @@ -4772,6 +4779,7 @@ async def create_assigned_issue( creation.title, creation.body, creation.assignee, + creation.unassigned, tuple(creation.label_ids), creation.milestone_id, creation.due_date, diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py index 2878089..a4eae9b 100644 --- a/tests/test_background_issue_sync.py +++ b/tests/test_background_issue_sync.py @@ -64,6 +64,20 @@ const fetchJson = async (url, options = {{}}) => {{ } +def test_closed_app_sync_preserves_explicit_no_owner(): + script = f""" +const createBackgroundIssueSync=require({json.dumps(str(SYNC))}); +let item={{id:'unowned-1',operationId:'unowned-1',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Backlog capture',body:'',labelIds:[],unassigned:true}}; +const calls=[];const store={{claimNext:async()=>item,complete:async()=>{{item=null;}},release:async()=>{{}},fail:async()=>{{}}}}; +const fetchJson=async(url,options={{}})=>{{calls.push({{url,body:options.body||''}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{repository:'o/r',number:19,assignees:[]}};}}; +(async()=>{{await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify(calls));}})(); +""" + calls = run_node(script) + assert json.loads(calls[1]["body"]) == { + "title": "Backlog capture", "body": "", "label_ids": [], "unassigned": True + } + + def test_closed_app_sync_delivers_desired_blocker_state(): script = f""" const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index 9a39e3b..7a87dd8 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -769,6 +769,49 @@ async def test_create_issue_endpoint_validates_and_assigns_selected_initial_owne assert calls == [("stackchain/api", "Delegate at capture", "alex")] +@pytest.mark.anyio +async def test_create_issue_endpoint_preserves_explicit_no_owner(monkeypatch): + calls = [] + + async def user(): + return {"login": "timmy"} + + async def access(repository): + return {"full_name": repository} + + async def create(repository, title, body, assignee, label_ids): + calls.append((repository, title, assignee)) + return {"number": 19, "repository": repository, "assignees": []} + + monkeypatch.setattr(main.gitea_proxy, "current_user", user) + monkeypatch.setattr(main.gitea_proxy, "repository_access", access) + monkeypatch.setattr(main.gitea_proxy, "create_issue", create) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/v1/repos/stackchain/api/issues", + json={"title": "Backlog capture", "unassigned": True}, + ) + + assert response.status_code == 201 + assert response.json()["assignees"] == [] + assert calls == [("stackchain/api", "Backlog capture", None)] + + +@pytest.mark.anyio +async def test_create_issue_endpoint_rejects_named_and_no_owner_together(): + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/v1/repos/stackchain/api/issues", + json={"title": "Contradictory owner", "unassigned": True, "assignee": "alex"}, + ) + + assert response.status_code == 422 + assert "assignee" in response.text + assert "unassigned" in response.text + + @pytest.mark.anyio async def test_initial_owner_candidates_require_repository_access_and_are_bounded(monkeypatch): async def access(repository): @@ -1290,6 +1333,33 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat assert result["labels"] == ["P0"] +@pytest.mark.anyio +async def test_gitea_create_issue_omits_assignment_and_confirms_no_owner(): + requests = [] + + async def handler(request): + requests.append(request) + return httpx.Response(201, json={ + "id": 83, "number": 19, "title": "Backlog capture", "state": "open", + "updated_at": "2026-08-07T03:00:00Z", + "html_url": "https://forge.example/stackchain/api/issues/19", + "assignees": [], "labels": [], + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.create_issue( + "stackchain/api", "Backlog capture", "Context", None, [] + ) + finally: + await gitea_proxy.stop_client() + + assert json.loads(requests[0].content) == { + "title": "Backlog capture", "body": "Context" + } + assert result["assignees"] == [] + + @pytest.mark.anyio async def test_gitea_create_issue_requires_exact_selected_initial_owner(): async def handler(_request): diff --git a/tests/test_issue_filing_review.py b/tests/test_issue_filing_review.py index 778fab6..5640f9a 100644 --- a/tests/test_issue_filing_review.py +++ b/tests/test_issue_filing_review.py @@ -99,6 +99,22 @@ process.stdout.write(JSON.stringify({{metadata:metadata.textContent}})); assert run_node(script)["metadata"].endswith("Owner: Alex (@alex)") +def test_review_names_explicit_no_owner(): + script = f""" +const createReview=require({json.dumps(str(MODULE))}); +function target(){{return{{hidden:true,disabled:false,textContent:'',addEventListener:()=>{{}},replaceChildren:()=>{{}},focus:()=>{{}}}};}} +const metadata=target(),intent=target(); +const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(), + repository:target(),intent,title:target(),body:target(),metadata, + document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}}); +review.open({{draft:{{repository:'o/r',title:'Backlog capture',unassigned:true}},intent:'create-and-assign'}},target()); +process.stdout.write(JSON.stringify({{metadata:metadata.textContent,intent:intent.textContent}})); +""" + output = run_node(script) + assert output["metadata"].endswith("Owner: No owner") + assert output["intent"] == "Create unassigned" + + def test_review_names_the_repository_issue_type(): script = f""" const createReview=require({json.dumps(str(MODULE))}); diff --git a/tests/test_issue_outbox.py b/tests/test_issue_outbox.py index d660644..3db2ad5 100644 --- a/tests/test_issue_outbox.py +++ b/tests/test_issue_outbox.py @@ -89,6 +89,24 @@ const queued=outbox.enqueue({{repository:'o/r',title:'Delegate',assignee:'alex', }] +def test_issue_outbox_preserves_no_owner_and_delivers_unassigned_intent(): + script = f""" +const createIssueOutbox=require({json.dumps(str(OUTBOX))}); +const values=new Map();const calls=[]; +const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'unowned-outbox',fetchJson:async(url,options)=>{{calls.push(JSON.parse(options.body));return{{number:19,assignees:[]}};}}}}); +const queued=outbox.enqueue({{repository:'o/r',title:'Backlog capture',unassigned:true,completionIntent:'create-and-start'}}); +(async()=>{{await outbox.flush('timmy');process.stdout.write(JSON.stringify({{queued,calls}}));}})(); +""" + output = run_node(script) + + assert output["queued"]["unassigned"] is True + assert "completionIntent" not in output["queued"] + assert output["calls"] == [{ + "title": "Backlog capture", "body": "", "label_ids": [], "unassigned": True + }] + + def test_editing_queued_issue_can_return_initial_owner_to_self(): script = f""" const createIssueOutbox=require({json.dumps(str(OUTBOX))}); @@ -105,6 +123,23 @@ process.stdout.write(JSON.stringify(updated)); assert output["operationId"] != "1" +def test_editing_queued_no_owner_to_self_rotates_delivery_and_allows_start(): + script = f""" +const createIssueOutbox=require({json.dumps(str(OUTBOX))}); +const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +let id=0;const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>String(++id)}}); +const queued=outbox.enqueue({{repository:'o/r',title:'Backlog',unassigned:true}}); +const updated=outbox.update(queued.id,{{...queued,unassigned:false,completionIntent:'create-and-start',estimateMinutes:30}}); +process.stdout.write(JSON.stringify({{queued,updated}})); +""" + output = run_node(script) + assert output["queued"]["unassigned"] is True + assert "unassigned" not in output["updated"] + assert output["updated"]["completionIntent"] == "create-and-start" + assert output["updated"]["estimateMinutes"] == 30 + assert output["updated"]["operationId"] != output["queued"]["operationId"] + + def test_create_and_start_estimate_survives_outbox_delivery_and_completion(): script = f""" const createIssueOutbox=require({json.dumps(str(OUTBOX))}); diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 54bb704..5785744 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -4072,6 +4072,25 @@ capture.saveDraft({{repository:'o/r',title:'Delegate',body:'Context',labelIds:[] assert json.loads(output["calls"][-1]["body"])["assignee"] == "alex" +def test_issue_capture_persists_and_sends_explicit_no_owner(): + script = f""" +const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))}); +const values=new Map();const calls=[]; +const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +const capture=createIssueCapture({{storage,createOperationId:()=>'unowned-op',fetchJson:async(url,options)=>{{calls.push(JSON.parse(options.body));return{{number:19,assignees:[]}};}}}}); +capture.saveDraft({{repository:'o/r',title:'Backlog capture',body:'Context',labelIds:[],unassigned:true}}); +(async()=>{{const draft=capture.loadDraft();await capture.submit(draft);process.stdout.write(JSON.stringify({{draft,calls}}));}})(); +""" + output = json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + assert output["draft"]["unassigned"] is True + assert output["calls"] == [{ + "title": "Backlog capture", "body": "Context", "label_ids": [], "unassigned": True + }] + + def test_issue_capture_applies_and_switches_repository_templates_without_losing_authored_work(): script = f""" const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))}); @@ -4122,7 +4141,35 @@ const picker=createIssueCapture.createOwnerPicker(capture,documentRef,()=>{{}}); output = json.loads(subprocess.run( ["node", "-e", script], check=True, capture_output=True, text=True ).stdout) - assert output == ["", "new"] + assert output == ["", "__unassigned__", "new"] + + +def test_initial_owner_picker_exposes_no_owner_and_disables_start(): + script = f""" +const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))}); +function select(){{return{{value:'',dataset:{{}},children:[],selectedOptions:[], + replaceChildren(){{this.children=[];this.value='';this.selectedOptions=[];}}, + appendChild(option){{this.children.push(option);}},addEventListener:()=>{{}}}};}} +const owner=select(),status={{textContent:''}},repo={{value:'o/r'}}; +const submit={{disabled:false,textContent:''}},start={{disabled:false,title:''}}; +const documentRef={{querySelector:id=>({{ + '#create-issue-assignee':owner,'#create-issue-assignee-status':status, + '#create-issue-repository':repo,'#submit-new-issue':submit,'#create-and-start-issue':start, +}}[id]),createElement:()=>({{value:'',textContent:'',dataset:{{}}}})}}; +const picker=createIssueCapture.createOwnerPicker({{loadOwners:async()=>[]}},documentRef,()=>{{}}); +picker.reset('o/r',{{unassigned:true}});owner.value='__unassigned__'; +picker.updateActions(true,false,true); +process.stdout.write(JSON.stringify({{options:owner.children.map(option=>[option.value,option.textContent]),fields:picker.fields(),submit,start}})); +""" + output = json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + assert output["options"] == [["", "Me"], ["__unassigned__", "No owner"]] + assert output["fields"] == {"assignee": "", "assigneeName": "", "unassigned": True} + assert output["submit"]["textContent"] == "Create unassigned" + assert output["start"]["disabled"] is True + assert "No owner" in output["start"]["title"] @pytest.mark.anyio @@ -4146,6 +4193,7 @@ async def test_mobile_issue_capture_lazily_selects_an_initial_owner(): html = await dashboard() assert 'id="create-issue-assignee"' in html assert '' in html + assert '' in html assert 'id="create-issue-assignee-status" class="small" aria-live="polite"' in html assert "createIssueCapture.createOwnerPicker(issueCapture, document," in html assert "() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });" in html