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