feat: file mobile issues without an owner (Closes #855)
All checks were successful
CI / lint (pull_request) Successful in 1m50s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-14 23:05:59 +00:00
parent fc35b17d24
commit ed246ce6dd
12 changed files with 275 additions and 63 deletions

View File

@ -446,6 +446,7 @@ function createBackgroundIssueSync({
title: item.title, title: item.title,
body: item.body, body: item.body,
label_ids: item.labelIds, label_ids: item.labelIds,
...(item.unassigned ? { unassigned: true } : {}),
...(item.assignee ? { assignee: item.assignee } : {}), ...(item.assignee ? { assignee: item.assignee } : {}),
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}), ...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}), ...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),

View File

@ -20,6 +20,7 @@ function normalizeSharedContent(value = {}) {
} }
function createIssueOwnerPicker(issueCapture, documentRef, onChange) { function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
const NO_OWNER = '__unassigned__';
const select = documentRef.querySelector('#create-issue-assignee'); const select = documentRef.querySelector('#create-issue-assignee');
const status = documentRef.querySelector('#create-issue-assignee-status'); const status = documentRef.querySelector('#create-issue-assignee-status');
const getRepository = () => documentRef.querySelector('#create-issue-repository').value; const getRepository = () => documentRef.querySelector('#create-issue-repository').value;
@ -31,6 +32,11 @@ function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
me.value = ''; me.value = '';
me.textContent = 'Me'; me.textContent = 'Me';
select.appendChild(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) { if (selected.assignee) {
const option = documentRef.createElement('option'); const option = documentRef.createElement('option');
option.value = selected.assignee; option.value = selected.assignee;
@ -53,7 +59,8 @@ function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
const owners = await loadOwners(repository); const owners = await loadOwners(repository);
if (request !== ownerRequest || getRepository() !== repository) return; if (request !== ownerRequest || getRepository() !== repository) return;
const selectedStillEligible = owners.some(owner => owner?.login === selected); 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 => { owners.forEach(owner => {
if (!owner?.login || owner.login === selected) return; if (!owner?.login || owner.login === selected) return;
const option = documentRef.createElement('option'); const option = documentRef.createElement('option');
@ -63,7 +70,8 @@ function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
select.appendChild(option); select.appendChild(option);
}); });
select.dataset.repository = repository; 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) { } catch (_error) {
if (request !== ownerRequest || getRepository() !== repository) return; if (request !== ownerRequest || getRepository() !== repository) return;
status.textContent = 'Teammates could not be loaded. The issue will stay assigned to you.'; 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) { function updateActions(hasRepository, hasBlockers, canStart) {
const submit = documentRef.querySelector('#submit-new-issue'); const submit = documentRef.querySelector('#submit-new-issue');
const start = documentRef.querySelector('#create-and-start-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.disabled = !hasRepository;
submit.textContent = hasTeammateOwner ? 'Create & assign' : 'Create & assign to me'; submit.textContent = hasNoOwner ? 'Create unassigned' :
start.disabled = !hasRepository || hasBlockers || hasTeammateOwner || !canStart; (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.' : 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() { function fields() {
return { return {
assignee: select.value, assignee: select.value === NO_OWNER ? '' : select.value,
assigneeName: select.selectedOptions?.[0]?.dataset.name || '', assigneeName: select.value === NO_OWNER ? '' : (select.selectedOptions?.[0]?.dataset.name || ''),
unassigned: select.value === NO_OWNER,
}; };
} }
function draft(labelIds, blockers, trim = false) { function draft(labelIds, blockers, trim = false) {
@ -152,6 +164,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
labelIds: safeLabelIds(parsed.labelIds), labelIds: safeLabelIds(parsed.labelIds),
operationId: String(parsed.operationId || '').slice(0, 128), operationId: String(parsed.operationId || '').slice(0, 128),
}; };
if (parsed.unassigned === true) draft.unassigned = true;
if (typeof parsed.templateName === 'string' && parsed.templateName.trim()) { if (typeof parsed.templateName === 'string' && parsed.templateName.trim()) {
draft.templateName = parsed.templateName.trim().slice(0, 80); draft.templateName = parsed.templateName.trim().slice(0, 80);
draft.templateId = String(parsed.templateId || '').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 || ''), body: String(draft?.body || ''),
labelIds: safeLabelIds(draft?.labelIds), labelIds: safeLabelIds(draft?.labelIds),
}; };
if (draft?.unassigned === true) safe.unassigned = true;
if (typeof draft?.templateName === 'string' && draft.templateName.trim()) { if (typeof draft?.templateName === 'string' && draft.templateName.trim()) {
safe.templateName = draft.templateName.trim().slice(0, 80); safe.templateName = draft.templateName.trim().slice(0, 80);
safe.templateId = String(draft.templateId || '').slice(0, 80); safe.templateId = String(draft.templateId || '').slice(0, 80);
safe.capturedBody = String(draft.capturedBody || '').slice(0, 10000); safe.capturedBody = String(draft.capturedBody || '').slice(0, 10000);
} }
const assignee = safeAssignee(draft?.assignee); const assignee = safe.unassigned ? '' : safeAssignee(draft?.assignee);
if (assignee) { if (assignee) {
safe.assignee = assignee; safe.assignee = assignee;
safe.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255); 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; if (dueDate) safe.dueDate = dueDate;
const blockers = safeBlockers(draft?.blockers); const blockers = safeBlockers(draft?.blockers);
if (blockers.length) safe.blockers = 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'] 'templateName', 'templateId', 'capturedBody']
.every(key => (previous[key] || '') === (safe[key] || '')) && .every(key => (previous[key] || '') === (safe[key] || '')) &&
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) && JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) &&
@ -677,6 +691,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}, },
body: JSON.stringify({ body: JSON.stringify({
title: saved.title, body: saved.body, label_ids: saved.labelIds, title: saved.title, body: saved.body, label_ids: saved.labelIds,
...(saved.unassigned ? {unassigned:true} : {}),
...(saved.assignee ? {assignee: saved.assignee} : {}), ...(saved.assignee ? {assignee: saved.assignee} : {}),
...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}), ...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}),
...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}), ...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}),

