feat: choose initial owner during issue filing
All checks were successful
CI / lint (pull_request) Successful in 1m48s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-14 18:26:33 +00:00
parent 322f025794
commit 6449ead83d
13 changed files with 383 additions and 38 deletions

View File

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

View File

@ -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;

View File

@ -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; }

View File

@ -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;

View File

@ -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>

View File

@ -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 => {

View File

@ -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' } : {}),
}), }),

View File

@ -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")

View File

@ -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,

View File

@ -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):

View File

@ -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))});

View File

@ -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))});

View File

@ -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()