diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js
index 18008b1..74b0fcc 100644
--- a/frontend/background-issue-sync.js
+++ b/frontend/background-issue-sync.js
@@ -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' } : {}),
}),
diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js
index ee63d05..19c7d19 100644
--- a/frontend/create-issue-sheet.js
+++ b/frontend/create-issue-sheet.js
@@ -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;
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 820188c..c68c382 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -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; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index ea84ae6..bf190f2 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -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;
diff --git a/frontend/index.html b/frontend/index.html
index 41e9eee..018c2c3 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -797,6 +797,12 @@
+