feat: file mobile issues without an owner (Closes #855)
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 committed 2026-08-14 23:05:59 +00:00
1 parent fc35b17d24
commit ed246ce6dd
12 files changed
+275 -63

No files matched your search

+1
View File
@@ -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' } : {}),
+25 -10
View File
@@ -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'} : {}),
+1
View File
@@ -806,6 +806,7 @@
<label class="create-issue-owner" for="create-issue-assignee">Owner
<select id="create-issue-assignee" aria-describedby="create-issue-assignee-status">
<option value="">Me</option>
<option value="__unassigned__">No owner</option>
</select>
<span id="create-issue-assignee-status" class="small" aria-live="polite">Open the owner picker to load eligible teammates.</span>
</label>
+4 -3
View File
@@ -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');
+44 -44
View File
@@ -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' } : {}),