feat: choose initial owner during issue filing
This commit is contained in:
parent
322f025794
commit
6449ead83d
|
|
@ -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.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' } : {}),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,90 @@ function normalizeSharedContent(value = {}) {
|
||||||
return { title, body };
|
return { title, body };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
|
||||||
|
const select = documentRef.querySelector('#create-issue-assignee');
|
||||||
|
const status = documentRef.querySelector('#create-issue-assignee-status');
|
||||||
|
const getRepository = () => documentRef.querySelector('#create-issue-repository').value;
|
||||||
|
const loadOwners = repository => issueCapture.loadOwners(repository);
|
||||||
|
let ownerRequest = 0;
|
||||||
|
function reset(repository, selected = {}) {
|
||||||
|
select.replaceChildren();
|
||||||
|
const me = documentRef.createElement('option');
|
||||||
|
me.value = '';
|
||||||
|
me.textContent = 'Me';
|
||||||
|
select.appendChild(me);
|
||||||
|
if (selected.assignee) {
|
||||||
|
const option = documentRef.createElement('option');
|
||||||
|
option.value = selected.assignee;
|
||||||
|
option.textContent = (selected.assigneeName || selected.assignee) + ' (@' + selected.assignee + ')';
|
||||||
|
option.dataset.name = selected.assigneeName || selected.assignee;
|
||||||
|
select.appendChild(option);
|
||||||
|
select.value = selected.assignee;
|
||||||
|
}
|
||||||
|
select.dataset.repository = '';
|
||||||
|
status.textContent = repository ? 'Open the owner picker to load eligible teammates.' : 'Choose a repository first.';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(repository) {
|
||||||
|
if (!repository || select.dataset.repository === repository) return;
|
||||||
|
const request = ++ownerRequest;
|
||||||
|
const selected = select.value;
|
||||||
|
const selectedName = select.selectedOptions?.[0]?.dataset.name || '';
|
||||||
|
status.textContent = 'Loading eligible teammates…';
|
||||||
|
try {
|
||||||
|
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} : {});
|
||||||
|
owners.forEach(owner => {
|
||||||
|
if (!owner?.login || owner.login === selected) return;
|
||||||
|
const option = documentRef.createElement('option');
|
||||||
|
option.value = owner.login;
|
||||||
|
option.textContent = (owner.name || owner.login) + ' (@' + owner.login + ')';
|
||||||
|
option.dataset.name = owner.name || owner.login;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
select.dataset.repository = repository;
|
||||||
|
status.textContent = owners.length ? 'Choose yourself or an eligible teammate.' : '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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
submit.disabled = !hasRepository;
|
||||||
|
submit.textContent = hasTeammateOwner ? 'Create & assign' : 'Create & assign to me';
|
||||||
|
start.disabled = !hasRepository || hasBlockers || hasTeammateOwner || !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.' : '');
|
||||||
|
}
|
||||||
|
function fields() {
|
||||||
|
return {
|
||||||
|
assignee: select.value,
|
||||||
|
assigneeName: select.selectedOptions?.[0]?.dataset.name || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function draft(labelIds, blockers, trim = false) {
|
||||||
|
const value = id => documentRef.querySelector(id).value;
|
||||||
|
const clean = input => trim ? input.trim() : input;
|
||||||
|
return {
|
||||||
|
repository:value('#create-issue-repository'),
|
||||||
|
title:clean(value('#create-issue-title')), body:clean(value('#create-issue-body')),
|
||||||
|
labelIds, milestoneId:Number(value('#create-issue-milestone')) || null,
|
||||||
|
dueDate:value('#create-issue-due-date'), ...fields(), blockers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
select.addEventListener('focus', () => {
|
||||||
|
const repository = getRepository();
|
||||||
|
if (repository) load(repository);
|
||||||
|
});
|
||||||
|
select.addEventListener('change', onChange);
|
||||||
|
return { reset, load, updateActions, fields, draft };
|
||||||
|
}
|
||||||
|
|
||||||
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
|
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
|
||||||
const storageKey = 'stackchain.issue-capture.v1';
|
const storageKey = 'stackchain.issue-capture.v1';
|
||||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||||
|
|
@ -51,6 +135,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
|
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
|
||||||
const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null;
|
const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null;
|
||||||
const safeDueDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) ? String(value) : '';
|
const safeDueDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) ? String(value) : '';
|
||||||
|
const safeAssignee = value => /^[A-Za-z0-9_.-]+$/.test(String(value || '')) ? String(value) : '';
|
||||||
|
|
||||||
function loadStored() {
|
function loadStored() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -63,6 +148,11 @@ 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),
|
||||||
};
|
};
|
||||||
|
const assignee = safeAssignee(parsed.assignee);
|
||||||
|
if (assignee) {
|
||||||
|
draft.assignee = assignee;
|
||||||
|
draft.assigneeName = String(parsed.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||||
|
}
|
||||||
const milestoneId = safeMilestoneId(parsed.milestoneId);
|
const milestoneId = safeMilestoneId(parsed.milestoneId);
|
||||||
const dueDate = safeDueDate(parsed.dueDate);
|
const dueDate = safeDueDate(parsed.dueDate);
|
||||||
if (milestoneId !== null) draft.milestoneId = milestoneId;
|
if (milestoneId !== null) draft.milestoneId = milestoneId;
|
||||||
|
|
@ -88,13 +178,18 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
body: String(draft?.body || ''),
|
body: String(draft?.body || ''),
|
||||||
labelIds: safeLabelIds(draft?.labelIds),
|
labelIds: safeLabelIds(draft?.labelIds),
|
||||||
};
|
};
|
||||||
|
const assignee = safeAssignee(draft?.assignee);
|
||||||
|
if (assignee) {
|
||||||
|
safe.assignee = assignee;
|
||||||
|
safe.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||||
|
}
|
||||||
const milestoneId = safeMilestoneId(draft?.milestoneId);
|
const milestoneId = safeMilestoneId(draft?.milestoneId);
|
||||||
const dueDate = safeDueDate(draft?.dueDate);
|
const dueDate = safeDueDate(draft?.dueDate);
|
||||||
if (milestoneId !== null) safe.milestoneId = milestoneId;
|
if (milestoneId !== null) safe.milestoneId = milestoneId;
|
||||||
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']
|
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName']
|
||||||
.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) &&
|
||||||
JSON.stringify(previous.blockers || []) === JSON.stringify(safe.blockers || []);
|
JSON.stringify(previous.blockers || []) === JSON.stringify(safe.blockers || []);
|
||||||
|
|
@ -203,6 +298,13 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadOwners(repository) {
|
||||||
|
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||||
|
return fetchJson('api/v1/repos/' + encoded + '/issue-assignees').then(owners =>
|
||||||
|
Array.isArray(owners) ? owners : []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function loadRepositoryPage(page) {
|
function loadRepositoryPage(page) {
|
||||||
const safePage = Math.max(1, Math.floor(Number(page) || 1));
|
const safePage = Math.max(1, Math.floor(Number(page) || 1));
|
||||||
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
|
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
|
||||||
|
|
@ -312,6 +414,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.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'} : {}),
|
||||||
}),
|
}),
|
||||||
|
|
@ -323,7 +426,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
|
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadRepositoryPage,
|
||||||
searchRepositories, searchBlockers, findDuplicates,
|
searchRepositories, searchBlockers, findDuplicates,
|
||||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||||
|
|
@ -332,5 +435,6 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
}
|
}
|
||||||
|
|
||||||
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
|
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
|
||||||
|
createIssueCapture.createOwnerPicker = createIssueOwnerPicker;
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||||
|
|
|
||||||
|
|
@ -682,6 +682,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.create-issue-blocker-selected button { min-height:44px; }
|
.create-issue-blocker-selected button { min-height:44px; }
|
||||||
.create-issue-form label { display:grid; gap:6px; }
|
.create-issue-form label { display:grid; gap:6px; }
|
||||||
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||||
|
.create-issue-owner { min-width:0; }
|
||||||
|
.create-issue-owner select { min-height:44px; width:100%; }
|
||||||
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
|
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
|
||||||
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
|
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
|
||||||
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||||
|
|
|
||||||
|
|
@ -519,6 +519,7 @@
|
||||||
loadMilestones: item => issueController.loadMilestones(item),
|
loadMilestones: item => issueController.loadMilestones(item),
|
||||||
});
|
});
|
||||||
let issueCapture = null;
|
let issueCapture = null;
|
||||||
|
let issueOwnerPicker = null;
|
||||||
let updateFollowUp = null;
|
let updateFollowUp = null;
|
||||||
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
|
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
|
||||||
const unfiledCaptures = createUnfiledCaptures({
|
const unfiledCaptures = createUnfiledCaptures({
|
||||||
|
|
@ -642,6 +643,8 @@
|
||||||
}, () => {
|
}, () => {
|
||||||
if (!issueCapture) {
|
if (!issueCapture) {
|
||||||
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||||
|
issueOwnerPicker = createIssueCapture.createOwnerPicker(issueCapture, document,
|
||||||
|
() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });
|
||||||
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
|
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
|
||||||
}
|
}
|
||||||
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
|
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
|
||||||
|
|
@ -3675,28 +3678,13 @@
|
||||||
let issueCaptureBlockers = [];
|
let issueCaptureBlockers = [];
|
||||||
|
|
||||||
function saveIssueCaptureDraft() {
|
function saveIssueCaptureDraft() {
|
||||||
if (!issueCapture) return;
|
if (issueCapture) issueCapture.saveDraft(
|
||||||
issueCapture.saveDraft({
|
issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers)
|
||||||
repository: qs('#create-issue-repository').value,
|
);
|
||||||
title: qs('#create-issue-title').value,
|
|
||||||
body: qs('#create-issue-body').value,
|
|
||||||
labelIds: selectedIssueLabelIds(),
|
|
||||||
milestoneId: Number(qs('#create-issue-milestone').value) || null,
|
|
||||||
dueDate: qs('#create-issue-due-date').value,
|
|
||||||
blockers: issueCaptureBlockers,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentIssueCaptureDraft() {
|
function currentIssueCaptureDraft() {
|
||||||
return {
|
return issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers, true);
|
||||||
repository: qs('#create-issue-repository').value,
|
|
||||||
title: qs('#create-issue-title').value.trim(),
|
|
||||||
body: qs('#create-issue-body').value.trim(),
|
|
||||||
labelIds: selectedIssueLabelIds(),
|
|
||||||
milestoneId: Number(qs('#create-issue-milestone').value) || null,
|
|
||||||
dueDate: qs('#create-issue-due-date').value,
|
|
||||||
blockers: issueCaptureBlockers,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let issueCaptureRepositories = [];
|
let issueCaptureRepositories = [];
|
||||||
|
|
@ -3723,11 +3711,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateIssueCreateActions() {
|
function updateIssueCreateActions() {
|
||||||
const hasRepository = Boolean(qs('#create-issue-repository').value);
|
issueOwnerPicker.updateActions(Boolean(qs('#create-issue-repository').value),
|
||||||
const hasBlockers = issueCaptureBlockers.length > 0;
|
issueCaptureBlockers.length > 0, createAndStart.available());
|
||||||
qs('#submit-new-issue').disabled = !hasRepository;
|
|
||||||
qs('#create-and-start-issue').disabled = !hasRepository || hasBlockers || !createAndStart.available();
|
|
||||||
qs('#create-and-start-issue').title = hasBlockers ? 'Blocked work cannot start until its blockers are complete.' : '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderIssueCaptureBlockers(blockers) {
|
function renderIssueCaptureBlockers(blockers) {
|
||||||
|
|
@ -3801,6 +3786,7 @@
|
||||||
qs('#create-issue-repository-search').value = repository;
|
qs('#create-issue-repository-search').value = repository;
|
||||||
qs('#create-issue-repository-results').hidden = true;
|
qs('#create-issue-repository-results').hidden = true;
|
||||||
qs('#create-issue-repository-status').textContent = 'Selected ' + repository + '.';
|
qs('#create-issue-repository-status').textContent = 'Selected ' + repository + '.';
|
||||||
|
issueOwnerPicker.reset(repository);
|
||||||
loadIssueLabels(repository);
|
loadIssueLabels(repository);
|
||||||
loadIssueMilestones(repository);
|
loadIssueMilestones(repository);
|
||||||
saveIssueCaptureDraft();
|
saveIssueCaptureDraft();
|
||||||
|
|
@ -3953,6 +3939,7 @@
|
||||||
setIssueFilingMode(Boolean(captureDraft.repository));
|
setIssueFilingMode(Boolean(captureDraft.repository));
|
||||||
qs('#create-issue-capture-status').textContent = '';
|
qs('#create-issue-capture-status').textContent = '';
|
||||||
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
|
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
|
||||||
|
issueOwnerPicker.reset(captureDraft.repository, captureDraft);
|
||||||
renderIssueCaptureBlockers(captureDraft.blockers || []);
|
renderIssueCaptureBlockers(captureDraft.blockers || []);
|
||||||
qs('#create-issue-blocker-search').value = '';
|
qs('#create-issue-blocker-search').value = '';
|
||||||
qs('#create-issue-blocker-results').hidden = true;
|
qs('#create-issue-blocker-results').hidden = true;
|
||||||
|
|
@ -5144,6 +5131,7 @@
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
qs('#create-issue-repository').addEventListener('change', event => {
|
qs('#create-issue-repository').addEventListener('change', event => {
|
||||||
|
issueOwnerPicker.reset(event.target.value);
|
||||||
loadIssueLabels(event.target.value);
|
loadIssueLabels(event.target.value);
|
||||||
loadIssueMilestones(event.target.value);
|
loadIssueMilestones(event.target.value);
|
||||||
qs('#create-issue-repository-search').value = event.target.value;
|
qs('#create-issue-repository-search').value = event.target.value;
|
||||||
|
|
|
||||||
|
|
@ -797,6 +797,12 @@
|
||||||
</div>
|
</div>
|
||||||
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
||||||
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
||||||
|
<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>
|
||||||
|
</select>
|
||||||
|
<span id="create-issue-assignee-status" class="small" aria-live="polite">Open the owner picker to load eligible teammates.</span>
|
||||||
|
</label>
|
||||||
<label for="create-issue-milestone">Milestone <span class="small">Optional</span>
|
<label for="create-issue-milestone">Milestone <span class="small">Optional</span>
|
||||||
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
|
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
|
||||||
<option value="">No milestone</option>
|
<option value="">No milestone</option>
|
||||||
|
|
|
||||||
|
|
@ -103,11 +103,14 @@
|
||||||
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
|
||||||
|
? 'Owner: ' + (draft.assigneeName || draft.assignee) + ' (@' + draft.assignee + ')'
|
||||||
|
: 'Assigned to you';
|
||||||
options.metadata.textContent = [
|
options.metadata.textContent = [
|
||||||
labels.length ? 'Labels: ' + labels.join(', ') : 'No labels',
|
labels.length ? 'Labels: ' + labels.join(', ') : 'No labels',
|
||||||
'Milestone: ' + milestone,
|
'Milestone: ' + milestone,
|
||||||
'Due: ' + dueDate,
|
'Due: ' + dueDate,
|
||||||
'Assigned to you',
|
owner,
|
||||||
].join(' · ');
|
].join(' · ');
|
||||||
if (options.blockerList) {
|
if (options.blockerList) {
|
||||||
options.blockerList.replaceChildren(...(draft.blockers || []).map(blocker => {
|
options.blockerList.replaceChildren(...(draft.blockers || []).map(blocker => {
|
||||||
|
|
|
||||||
|
|
@ -83,9 +83,15 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
queuedAt: Number(now()),
|
queuedAt: Number(now()),
|
||||||
};
|
};
|
||||||
|
const assignee = /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || ''))
|
||||||
|
? String(draft.assignee) : '';
|
||||||
|
if (assignee) {
|
||||||
|
item.assignee = assignee;
|
||||||
|
item.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||||
|
}
|
||||||
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') item.completionIntent = 'create-and-start';
|
if (draft?.completionIntent === 'create-and-start' && !assignee) item.completionIntent = 'create-and-start';
|
||||||
const attachment = captureAttachment(draft?.attachment);
|
const attachment = captureAttachment(draft?.attachment);
|
||||||
if (attachment) item.attachment = attachment;
|
if (attachment) item.attachment = attachment;
|
||||||
const attachments = captureAttachments(draft?.attachments);
|
const attachments = captureAttachments(draft?.attachments);
|
||||||
|
|
@ -192,6 +198,11 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
const nextTitle = String(draft?.title || '');
|
const nextTitle = String(draft?.title || '');
|
||||||
const nextBody = String(draft?.body || '');
|
const nextBody = String(draft?.body || '');
|
||||||
const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
|
const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
|
||||||
|
const nextAssignee = /^[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)
|
||||||
|
: undefined;
|
||||||
const nextMilestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
|
const nextMilestoneId = 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 nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
|
||||||
|
|
@ -203,19 +214,25 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
JSON.stringify(item.attachments || null) !== JSON.stringify(nextAttachments || null);
|
JSON.stringify(item.attachments || null) !== JSON.stringify(nextAttachments || null);
|
||||||
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
||||||
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
||||||
|
|| item.assignee !== nextAssignee || item.assigneeName !== nextAssigneeName
|
||||||
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|
||||||
|| attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(nextBlockers || null);
|
|| attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(nextBlockers || null);
|
||||||
updated = {
|
updated = {
|
||||||
...item,
|
...item,
|
||||||
repository: nextRepository, title: nextTitle,
|
repository: nextRepository, title: nextTitle,
|
||||||
body: nextBody, labelIds: nextLabelIds,
|
body: nextBody, labelIds: nextLabelIds,
|
||||||
|
assignee: nextAssignee, assigneeName: nextAssigneeName,
|
||||||
milestoneId: nextMilestoneId, dueDate: nextDueDate,
|
milestoneId: nextMilestoneId, dueDate: nextDueDate,
|
||||||
attachment: nextAttachment, attachments:nextAttachments, blockers:nextBlockers,
|
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') updated.completionIntent = 'create-and-start';
|
if (draft?.completionIntent === 'create-and-start' && !nextAssignee) updated.completionIntent = 'create-and-start';
|
||||||
else delete updated.completionIntent;
|
else delete updated.completionIntent;
|
||||||
|
if (nextAssignee === undefined) {
|
||||||
|
delete updated.assignee;
|
||||||
|
delete updated.assigneeName;
|
||||||
|
}
|
||||||
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
||||||
if (nextDueDate === undefined) delete updated.dueDate;
|
if (nextDueDate === undefined) delete updated.dueDate;
|
||||||
if (nextAttachment === undefined) delete updated.attachment;
|
if (nextAttachment === undefined) delete updated.attachment;
|
||||||
|
|
@ -305,6 +322,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.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' } : {}),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1452,8 +1452,10 @@ 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 assignee not in confirmed_assignees:
|
if confirmed_assignees != [assignee]:
|
||||||
raise ValueError("Gitea did not confirm issue self-assignment")
|
raise ValueError(
|
||||||
|
"Gitea did not confirm self-assignment or exact issue assignment"
|
||||||
|
)
|
||||||
labels_value = issue.get("labels")
|
labels_value = issue.get("labels")
|
||||||
labels = labels_value if isinstance(labels_value, list) else []
|
labels = labels_value if isinstance(labels_value, list) else []
|
||||||
milestone_value = issue.get("milestone")
|
milestone_value = issue.get("milestone")
|
||||||
|
|
|
||||||
49
src/main.py
49
src/main.py
|
|
@ -714,6 +714,9 @@ 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)
|
||||||
|
assignee: str | None = Field(
|
||||||
|
default=None, pattern=r"^[A-Za-z0-9_.-]+$", max_length=255
|
||||||
|
)
|
||||||
label_ids: list[int] = Field(default_factory=list, max_length=20)
|
label_ids: list[int] = Field(default_factory=list, max_length=20)
|
||||||
milestone_id: PositiveInt | None = None
|
milestone_id: PositiveInt | None = None
|
||||||
due_date: str | None = Field(
|
due_date: str | None = Field(
|
||||||
|
|
@ -4525,6 +4528,36 @@ async def repository_labels(owner: str, repo: str):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/repos/{owner}/{repo}/issue-assignees")
|
||||||
|
async def repository_issue_assignees(owner: str, repo: str):
|
||||||
|
repository = f"{owner}/{repo}"
|
||||||
|
|
||||||
|
async def load_assignees():
|
||||||
|
if await gitea_proxy.repository_access(repository) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Repository not found")
|
||||||
|
candidates = [
|
||||||
|
item
|
||||||
|
for item in await gitea_proxy.issue_handoff_candidates(repository)
|
||||||
|
if isinstance(item, dict)
|
||||||
|
and isinstance(item.get("login"), str)
|
||||||
|
and re.fullmatch(r"[A-Za-z0-9_.-]+", item["login"])
|
||||||
|
][:25]
|
||||||
|
return JSONResponse(candidates, headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
load_assignees(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Owners could not be loaded. You can still assign the issue to yourself."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/repos/{owner}/{repo}/issues", status_code=201)
|
@app.post("/api/v1/repos/{owner}/{repo}/issues", status_code=201)
|
||||||
async def create_assigned_issue(
|
async def create_assigned_issue(
|
||||||
creation: IssueCreation,
|
creation: IssueCreation,
|
||||||
|
|
@ -4541,6 +4574,17 @@ 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
|
||||||
|
if creation.assignee and creation.assignee != login:
|
||||||
|
eligible = {
|
||||||
|
item["login"]
|
||||||
|
for item in await gitea_proxy.issue_handoff_candidates(repository)
|
||||||
|
}
|
||||||
|
if creation.assignee not in eligible:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail="Selected owner is no longer eligible"
|
||||||
|
)
|
||||||
|
assignee = creation.assignee
|
||||||
if creation.label_ids:
|
if creation.label_ids:
|
||||||
available_labels = await gitea_proxy.repo_labels(repository)
|
available_labels = await gitea_proxy.repo_labels(repository)
|
||||||
valid_label_ids = {
|
valid_label_ids = {
|
||||||
|
|
@ -4566,13 +4610,13 @@ async def create_assigned_issue(
|
||||||
repository,
|
repository,
|
||||||
creation.title,
|
creation.title,
|
||||||
creation.body,
|
creation.body,
|
||||||
login,
|
assignee,
|
||||||
creation.label_ids,
|
creation.label_ids,
|
||||||
creation.milestone_id,
|
creation.milestone_id,
|
||||||
creation.due_date,
|
creation.due_date,
|
||||||
)
|
)
|
||||||
return await gitea_proxy.create_issue(
|
return await gitea_proxy.create_issue(
|
||||||
repository, creation.title, creation.body, login, creation.label_ids
|
repository, creation.title, creation.body, assignee, creation.label_ids
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -4584,6 +4628,7 @@ async def create_assigned_issue(
|
||||||
repository,
|
repository,
|
||||||
creation.title,
|
creation.title,
|
||||||
creation.body,
|
creation.body,
|
||||||
|
creation.assignee,
|
||||||
tuple(creation.label_ids),
|
tuple(creation.label_ids),
|
||||||
creation.milestone_id,
|
creation.milestone_id,
|
||||||
creation.due_date,
|
creation.due_date,
|
||||||
|
|
|
||||||
|
|
@ -620,6 +620,63 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
|
||||||
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
|
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_issue_endpoint_validates_and_assigns_selected_initial_owner(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"login": "timmy"}
|
||||||
|
|
||||||
|
async def access(repository):
|
||||||
|
return {"full_name": repository}
|
||||||
|
|
||||||
|
async def candidates(repository):
|
||||||
|
assert repository == "stackchain/api"
|
||||||
|
return [{"login": "alex", "name": "Alex"}]
|
||||||
|
|
||||||
|
async def create(repository, title, body, assignee, label_ids):
|
||||||
|
calls.append((repository, title, assignee))
|
||||||
|
return {"number": 18, "repository": repository, "assignees": [assignee]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
|
||||||
|
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": "Delegate at capture", "assignee": "alex"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert response.json()["assignees"] == ["alex"]
|
||||||
|
assert calls == [("stackchain/api", "Delegate at capture", "alex")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_initial_owner_candidates_require_repository_access_and_are_bounded(monkeypatch):
|
||||||
|
async def access(repository):
|
||||||
|
return {"full_name": repository} if repository == "stackchain/api" else None
|
||||||
|
|
||||||
|
async def candidates(repository):
|
||||||
|
return ([{"login": "bad user", "name": "Unsafe"}] +
|
||||||
|
[{"login": f"user-{index}", "name": f"User {index}"} for index in range(40)])
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
listed = await client.get("/api/v1/repos/stackchain/api/issue-assignees")
|
||||||
|
missing = await client.get("/api/v1/repos/stackchain/private/issue-assignees")
|
||||||
|
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert listed.headers["cache-control"] == "no-store"
|
||||||
|
assert len(listed.json()) == 25
|
||||||
|
assert all(item["login"] != "bad user" for item in listed.json())
|
||||||
|
assert missing.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_repository_page_reports_more_results_and_targeted_access_uses_repository_route():
|
async def test_repository_page_reports_more_results_and_targeted_access_uses_repository_route():
|
||||||
calls = []
|
calls = []
|
||||||
|
|
@ -1118,6 +1175,22 @@ 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_requires_exact_selected_initial_owner():
|
||||||
|
async def handler(_request):
|
||||||
|
return httpx.Response(201, json={
|
||||||
|
"id": 82, "number": 18, "title": "Delegate", "state": "open",
|
||||||
|
"assignees": [{"login": "alex"}, {"login": "timmy"}], "labels": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||||
|
try:
|
||||||
|
with pytest.raises(ValueError, match="exact issue assignment"):
|
||||||
|
await gitea_proxy.create_issue("stackchain/api", "Delegate", "", "alex", [])
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_gitea_create_issue_requires_confirmed_release_plan():
|
async def test_gitea_create_issue_requires_confirmed_release_plan():
|
||||||
async def handler(_request):
|
async def handler(_request):
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,20 @@ process.stdout.write(JSON.stringify(blockerList.children.map(item=>item.textCont
|
||||||
assert run_node(script) == ["o/api #7 — API ready", "o/web #8 — Web ready"]
|
assert run_node(script) == ["o/api #7 — API ready", "o/web #8 — Web ready"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_names_the_selected_initial_owner():
|
||||||
|
script = f"""
|
||||||
|
const createReview=require({json.dumps(str(MODULE))});
|
||||||
|
function target(){{return{{hidden:true,disabled:false,textContent:'',addEventListener:()=>{{}},replaceChildren:()=>{{}},focus:()=>{{}}}};}}
|
||||||
|
const metadata=target();
|
||||||
|
const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(),
|
||||||
|
repository:target(),intent:target(),title:target(),body:target(),metadata,
|
||||||
|
document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}});
|
||||||
|
review.open({{draft:{{repository:'o/r',title:'Delegate',assignee:'alex',assigneeName:'Alex'}},intent:'create-and-assign'}},target());
|
||||||
|
process.stdout.write(JSON.stringify({{metadata:metadata.textContent}}));
|
||||||
|
"""
|
||||||
|
assert run_node(script)["metadata"].endswith("Owner: Alex (@alex)")
|
||||||
|
|
||||||
|
|
||||||
def test_escape_returns_to_the_unchanged_issue_form():
|
def test_escape_returns_to_the_unchanged_issue_form():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createReview = require({json.dumps(str(MODULE))});
|
const createReview = require({json.dumps(str(MODULE))});
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,40 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
|
||||||
assert output["stored"]["version"] == 3
|
assert output["stored"]["version"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_outbox_preserves_initial_owner_and_disables_start_for_teammate():
|
||||||
|
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:()=>'owner-outbox',fetchJson:async(url,options)=>{{calls.push(JSON.parse(options.body));return{{number:18,assignees:['alex']}};}}}});
|
||||||
|
const queued=outbox.enqueue({{repository:'o/r',title:'Delegate',assignee:'alex',assigneeName:'Alex',completionIntent:'create-and-start'}});
|
||||||
|
(async()=>{{await outbox.flush('timmy');process.stdout.write(JSON.stringify({{queued,calls}}));}})();
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
assert output["queued"]["assignee"] == "alex"
|
||||||
|
assert output["queued"]["assigneeName"] == "Alex"
|
||||||
|
assert "completionIntent" not in output["queued"]
|
||||||
|
assert output["calls"] == [{
|
||||||
|
"title": "Delegate", "body": "", "label_ids": [], "assignee": "alex"
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_editing_queued_issue_can_return_initial_owner_to_self():
|
||||||
|
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:'Delegate',assignee:'alex',assigneeName:'Alex'}});
|
||||||
|
const updated=outbox.update(queued.id,{{...queued,assignee:'',assigneeName:'',completionIntent:'create-and-start'}});
|
||||||
|
process.stdout.write(JSON.stringify(updated));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
assert "assignee" not in output
|
||||||
|
assert "assigneeName" not in output
|
||||||
|
assert output["completionIntent"] == "create-and-start"
|
||||||
|
assert output["operationId"] != "1"
|
||||||
|
|
||||||
|
|
||||||
def test_issue_outbox_preserves_bounded_ordered_evidence_bundle():
|
def test_issue_outbox_preserves_bounded_ordered_evidence_bundle():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||||
|
|
|
||||||
|
|
@ -1845,8 +1845,7 @@ async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
|
||||||
assert 'id="create-issue-milestone-status" aria-live="polite"' in html
|
assert 'id="create-issue-milestone-status" aria-live="polite"' in html
|
||||||
assert '.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px;' in html
|
assert '.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px;' in html
|
||||||
assert 'issueCapture.loadMilestones(repository)' in html
|
assert 'issueCapture.loadMilestones(repository)' in html
|
||||||
assert "milestoneId: Number(qs('#create-issue-milestone').value) || null" in html
|
assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html
|
||||||
assert "dueDate: qs('#create-issue-due-date').value" in html
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -4018,7 +4017,7 @@ async def test_mobile_issue_capture_requires_explicit_searchable_repository_sele
|
||||||
assert "if (query.length < 2) {\n issueCapture.searchRepositories(query);" in html
|
assert "if (query.length < 2) {\n issueCapture.searchRepositories(query);" in html
|
||||||
assert "renderIssueRepositoryResults(state.items)" in html
|
assert "renderIssueRepositoryResults(state.items)" in html
|
||||||
assert "selectIssueCaptureRepository(repository)" in html
|
assert "selectIssueCaptureRepository(repository)" in html
|
||||||
assert "const hasRepository = Boolean(qs('#create-issue-repository').value);" in html
|
assert "issueOwnerPicker.updateActions(Boolean(qs('#create-issue-repository').value)" in html
|
||||||
assert '.create-issue-repository-result { min-height:44px;' in html
|
assert '.create-issue-repository-result { min-height:44px;' in html
|
||||||
assert '.create-issue-repository-picker { min-width:0;' in html
|
assert '.create-issue-repository-picker { min-width:0;' in html
|
||||||
|
|
||||||
|
|
@ -4053,6 +4052,49 @@ capture.saveDraft({{repository:'o/r',title:'Blocked work',body:'',blockers}});
|
||||||
assert output["calls"] == ["api/v1/search?q=api&limit=20"]
|
assert output["calls"] == ["api/v1/search?q=api&limit=20"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_capture_persists_selected_owner_and_sends_it_on_creation():
|
||||||
|
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 fetchJson=async (url,options={{}})=>{{calls.push({{url,body:options.body||''}});if(url.endsWith('/issue-assignees'))return [{{login:'alex',name:'Alex'}}];return {{number:18,assignees:['alex']}};}};
|
||||||
|
const capture=createIssueCapture({{storage,fetchJson,createOperationId:()=>'owner-op'}});
|
||||||
|
capture.saveDraft({{repository:'o/r',title:'Delegate',body:'Context',labelIds:[],assignee:'alex',assigneeName:'Alex'}});
|
||||||
|
(async()=>{{const owners=await capture.loadOwners('o/r');const draft=capture.loadDraft();await capture.submit(draft);process.stdout.write(JSON.stringify({{owners,draft,calls}}));}})();
|
||||||
|
"""
|
||||||
|
output = json.loads(subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
).stdout)
|
||||||
|
|
||||||
|
assert output["owners"] == [{"login": "alex", "name": "Alex"}]
|
||||||
|
assert output["draft"]["assignee"] == "alex"
|
||||||
|
assert output["draft"]["assigneeName"] == "Alex"
|
||||||
|
assert json.loads(output["calls"][-1]["body"])["assignee"] == "alex"
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_owner_picker_discards_an_older_same_repository_response():
|
||||||
|
script = f"""
|
||||||
|
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||||
|
let repository='o/a'; const pending=[];
|
||||||
|
function select(){{const listeners={{}};return{{value:'',dataset:{{}},children:[],selectedOptions:[],
|
||||||
|
replaceChildren(){{this.children=[];this.value='';this.selectedOptions=[];}},appendChild(option){{this.children.push(option);if(option.value===this.value)this.selectedOptions=[option];}},
|
||||||
|
addEventListener:(name,fn)=>listeners[name]=fn}};}}
|
||||||
|
const owner=select(),status={{textContent:''}},repo={{get value(){{return repository;}}}};
|
||||||
|
const documentRef={{querySelector:id=>id==='#create-issue-assignee'?owner:id==='#create-issue-assignee-status'?status:repo,
|
||||||
|
createElement:()=>({{value:'',textContent:'',dataset:{{}}}})}};
|
||||||
|
const capture={{loadOwners:()=>new Promise(resolve=>pending.push(resolve))}};
|
||||||
|
const picker=createIssueCapture.createOwnerPicker(capture,documentRef,()=>{{}});
|
||||||
|
(async()=>{{picker.reset('o/a');const first=picker.load('o/a');repository='o/b';picker.reset('o/b');
|
||||||
|
repository='o/a';picker.reset('o/a');const latest=picker.load('o/a');pending[1]([{{login:'new',name:'New'}}]);await latest;
|
||||||
|
pending[0]([{{login:'old',name:'Old'}}]);await first;
|
||||||
|
process.stdout.write(JSON.stringify(owner.children.map(option=>option.value)));}})();
|
||||||
|
"""
|
||||||
|
output = json.loads(subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
).stdout)
|
||||||
|
assert output == ["", "new"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_mobile_issue_capture_selects_blockers_without_starting_blocked_work():
|
async def test_mobile_issue_capture_selects_blockers_without_starting_blocked_work():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
@ -4061,14 +4103,27 @@ async def test_mobile_issue_capture_selects_blockers_without_starting_blocked_wo
|
||||||
assert 'id="create-issue-blocker-selected"' in html
|
assert 'id="create-issue-blocker-selected"' in html
|
||||||
assert 'id="create-issue-blocker-status" class="small" aria-live="polite"' in html
|
assert 'id="create-issue-blocker-status" class="small" aria-live="polite"' in html
|
||||||
assert "issueCapture.searchBlockers(query)" in html
|
assert "issueCapture.searchBlockers(query)" in html
|
||||||
assert "blockers: issueCaptureBlockers" in html
|
assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html
|
||||||
assert "renderIssueCaptureBlockers(captureDraft.blockers || [])" in html
|
assert "renderIssueCaptureBlockers(captureDraft.blockers || [])" in html
|
||||||
assert "const hasBlockers = issueCaptureBlockers.length > 0;" in html
|
assert "issueCaptureBlockers.length > 0, createAndStart.available()" in html
|
||||||
assert ".create-issue-blocker-result" in html
|
assert ".create-issue-blocker-result" in html
|
||||||
assert "min-height:44px" in html.split(".create-issue-blocker-result", 1)[1]
|
assert "min-height:44px" in html.split(".create-issue-blocker-result", 1)[1]
|
||||||
assert "@media(max-width:320px)" in html
|
assert "@media(max-width:320px)" in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_issue_capture_lazily_selects_an_initial_owner():
|
||||||
|
html = await dashboard()
|
||||||
|
assert 'id="create-issue-assignee"' in html
|
||||||
|
assert '<option value="">Me</option>' in html
|
||||||
|
assert 'id="create-issue-assignee-status" class="small" aria-live="polite"' in html
|
||||||
|
assert "createIssueCapture.createOwnerPicker(issueCapture, document," in html
|
||||||
|
assert "() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });" in html
|
||||||
|
assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html
|
||||||
|
assert "issueOwnerPicker.updateActions(" in html
|
||||||
|
assert '.create-issue-owner select { min-height:44px;' in html
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user