feat: choose initial owner during issue filing
This commit is contained in:
1 parent
322f025794
commit
6449ead83d
13 files changed
+383
-38
No files matched your search
@@ -446,6 +446,7 @@ function createBackgroundIssueSync({
|
||||
title: item.title,
|
||||
body: item.body,
|
||||
label_ids: item.labelIds,
|
||||
...(item.assignee ? { assignee: item.assignee } : {}),
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
|
||||
@@ -19,6 +19,90 @@ function normalizeSharedContent(value = {}) {
|
||||
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 }) {
|
||||
const storageKey = 'stackchain.issue-capture.v1';
|
||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||
@@ -51,6 +135,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
|
||||
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 safeAssignee = value => /^[A-Za-z0-9_.-]+$/.test(String(value || '')) ? String(value) : '';
|
||||
|
||||
function loadStored() {
|
||||
try {
|
||||
@@ -63,6 +148,11 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
labelIds: safeLabelIds(parsed.labelIds),
|
||||
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 dueDate = safeDueDate(parsed.dueDate);
|
||||
if (milestoneId !== null) draft.milestoneId = milestoneId;
|
||||
@@ -88,13 +178,18 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
body: String(draft?.body || ''),
|
||||
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 dueDate = safeDueDate(draft?.dueDate);
|
||||
if (milestoneId !== null) safe.milestoneId = milestoneId;
|
||||
if (dueDate) safe.dueDate = dueDate;
|
||||
const blockers = safeBlockers(draft?.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] || '')) &&
|
||||
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) &&
|
||||
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) {
|
||||
const safePage = Math.max(1, Math.floor(Number(page) || 1));
|
||||
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
|
||||
@@ -312,6 +414,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: saved.title, body: saved.body, label_ids: saved.labelIds,
|
||||
...(saved.assignee ? {assignee: saved.assignee} : {}),
|
||||
...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}),
|
||||
...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}),
|
||||
}),
|
||||
@@ -323,7 +426,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
|
||||
return {
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadRepositoryPage,
|
||||
searchRepositories, searchBlockers, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
@@ -332,5 +435,6 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
|
||||
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
|
||||
createIssueCapture.createOwnerPicker = createIssueOwnerPicker;
|
||||
|
||||
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-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-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-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; }
|
||||
|
||||
+12
-24
@@ -519,6 +519,7 @@
|
||||
loadMilestones: item => issueController.loadMilestones(item),
|
||||
});
|
||||
let issueCapture = null;
|
||||
let issueOwnerPicker = null;
|
||||
let updateFollowUp = null;
|
||||
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
|
||||
const unfiledCaptures = createUnfiledCaptures({
|
||||
@@ -642,6 +643,8 @@
|
||||
}, () => {
|
||||
if (!issueCapture) {
|
||||
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
issueOwnerPicker = createIssueCapture.createOwnerPicker(issueCapture, document,
|
||||
() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });
|
||||
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
|
||||
}
|
||||
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
|
||||
@@ -3675,28 +3678,13 @@
|
||||
let issueCaptureBlockers = [];
|
||||
|
||||
function saveIssueCaptureDraft() {
|
||||
if (!issueCapture) return;
|
||||
issueCapture.saveDraft({
|
||||
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,
|
||||
});
|
||||
if (issueCapture) issueCapture.saveDraft(
|
||||
issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers)
|
||||
);
|
||||
}
|
||||
|
||||
function currentIssueCaptureDraft() {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
return issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers, true);
|
||||
}
|
||||
|
||||
let issueCaptureRepositories = [];
|
||||
@@ -3723,11 +3711,8 @@
|
||||
}
|
||||
|
||||
function updateIssueCreateActions() {
|
||||
const hasRepository = Boolean(qs('#create-issue-repository').value);
|
||||
const hasBlockers = issueCaptureBlockers.length > 0;
|
||||
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.' : '';
|
||||
issueOwnerPicker.updateActions(Boolean(qs('#create-issue-repository').value),
|
||||
issueCaptureBlockers.length > 0, createAndStart.available());
|
||||
}
|
||||
|
||||
function renderIssueCaptureBlockers(blockers) {
|
||||
@@ -3801,6 +3786,7 @@
|
||||
qs('#create-issue-repository-search').value = repository;
|
||||
qs('#create-issue-repository-results').hidden = true;
|
||||
qs('#create-issue-repository-status').textContent = 'Selected ' + repository + '.';
|
||||
issueOwnerPicker.reset(repository);
|
||||
loadIssueLabels(repository);
|
||||
loadIssueMilestones(repository);
|
||||
saveIssueCaptureDraft();
|
||||
@@ -3953,6 +3939,7 @@
|
||||
setIssueFilingMode(Boolean(captureDraft.repository));
|
||||
qs('#create-issue-capture-status').textContent = '';
|
||||
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
|
||||
issueOwnerPicker.reset(captureDraft.repository, captureDraft);
|
||||
renderIssueCaptureBlockers(captureDraft.blockers || []);
|
||||
qs('#create-issue-blocker-search').value = '';
|
||||
qs('#create-issue-blocker-results').hidden = true;
|
||||
@@ -5144,6 +5131,7 @@
|
||||
})
|
||||
);
|
||||
qs('#create-issue-repository').addEventListener('change', event => {
|
||||
issueOwnerPicker.reset(event.target.value);
|
||||
loadIssueLabels(event.target.value);
|
||||
loadIssueMilestones(event.target.value);
|
||||
qs('#create-issue-repository-search').value = event.target.value;
|
||||
|
||||
@@ -797,6 +797,12 @@
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
|
||||
<option value="">No milestone</option>
|
||||
|
||||
@@ -103,11 +103,14 @@
|
||||
const labels = (draft.labels || []).map(label => typeof label === 'string' ? label : label.name);
|
||||
const milestone = draft.milestone?.title || draft.milestoneTitle || 'No milestone';
|
||||
const dueDate = draft.dueDate || draft.due_date || 'No due date';
|
||||
const owner = draft.assignee
|
||||
? 'Owner: ' + (draft.assigneeName || draft.assignee) + ' (@' + draft.assignee + ')'
|
||||
: 'Assigned to you';
|
||||
options.metadata.textContent = [
|
||||
labels.length ? 'Labels: ' + labels.join(', ') : 'No labels',
|
||||
'Milestone: ' + milestone,
|
||||
'Due: ' + dueDate,
|
||||
'Assigned to you',
|
||||
owner,
|
||||
].join(' · ');
|
||||
if (options.blockerList) {
|
||||
options.blockerList.replaceChildren(...(draft.blockers || []).map(blocker => {
|
||||
|
||||
@@ -83,9 +83,15 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
status: 'queued',
|
||||
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);
|
||||
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);
|
||||
if (attachment) item.attachment = attachment;
|
||||
const attachments = captureAttachments(draft?.attachments);
|
||||
@@ -192,6 +198,11 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
const nextTitle = String(draft?.title || '');
|
||||
const nextBody = String(draft?.body || '');
|
||||
const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
|
||||
const nextAssignee = /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || ''))
|
||||
? 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
|
||||
? Number(draft.milestoneId) : undefined;
|
||||
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);
|
||||
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
||||
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
||||
|| item.assignee !== nextAssignee || item.assigneeName !== nextAssigneeName
|
||||
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|
||||
|| attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(nextBlockers || null);
|
||||
updated = {
|
||||
...item,
|
||||
repository: nextRepository, title: nextTitle,
|
||||
body: nextBody, labelIds: nextLabelIds,
|
||||
assignee: nextAssignee, assigneeName: nextAssigneeName,
|
||||
milestoneId: nextMilestoneId, dueDate: nextDueDate,
|
||||
attachment: nextAttachment, attachments:nextAttachments, blockers:nextBlockers,
|
||||
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||
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;
|
||||
if (nextAssignee === undefined) {
|
||||
delete updated.assignee;
|
||||
delete updated.assigneeName;
|
||||
}
|
||||
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
||||
if (nextDueDate === undefined) delete updated.dueDate;
|
||||
if (nextAttachment === undefined) delete updated.attachment;
|
||||
@@ -305,6 +322,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title, body: item.body, label_ids: item.labelIds,
|
||||
...(item.assignee ? { assignee: item.assignee } : {}),
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user