diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js
index b087a4e..18008b1 100644
--- a/frontend/background-issue-sync.js
+++ b/frontend/background-issue-sync.js
@@ -539,7 +539,27 @@ function createBackgroundIssueSync({
deliveredIssue = await requestStage(item, request.url, request.options);
await checkpointClaim(item, current => ({ ...current, deliveredIssue }));
}
- const attachments = Array.isArray(item.attachments) ? item.attachments : [item.attachment];
+ const blockers = Array.isArray(item.blockers) ? item.blockers : [];
+ let deliveredBlockers = Math.min(Number(item.deliveredBlockers) || 0, blockers.length);
+ for (let index = deliveredBlockers; index < blockers.length; index += 1) {
+ const blocker = blockers[index];
+ await requestStage(
+ item,
+ base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/blockers',
+ {
+ method:'PATCH',
+ headers:{
+ Accept:'application/json', 'Content-Type':'application/json',
+ 'Idempotency-Key':stageOperationId(item.operationId, 'blocker-' + index),
+ },
+ body:JSON.stringify({repository:blocker.repository, number:blocker.number, present:true}),
+ },
+ );
+ deliveredBlockers = index + 1;
+ await checkpointClaim(item, current => ({ ...current, deliveredIssue, deliveredBlockers }));
+ }
+ const attachments = (Array.isArray(item.attachments) ? item.attachments : [item.attachment]).filter(Boolean);
+ if (!attachments.length) return deliveredIssue;
const attachmentMarkdowns = Array.isArray(item.attachmentMarkdowns)
? item.attachmentMarkdowns.slice(0, attachments.length)
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
@@ -664,7 +684,8 @@ function createBackgroundIssueSync({
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
await deliverIssueCapture(item) : item.attachments?.length && !item.kind ?
- await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
+ await deliverIssueCapture(item) : item.blockers?.length && !item.kind ?
+ await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
const error = new Error('Issue closure was not confirmed.');
error.status = 422;
diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js
index 7d802a7..ee63d05 100644
--- a/frontend/create-issue-sheet.js
+++ b/frontend/create-issue-sheet.js
@@ -26,12 +26,28 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
let pending = null;
let duplicateRequest = 0;
let repositorySearchRequest = 0;
+ let blockerSearchRequest = 0;
let duplicateState = {status: 'idle', key: '', candidates: []};
let acknowledgedDuplicateKey = '';
const repositoryPageRequests = new Map();
const safeLabelIds = value => Array.from(new Set(
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
)).slice(0, 20);
+ const safeBlockers = value => {
+ const seen = new Set();
+ return (Array.isArray(value) ? value : []).reduce((items, blocker) => {
+ const repository = String(blocker?.repository || '').trim();
+ const number = Number(blocker?.number);
+ const key = repository + '#' + number;
+ if (items.length >= 5 || seen.has(key) ||
+ !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
+ !Number.isInteger(number) || number < 1) return items;
+ seen.add(key);
+ items.push({repository, number,
+ title:String(blocker?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255)});
+ return items;
+ }, []);
+ };
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) : '';
@@ -51,6 +67,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
const dueDate = safeDueDate(parsed.dueDate);
if (milestoneId !== null) draft.milestoneId = milestoneId;
if (dueDate) draft.dueDate = dueDate;
+ const blockers = safeBlockers(parsed.blockers);
+ if (blockers.length) draft.blockers = blockers;
return draft;
} catch (_error) {
return {...emptyDraft(), operationId: ''};
@@ -74,9 +92,12 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
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']
.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 || []);
writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
return safe;
}
@@ -216,6 +237,21 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
}
+ async function searchBlockers(value) {
+ const query = String(value || '').trim().slice(0, 80);
+ const request = ++blockerSearchRequest;
+ if (query.length < 2) return {status:'idle', items:[]};
+ try {
+ const payload = await fetchJson('api/v1/search?q=' + encodeURIComponent(query) + '&limit=20');
+ if (request !== blockerSearchRequest) return {status:'stale', items:[]};
+ return {status:'ready', items:(Array.isArray(payload?.items) ? payload.items : [])
+ .filter(item => item?.kind === 'issue' && item?.state === 'open').slice(0, 20)};
+ } catch (error) {
+ if (request !== blockerSearchRequest) return {status:'stale', items:[]};
+ return {status:'failed', items:[], error};
+ }
+ }
+
function duplicateKey(draft) {
const repository = String(draft?.repository || '').trim();
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
@@ -288,7 +324,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
return {
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
- searchRepositories, findDuplicates,
+ searchRepositories, searchBlockers, findDuplicates,
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp,
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index b06d92a..820188c 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -672,6 +672,14 @@ textarea { resize: vertical; min-height: 120px; }
#create-issue-repository-results[hidden] { display:none; }
.create-issue-repository-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; }
.create-issue-repository-result:last-child { border-bottom:0; }
+.create-issue-blockers { display:grid; gap:8px; min-width:0; }
+#create-issue-blocker-search { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
+#create-issue-blocker-results { display:grid; max-height:min(36dvh,280px); overflow:auto; border:1px solid #2a496e; border-radius:10px; }
+#create-issue-blocker-results[hidden] { display:none; }
+.create-issue-blocker-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; }
+#create-issue-blocker-selected { display:grid; gap:8px; margin:0; padding:0; list-style:none; min-width:0; }
+.create-issue-blocker-selected { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; min-width:0; overflow-wrap:anywhere; }
+.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-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 99f9cd7..ea84ae6 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -568,6 +568,7 @@
title: qs('#issue-filing-review-title'),
body: qs('#issue-filing-review-body'),
metadata: qs('#issue-filing-review-metadata'),
+ blockerList: qs('#issue-filing-review-blockers'),
status: qs('#issue-filing-review-status'),
document,
createObjectURL: blob => URL.createObjectURL(blob),
@@ -3671,6 +3672,8 @@
qs('#find-work').focus();
}
+ let issueCaptureBlockers = [];
+
function saveIssueCaptureDraft() {
if (!issueCapture) return;
issueCapture.saveDraft({
@@ -3680,6 +3683,7 @@
labelIds: selectedIssueLabelIds(),
milestoneId: Number(qs('#create-issue-milestone').value) || null,
dueDate: qs('#create-issue-due-date').value,
+ blockers: issueCaptureBlockers,
});
}
@@ -3691,6 +3695,7 @@
labelIds: selectedIssueLabelIds(),
milestoneId: Number(qs('#create-issue-milestone').value) || null,
dueDate: qs('#create-issue-due-date').value,
+ blockers: issueCaptureBlockers,
};
}
@@ -3698,6 +3703,7 @@
let nextIssueRepositoryPage = 2;
let moreIssueRepositoriesAvailable = false;
let issueRepositorySearchTimer = null;
+ let captureBlockerSearchTimer = null;
function appendIssueRepositories(items) {
const select = qs('#create-issue-repository');
@@ -3718,8 +3724,58 @@
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 || !createAndStart.available();
+ 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) {
+ issueCaptureBlockers = Array.isArray(blockers) ? blockers.slice(0, 5) : [];
+ const selected = qs('#create-issue-blocker-selected');
+ selected.replaceChildren(...issueCaptureBlockers.map((blocker, index) => {
+ const item = document.createElement('li');
+ item.className = 'create-issue-blocker-selected';
+ const text = document.createElement('span');
+ text.textContent = blocker.repository + ' #' + blocker.number + ' — ' + blocker.title;
+ const remove = document.createElement('button');
+ remove.type = 'button';
+ remove.textContent = 'Remove';
+ remove.setAttribute('aria-label', 'Remove blocker ' + blocker.repository + ' #' + blocker.number);
+ remove.addEventListener('click', () => {
+ renderIssueCaptureBlockers(issueCaptureBlockers.filter((_value, position) => position !== index));
+ saveIssueCaptureDraft();
+ });
+ item.append(text, remove);
+ return item;
+ }));
+ qs('#create-issue-blocker-status').textContent = issueCaptureBlockers.length ?
+ issueCaptureBlockers.length + ' blocker' + (issueCaptureBlockers.length === 1 ? '' : 's') +
+ ' selected. Blocked work will be created without starting.' : 'No blockers selected.';
+ updateIssueCreateActions();
+ }
+
+ function renderIssueCaptureBlockerResults(items) {
+ const results = qs('#create-issue-blocker-results');
+ results.replaceChildren();
+ (Array.isArray(items) ? items : []).filter(candidate => !issueCaptureBlockers.some(blocker =>
+ blocker.repository === candidate.repository && blocker.number === Number(candidate.number))).forEach(candidate => {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'create-issue-blocker-result';
+ button.setAttribute('role', 'option');
+ button.textContent = candidate.repository + ' #' + candidate.number + ' — ' + candidate.title;
+ button.addEventListener('click', () => {
+ renderIssueCaptureBlockers([...issueCaptureBlockers, {
+ repository:candidate.repository, number:Number(candidate.number), title:candidate.title,
+ }]);
+ results.hidden = true;
+ qs('#create-issue-blocker-search').value = '';
+ saveIssueCaptureDraft();
+ });
+ results.appendChild(button);
+ });
+ results.hidden = !results.childElementCount;
}
function renderIssueRepositoryResults(items) {
@@ -3897,6 +3953,9 @@
setIssueFilingMode(Boolean(captureDraft.repository));
qs('#create-issue-capture-status').textContent = '';
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
+ renderIssueCaptureBlockers(captureDraft.blockers || []);
+ qs('#create-issue-blocker-search').value = '';
+ qs('#create-issue-blocker-results').hidden = true;
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
scheduleIssueDuplicateCheck();
@@ -5116,6 +5175,36 @@
status.textContent = state.items.length ? 'Choose a matching repository.' : 'No accessible repositories match.';
}, 250);
});
+ qs('#create-issue-blocker-search').addEventListener('input', event => {
+ clearTimeout(captureBlockerSearchTimer);
+ const query = event.target.value.trim();
+ const results = qs('#create-issue-blocker-results');
+ const status = qs('#create-issue-blocker-status');
+ if (query.length < 2) {
+ issueCapture.searchBlockers(query);
+ results.hidden = true;
+ status.textContent = issueCaptureBlockers.length ? issueCaptureBlockers.length + ' blocker(s) selected.' :
+ (query ? 'Enter at least 2 characters to search.' : 'No blockers selected.');
+ return;
+ }
+ if (issueCaptureBlockers.length >= 5) {
+ results.hidden = true;
+ status.textContent = 'Five blockers selected. Remove one to choose another.';
+ return;
+ }
+ status.textContent = 'Searching open issues…';
+ captureBlockerSearchTimer = setTimeout(async () => {
+ const state = await issueCapture.searchBlockers(query);
+ if (state.status === 'stale') return;
+ if (state.status === 'failed') {
+ results.hidden = true;
+ status.textContent = 'Blocker search failed. Your draft is safe; retry.';
+ return;
+ }
+ renderIssueCaptureBlockerResults(state.items);
+ status.textContent = state.items.length ? 'Choose an issue that must finish first.' : 'No open issues match.';
+ }, 250);
+ });
qs('#load-more-issue-repositories').addEventListener('click', async event => {
const button = event.currentTarget;
const status = qs('#create-issue-repository-status');
diff --git a/frontend/index.html b/frontend/index.html
index c4be4d2..41e9eee 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -806,6 +806,15 @@
+