View File

@ -806,6 +806,7 @@
<label class="create-issue-owner" for="create-issue-assignee">Owner <label class="create-issue-owner" for="create-issue-assignee">Owner
<select id="create-issue-assignee" aria-describedby="create-issue-assignee-status"> <select id="create-issue-assignee" aria-describedby="create-issue-assignee-status">
<option value="">Me</option> <option value="">Me</option>
<option value="__unassigned__">No owner</option>
</select> </select>
<span id="create-issue-assignee-status" class="small" aria-live="polite">Open the owner picker to load eligible teammates.</span> <span id="create-issue-assignee-status" class="small" aria-live="polite">Open the owner picker to load eligible teammates.</span>
</label> </label>

View File

@ -97,16 +97,17 @@
function render(payload) { function render(payload) {
const draft = payload.draft; const draft = payload.draft;
options.repository.textContent = draft.repository || 'No repository'; 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'; if (options.issueType) options.issueType.textContent = draft.templateName || 'Blank issue';
options.title.textContent = draft.title; options.title.textContent = draft.title;
options.body.textContent = draft.body || 'No note provided.'; options.body.textContent = draft.body || 'No note provided.';
const labels = (draft.labels || []).map(label => typeof label === 'string' ? label : label.name); const labels = (draft.labels || []).map(label => typeof label === 'string' ? label : label.name);
const milestone = draft.milestone?.title || draft.milestoneTitle || 'No milestone'; const milestone = draft.milestone?.title || draft.milestoneTitle || 'No milestone';
const dueDate = draft.dueDate || draft.due_date || 'No due date'; 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 + ')' ? 'Owner: ' + (draft.assigneeName || draft.assignee) + ' (@' + draft.assignee + ')'
: 'Assigned to you'; : 'Assigned to you');
const planning = []; const planning = [];
if (Number.isInteger(draft.estimateMinutes)) { if (Number.isInteger(draft.estimateMinutes)) {
planning.push('Estimate: ' + draft.estimateMinutes + ' min'); planning.push('Estimate: ' + draft.estimateMinutes + ' min');

View File

@ -49,6 +49,11 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return blockers.length ? blockers : undefined; return blockers.length ? blockers : undefined;
} }
function captureEstimate(value) {
const estimate = Number(value);
return Number.isInteger(estimate) && estimate >= 5 && estimate <= 1440 ? estimate : undefined;
}
function read() { function read() {
try { try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null'); const record = JSON.parse(storage?.getItem(storageKey) || 'null');
@ -89,14 +94,12 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
item.assignee = assignee; item.assignee = assignee;
item.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255); 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); const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId; 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'; item.completionIntent = 'create-and-start';
const estimateMinutes = Number(draft?.estimateMinutes); item.estimateMinutes = captureEstimate(draft?.estimateMinutes);
if (Number.isInteger(estimateMinutes) && estimateMinutes >= 5 && estimateMinutes <= 1440) {
item.estimateMinutes = estimateMinutes;
}
} }
const attachment = captureAttachment(draft?.attachment); const attachment = captureAttachment(draft?.attachment);
if (attachment) item.attachment = attachment; if (attachment) item.attachment = attachment;
@ -200,62 +203,58 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
let updated = null; let updated = null;
const items = read().map(item => { const items = read().map(item => {
if (item.id !== id) return item; if (item.id !== id) return item;
const nextRepository = String(draft?.repository || ''); const repository = String(draft?.repository || '');
const nextTitle = String(draft?.title || ''); const title = String(draft?.title || '');
const nextBody = String(draft?.body || ''); const body = String(draft?.body || '');
const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : []; const labelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
const nextAssignee = /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || '')) const unassigned = draft?.unassigned === true;
const assignee = !unassigned && /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || ''))
? String(draft.assignee) : undefined; ? String(draft.assignee) : undefined;
const nextAssigneeName = nextAssignee const assigneeName = assignee
? String(draft?.assigneeName || nextAssignee).replace(/\s+/g, ' ').trim().slice(0, 255) ? String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255)
: undefined; : 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; ? 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; ? String(draft.dueDate) : undefined;
const nextAttachment = captureAttachment(draft?.attachment); const attachment = captureAttachment(draft?.attachment);
const nextAttachments = captureAttachments(draft?.attachments); const attachments = captureAttachments(draft?.attachments);
const nextBlockers = captureBlockers(draft?.blockers); const blockers = captureBlockers(draft?.blockers);
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null) || const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(attachment || null) ||
JSON.stringify(item.attachments || null) !== JSON.stringify(nextAttachments || null); JSON.stringify(item.attachments || null) !== JSON.stringify(attachments || null);
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody const changed = item.repository !== repository || item.title !== title || item.body !== body
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds) || JSON.stringify(item.labelIds || []) !== JSON.stringify(labelIds)
|| item.assignee !== nextAssignee || item.assigneeName !== nextAssigneeName || Boolean(item.unassigned) !== unassigned
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate || item.assignee !== assignee || item.assigneeName !== assigneeName
|| item.estimateMinutes !== (() => { || item.milestoneId !== milestoneId || item.dueDate !== dueDate
const value = Number(draft?.estimateMinutes); || item.estimateMinutes !== captureEstimate(draft?.estimateMinutes)
return Number.isInteger(value) && value >= 5 && value <= 1440 ? value : undefined; || attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(blockers || null);
})()
|| attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(nextBlockers || null);
updated = { updated = {
...item, ...item,
repository: nextRepository, title: nextTitle, repository, title, body, labelIds,
body: nextBody, labelIds: nextLabelIds, unassigned: unassigned || undefined,
assignee: nextAssignee, assigneeName: nextAssigneeName, assignee, assigneeName, milestoneId, dueDate,
milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment, attachments, blockers,
attachment: nextAttachment, attachments:nextAttachments, blockers:nextBlockers,
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId, operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
status: 'queued', status: 'queued',
}; };
if (draft?.completionIntent === 'create-and-start' && !nextAssignee) { if (draft?.completionIntent === 'create-and-start' && !assignee && !unassigned) {
updated.completionIntent = 'create-and-start'; updated.completionIntent = 'create-and-start';
const estimateMinutes = Number(draft?.estimateMinutes); updated.estimateMinutes = captureEstimate(draft?.estimateMinutes);
if (Number.isInteger(estimateMinutes) && estimateMinutes >= 5 && estimateMinutes <= 1440) {
updated.estimateMinutes = estimateMinutes;
} else delete updated.estimateMinutes;
} else { } else {
delete updated.completionIntent; delete updated.completionIntent;
delete updated.estimateMinutes; delete updated.estimateMinutes;
} }
if (nextAssignee === undefined) { if (assignee === undefined) {
delete updated.assignee; delete updated.assignee;
delete updated.assigneeName; delete updated.assigneeName;
} }
if (nextMilestoneId === undefined) delete updated.milestoneId; if (!unassigned) delete updated.unassigned;
if (nextDueDate === undefined) delete updated.dueDate; if (milestoneId === undefined) delete updated.milestoneId;
if (nextAttachment === undefined) delete updated.attachment; if (dueDate === undefined) delete updated.dueDate;
if (nextAttachments === undefined) delete updated.attachments; if (attachment === undefined) delete updated.attachment;
if (nextBlockers === undefined) delete updated.blockers; if (attachments === undefined) delete updated.attachments;
if (blockers === undefined) delete updated.blockers;
if (attachmentChanged) { if (attachmentChanged) {
delete updated.attachmentMarkdown; delete updated.attachmentMarkdown;
delete updated.attachmentMarkdowns; delete updated.attachmentMarkdowns;
@ -340,6 +339,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}, },
body: JSON.stringify({ body: JSON.stringify({
title: item.title, body: item.body, label_ids: item.labelIds, title: item.title, body: item.body, label_ids: item.labelIds,
...(item.unassigned ? { unassigned: true } : {}),
...(item.assignee ? { assignee: item.assignee } : {}), ...(item.assignee ? { assignee: item.assignee } : {}),
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}), ...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}), ...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),

