feat: manage issue blockers in app (Closes #531)
CI / lint (pull_request) Successful in 1m14s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 02:02:23 +00:00
1 parent 29ceedfc01
commit 2c8a1225e0
8 files changed
+484 -1

No files matched your search

+10
View File
@@ -278,6 +278,14 @@ textarea { resize: vertical; min-height: 120px; }
.issue-blocker-list { display:grid; gap:8px; }
.issue-blocker { min-width:0; overflow-wrap:anywhere; display:grid; gap:4px; padding:10px; border:1px solid #92400e; border-radius:10px; color:#fef3c7; text-decoration:none; }
.issue-blocker:hover, .issue-blocker:focus-visible { border-color:#f59e0b; }
.issue-blocker-row { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:start; }
.issue-blocker-row .issue-blocker { min-width:0; }
.issue-blocker-remove { min-height:44px; white-space:normal; }
.issue-blocker-manager { display:grid; gap:8px; margin-top:10px; min-width:0; }
.issue-blocker-manager :is(input,button) { min-height:44px; width:100%; max-width:100%; box-sizing:border-box; }
.issue-blocker-manager [role="listbox"] { display:grid; gap:6px; max-height:240px; overflow:auto; }
.issue-blocker-manager [role="option"] { text-align:left; overflow-wrap:anywhere; }
#manage-issue-blockers, #start-unblocked-issue { min-height:44px; width:100%; margin-top:10px; }
.today-readiness-blocker { width:100%; min-height:44px; text-align:left; background:#291b0c; }
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
@@ -468,6 +476,8 @@ textarea { resize: vertical; min-height: 120px; }
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
.issue-blocker-row { grid-template-columns:1fr; }
.issue-blocker-remove { width:100%; }
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
#cmd-palette.open { display:flex; flex-direction:column; }
+124 -1
View File
@@ -123,6 +123,8 @@
let selectedIssue = null;
let selectedIssueOffline = false;
let selectedIssueDetail = null;
let issueBlockerCandidates = [];
let issueBlockerSearchTimer = null;
let issueConversation = null;
let issueTrigger = null;
let selectedPull = null;
@@ -1332,13 +1334,42 @@
function renderPlanIssueDependencies(detail, loading = false) {
const preview = planTodayPreview.snapshot();
if (!preview.open || preview.item?.kind !== 'issue') return;
const assignedIssue = Boolean(selectedIssue && selectedIssueDetail === detail);
if (!assignedIssue && (!preview.open || preview.item?.kind !== 'issue')) return;
const panel = qs('#issue-blockers');
const list = qs('#issue-blocker-list');
const status = qs('#issue-blocker-status');
const addButton = qs('#add-plan-preview');
panel.hidden = false;
list.innerHTML = '';
qs('#manage-issue-blockers').hidden = !assignedIssue || selectedIssueOffline;
qs('#start-unblocked-issue').hidden = true;
if (!assignedIssue) qs('#issue-blocker-manager').hidden = true;
if (assignedIssue) {
const available = detail?.dependencies_available === true;
const dependencies = Array.isArray(detail?.dependencies) ? detail.dependencies : [];
list.innerHTML = dependencies.map((blocker, index) =>
'<div class="issue-blocker-row"><a class="issue-blocker" href="' + escAttr(blocker.url || '#') +
'" target="_blank" rel="noopener noreferrer"><strong>' + escapeHtml(blocker.repository + '#' + blocker.number) +
' · ' + escapeHtml(blocker.title || 'Untitled blocker') + '</strong><span class="small">State: ' +
escapeHtml(blocker.state || 'open') + '</span></a><button class="issue-blocker-remove" type="button" data-remove-blocker-index="' +
index + '">Remove blocker</button></div>'
).join('');
list.querySelectorAll('[data-remove-blocker-index]').forEach(button => {
button.addEventListener('click', () => {
const blocker = dependencies[Number(button.dataset.removeBlockerIndex)];
if (blocker && window.confirm('Remove ' + blocker.repository + '#' + blocker.number + ' as a blocker?')) {
mutateIssueBlocker(blocker, true, button);
}
});
});
status.textContent = available ? (dependencies.length ?
dependencies.length + (dependencies.length === 1 ? ' unresolved blocker.' : ' unresolved blockers.') :
'No unresolved blockers. Add one without leaving this issue.') :
'Blocker status unavailable. Reload before changing relationships.';
qs('#manage-issue-blockers').disabled = !available;
return;
}
addButton.disabled = loading;
addButton.dataset.planOverride = '';
if (loading) {
@@ -1371,6 +1402,98 @@
addButton.textContent = 'Add to Today & back';
}
async function mutateIssueBlocker(blocker, remove, button) {
if (!selectedIssue || selectedIssueOffline) return;
const item = selectedIssue;
button.disabled = true;
qs('#issue-blocker-status').textContent = remove ? 'Removing blocker…' : 'Adding blocker…';
try {
const result = remove ?
await issueController.updateBlocker(selectedIssue, blocker, true) :
await issueController.updateBlocker(selectedIssue, blocker, false);
if (selectedIssue !== item) return;
selectedIssueDetail = { ...selectedIssueDetail, ...result };
renderPlanIssueDependencies(selectedIssueDetail);
if (remove && result.dependencies.length === 0 && workSession.checkpointed(item)) {
qs('#issue-blocker-status').textContent = 'All blockers cleared. This Today item is ready.';
qs('#start-unblocked-issue').hidden = false;
}
} catch (error) {
if (selectedIssue !== item) return;
qs('#issue-blocker-status').textContent = error.message + ' The previous blocker list is unchanged; reload before retrying.';
button.disabled = false;
button.focus();
}
}
function renderIssueBlockerCandidates(items) {
issueBlockerCandidates = items;
const results = qs('#issue-blocker-results');
results.innerHTML = items.map((result, index) =>
'<button type="button" role="option" data-blocker-result="' + index + '"><strong>' +
escapeHtml(result.repository + '#' + result.number) + '</strong><span class="small">' +
escapeHtml(result.title || 'Untitled issue') + '</span></button>'
).join('');
results.querySelectorAll('[data-blocker-result]').forEach(button => {
button.addEventListener('click', () => {
const blocker = issueBlockerCandidates[Number(button.dataset.blockerResult)];
if (blocker) mutateIssueBlocker(blocker, false, button).then(() => {
if (selectedIssueDetail?.dependencies?.some(candidate =>
candidate.repository === blocker.repository && candidate.number === blocker.number
)) {
qs('#issue-blocker-manager').hidden = true;
qs('#issue-blocker-search').setAttribute('aria-expanded', 'false');
}
});
});
});
}
qs('#manage-issue-blockers').addEventListener('click', () => {
qs('#issue-blocker-manager').hidden = false;
qs('#issue-blocker-search').setAttribute('aria-expanded', 'true');
qs('#issue-blocker-search-status').textContent = 'Enter at least 2 characters.';
qs('#issue-blocker-search').focus();
});
qs('#cancel-issue-blocker').addEventListener('click', () => {
qs('#issue-blocker-manager').hidden = true;
qs('#issue-blocker-search').setAttribute('aria-expanded', 'false');
qs('#manage-issue-blockers').focus();
});
qs('#issue-blocker-search').addEventListener('keydown', event => {
if (event.key === 'Escape') qs('#cancel-issue-blocker').click();
});
qs('#issue-blocker-search').addEventListener('input', event => {
clearTimeout(issueBlockerSearchTimer);
const query = event.target.value.trim();
if (query.length < 2) {
renderIssueBlockerCandidates([]);
qs('#issue-blocker-search-status').textContent = 'Enter at least 2 characters.';
return;
}
qs('#issue-blocker-search-status').textContent = 'Searching open issues…';
issueBlockerSearchTimer = setTimeout(async () => {
try {
const payload = await api('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10');
const existing = selectedIssueDetail?.dependencies || [];
const items = (payload.items || []).filter(result =>
result.kind === 'issue' && result.state === 'open' &&
!(result.repository === selectedIssue?.repository && result.number === selectedIssue?.number) &&
!existing.some(blocker => blocker.repository === result.repository && blocker.number === result.number)
);
renderIssueBlockerCandidates(items);
qs('#issue-blocker-search-status').textContent = items.length ?
items.length + (items.length === 1 ? ' open issue found.' : ' open issues found.') : 'No eligible open issues found.';
} catch (error) {
renderIssueBlockerCandidates([]);
qs('#issue-blocker-search-status').textContent = error.message + ' Retry your search.';
}
}, 250);
});
qs('#start-unblocked-issue').addEventListener('click', () => {
if (selectedIssue) todayReadiness.run('start', workSession.items(), selectedIssue);
});
let addPlanPreviewOnReturn = false;
let addPlanPreviewOverride = false;
+10
View File
@@ -307,6 +307,16 @@
<h2 id="issue-blockers-title">Blocked by</h2>
<div class="issue-blocker-list" id="issue-blocker-list"></div>
<div id="issue-blocker-status" class="small" aria-live="polite"></div>
<button id="manage-issue-blockers" type="button" hidden>Add blocker</button>
<button id="start-unblocked-issue" type="button" hidden>Start now</button>
<div class="issue-blocker-manager" id="issue-blocker-manager" hidden>
<label for="issue-blocker-search">Search open issues</label>
<input id="issue-blocker-search" type="search" maxlength="100" autocomplete="off"
role="combobox" aria-controls="issue-blocker-results" aria-expanded="false" />
<div id="issue-blocker-results" role="listbox" aria-label="Open issue results"></div>
<div id="issue-blocker-search-status" class="small" aria-live="polite"></div>
<button id="cancel-issue-blocker" type="button">Cancel</button>
</div>
</section>
<h2>Full conversation</h2>
<div id="issue-comments"></div>
+19
View File
@@ -27,6 +27,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
let editRequest = null;
let dueDateRequest = null;
let milestoneRequest = null;
let blockerRequest = null;
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(item.number);
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
@@ -67,6 +68,24 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
headers: { Accept: 'application/json' },
});
},
updateBlocker(item, blocker, remove = false) {
if (blockerRequest) return blockerRequest;
blockerRequest = fetchJson(issuePath(item) + '/blockers', {
method: remove ? 'DELETE' : 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ repository: blocker.repository, number: blocker.number }),
}).then(result => {
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
const present = dependencies.some(candidate =>
candidate?.repository === blocker.repository && Number(candidate?.number) === Number(blocker.number)
);
if (result?.number !== item.number || result?.dependencies_available !== true || present === remove) {
throw new Error('Blocker change was not confirmed.');
}
return result;
}).finally(() => { blockerRequest = null; });
return blockerRequest;
},
loadDraft(item) {
try { return storage?.getItem(draftKey(item)) || ''; }
catch (_error) { return ''; }