feat: file mobile issues without an owner (Closes #855)
This commit is contained in:
parent
fc35b17d24
commit
ed246ce6dd
|
|
@ -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' } : {}),
|
||||||
|
|
|
||||||
|
|
@ -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'} : {}),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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' } : {}),
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
12
src/main.py
12
src/main.py
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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))});
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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))});
|
||||||
|
|
|
||||||
|
|
@ -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))});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user