View File

@ -1469,12 +1469,14 @@ async def create_issue(
repository: str, repository: str,
title: str, title: str,
body: str, body: str,
assignee: str, assignee: str | None,
label_ids: list[int] | None = None, label_ids: list[int] | None = None,
milestone_id: int | None = None, milestone_id: int | None = None,
due_date: str | None = None, due_date: str | None = None,
) -> dict: ) -> 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: if label_ids:
payload["labels"] = label_ids payload["labels"] = label_ids
if milestone_id is not None: if milestone_id is not None:
@ -1497,7 +1499,8 @@ async def create_issue(
for item in assignees for item in assignees
if isinstance(item, dict) and isinstance(item.get("login"), str) 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( raise ValueError(
"Gitea did not confirm self-assignment or exact issue assignment" "Gitea did not confirm self-assignment or exact issue assignment"
) )

View File

@ -742,6 +742,7 @@ def _validate_binary_attachment(filename: str, content_type: str, content: bytes
class IssueCreation(BaseModel): class IssueCreation(BaseModel):
title: str = Field(min_length=1, max_length=255) title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000) body: str = Field(default="", max_length=10_000)
unassigned: bool = False
assignee: str | None = Field( assignee: str | None = Field(
default=None, pattern=r"^[A-Za-z0-9_.-]+$", max_length=255 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: def strip_issue_body(cls, value: str) -> str:
return value.strip() 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") @field_validator("due_date")
@classmethod @classmethod
def validate_due_date(cls, value: str | None) -> str | None: 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 login = user.get("login") if isinstance(user, dict) else None
if not login or accessible is None: if not login or accessible is None:
raise HTTPException(status_code=404, detail="Repository not found") raise HTTPException(status_code=404, detail="Repository not found")
assignee = login assignee = None if creation.unassigned else login
if creation.assignee and creation.assignee != login: if not creation.unassigned and creation.assignee and creation.assignee != login:
eligible = { eligible = {
item["login"] item["login"]
for item in await gitea_proxy.issue_handoff_candidates(repository) for item in await gitea_proxy.issue_handoff_candidates(repository)
@ -4772,6 +4779,7 @@ async def create_assigned_issue(
creation.title, creation.title,
creation.body, creation.body,
creation.assignee, creation.assignee,
creation.unassigned,
tuple(creation.label_ids), tuple(creation.label_ids),
creation.milestone_id, creation.milestone_id,
creation.due_date, creation.due_date,

View File

@ -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(): def test_closed_app_sync_delivers_desired_blocker_state():
script = f""" script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); const createBackgroundIssueSync = require({json.dumps(str(SYNC))});

View File

@ -769,6 +769,49 @@ async def test_create_issue_endpoint_validates_and_assigns_selected_initial_owne
assert calls == [("stackchain/api", "Delegate at capture", "alex")] 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 @pytest.mark.anyio
async def test_initial_owner_candidates_require_repository_access_and_are_bounded(monkeypatch): async def test_initial_owner_candidates_require_repository_access_and_are_bounded(monkeypatch):
async def access(repository): async def access(repository):
@ -1290,6 +1333,33 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
assert result["labels"] == ["P0"] 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 @pytest.mark.anyio
async def test_gitea_create_issue_requires_exact_selected_initial_owner(): async def test_gitea_create_issue_requires_exact_selected_initial_owner():
async def handler(_request): async def handler(_request):

View File

@ -99,6 +99,22 @@ process.stdout.write(JSON.stringify({{metadata:metadata.textContent}}));
assert run_node(script)["metadata"].endswith("Owner: Alex (@alex)") 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(): def test_review_names_the_repository_issue_type():
script = f""" script = f"""
const createReview=require({json.dumps(str(MODULE))}); const createReview=require({json.dumps(str(MODULE))});

View File

@ -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(): def test_editing_queued_issue_can_return_initial_owner_to_self():
script = f""" script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))}); const createIssueOutbox=require({json.dumps(str(OUTBOX))});
@ -105,6 +123,23 @@ process.stdout.write(JSON.stringify(updated));
assert output["operationId"] != "1" 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(): def test_create_and_start_estimate_survives_outbox_delivery_and_completion():
script = f""" script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))}); const createIssueOutbox=require({json.dumps(str(OUTBOX))});

View File

@ -4072,6 +4072,25 @@ capture.saveDraft({{repository:'o/r',title:'Delegate',body:'Context',labelIds:[]
assert json.loads(output["calls"][-1]["body"])["assignee"] == "alex" 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(): def test_issue_capture_applies_and_switches_repository_templates_without_losing_authored_work():
script = f""" script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))}); const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
@ -4122,7 +4141,35 @@ const picker=createIssueCapture.createOwnerPicker(capture,documentRef,()=>{{}});
output = json.loads(subprocess.run( output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True ["node", "-e", script], check=True, capture_output=True, text=True
).stdout) ).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 @pytest.mark.anyio
@ -4146,6 +4193,7 @@ async def test_mobile_issue_capture_lazily_selects_an_initial_owner():
html = await dashboard() html = await dashboard()
assert 'id="create-issue-assignee"' in html assert 'id="create-issue-assignee"' in html
assert '<option value="">Me</option>' in html assert '<option value="">Me</option>' in html
assert '<option value="__unassigned__">No owner</option>' in html
assert 'id="create-issue-assignee-status" class="small" aria-live="polite"' in html assert 'id="create-issue-assignee-status" class="small" aria-live="polite"' in html
assert "createIssueCapture.createOwnerPicker(issueCapture, document," in html assert "createIssueCapture.createOwnerPicker(issueCapture, document," in html
assert "() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });" in html assert "() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });" in html