feat: triage mobile issue capture with labels (#163)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy committed 2026-08-07 03:55:51 +00:00
1 parent e5f001f2b0
commit b6390d3298
6 files changed
+297 -19

No files matched your search

+22 -3
View File
@@ -1,13 +1,17 @@
function createIssueCapture({ fetchJson, storage }) {
const storageKey = 'stackchain.issue-capture.v1';
let pending = null;
const emptyDraft = () => ({ repository: '', title: '', body: '' });
const safeLabelIds = value => Array.from(new Set(
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
)).slice(0, 20);
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
function saveDraft(draft) {
const safe = {
repository: String(draft?.repository || ''),
title: String(draft?.title || ''),
body: String(draft?.body || ''),
labelIds: safeLabelIds(draft?.labelIds),
};
try { storage.setItem(storageKey, JSON.stringify(safe)); }
catch (_error) { /* Keep the form as the in-memory fallback. */ }
@@ -21,6 +25,7 @@ function createIssueCapture({ fetchJson, storage }) {
repository: String(parsed.repository || ''),
title: String(parsed.title || ''),
body: String(parsed.body || ''),
labelIds: safeLabelIds(parsed.labelIds),
} : emptyDraft();
} catch (_error) {
return emptyDraft();
@@ -32,6 +37,18 @@ function createIssueCapture({ fetchJson, storage }) {
catch (_error) { /* Confirmed creation remains authoritative. */ }
}
function loadLabels(repository) {
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
const priorities = new Set(['p0', 'priority-high', 'critical']);
return fetchJson('api/v1/repos/' + encoded + '/labels').then(labels =>
(Array.isArray(labels) ? labels : []).slice().sort((left, right) => {
const leftPriority = priorities.has(String(left?.name || '').toLowerCase());
const rightPriority = priorities.has(String(right?.name || '').toLowerCase());
return Number(rightPriority) - Number(leftPriority);
})
);
}
function submit(draft) {
if (pending) return pending;
const saved = saveDraft(draft);
@@ -39,7 +56,9 @@ function createIssueCapture({ fetchJson, storage }) {
pending = fetchJson('api/v1/repos/' + repository + '/issues', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ title: saved.title, body: saved.body }),
body: JSON.stringify({
title: saved.title, body: saved.body, label_ids: saved.labelIds,
}),
}).then(issue => {
clearDraft();
return issue;
@@ -47,7 +66,7 @@ function createIssueCapture({ fetchJson, storage }) {
return pending;
}
return { saveDraft, loadDraft, submit };
return { saveDraft, loadDraft, loadLabels, submit };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
+46 -1
View File
@@ -138,6 +138,10 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-form { display:grid; gap:12px; }
.create-issue-form label { display:grid; gap:6px; }
.create-issue-form select { 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; }
.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 input { width:20px; height:20px; margin:0; }
.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.pull-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.pull-sheet.open { display:flex; }
@@ -332,6 +336,11 @@ textarea { resize: vertical; min-height: 120px; }
<label for="create-issue-body">Description <span class="small">Optional</span>
<textarea id="create-issue-body" maxlength="10000"></textarea>
</label>
<fieldset class="create-issue-labels" id="create-issue-labels" aria-describedby="create-issue-label-status">
<legend>Labels <span class="small">Optional</span></legend>
<div class="small" id="create-issue-label-status" aria-live="polite">Choose a repository to load labels.</div>
<div class="create-issue-label-list" id="create-issue-label-list"></div>
</fieldset>
<div class="small">The issue will be assigned to you.</div>
<div class="create-issue-actions">
<button id="submit-new-issue" type="submit">Create &amp; assign to me</button>
@@ -948,14 +957,43 @@ textarea { resize: vertical; min-height: 120px; }
if (pullTrigger?.isConnected) pullTrigger.focus();
}
function selectedIssueLabelIds() {
return Array.from(document.querySelectorAll('input[name="create-issue-label"]:checked'))
.map(input => Number(input.value)).filter(Number.isInteger);
}
function saveIssueCaptureDraft() {
issueCapture.saveDraft({
repository: qs('#create-issue-repository').value,
title: qs('#create-issue-title').value,
body: qs('#create-issue-body').value,
labelIds: selectedIssueLabelIds(),
});
}
async function loadIssueLabels(repository, selectedIds = []) {
const list = qs('#create-issue-label-list');
const status = qs('#create-issue-label-status');
list.innerHTML = '';
if (!repository) {
status.textContent = 'Choose a repository to load labels.';
return;
}
status.textContent = 'Loading labels…';
try {
const labels = await issueCapture.loadLabels(repository);
const selected = new Set(selectedIds.map(Number));
list.innerHTML = labels.map(label =>
'<label class="create-issue-label-option"><input type="checkbox" name="create-issue-label" value="' +
Number(label.id) + '"' + (selected.has(Number(label.id)) ? ' checked' : '') + '><span>' +
escapeHtml(label.name) + '</span></label>'
).join('');
status.textContent = labels.length ? 'Select labels to triage this issue.' : 'This repository has no labels.';
} catch (error) {
status.textContent = 'Labels could not be loaded. You can still create the issue without labels.';
}
}
function openCreateIssueSheet() {
const captureDraft = issueCapture.loadDraft();
const repositories = lastContextSnapshot?.repos || [];
@@ -967,6 +1005,7 @@ textarea { resize: vertical; min-height: 120px; }
}
qs('#create-issue-title').value = captureDraft.title;
qs('#create-issue-body').value = captureDraft.body;
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.';
qs('#submit-new-issue').disabled = !repositories.length;
qs('#create-issue-sheet').classList.add('open');
@@ -1256,15 +1295,21 @@ textarea { resize: vertical; min-height: 120px; }
saveIssueCaptureDraft();
closeCreateIssueSheet();
});
['#create-issue-repository', '#create-issue-title', '#create-issue-body'].forEach(selector =>
['#create-issue-title', '#create-issue-body'].forEach(selector =>
qs(selector).addEventListener('input', saveIssueCaptureDraft)
);
qs('#create-issue-repository').addEventListener('change', event => {
loadIssueLabels(event.target.value);
saveIssueCaptureDraft();
});
qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft);
qs('#create-issue-form').addEventListener('submit', async event => {
event.preventDefault();
const captureDraft = {
repository: qs('#create-issue-repository').value,
title: qs('#create-issue-title').value.trim(),
body: qs('#create-issue-body').value.trim(),
labelIds: selectedIssueLabelIds(),
};
if (!captureDraft.repository || !captureDraft.title) {
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';