diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js
index 38eb77d..b9ab03b 100644
--- a/frontend/create-issue-sheet.js
+++ b/frontend/create-issue-sheet.js
@@ -113,6 +113,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
let templateRequest = 0;
let duplicateRequest = 0;
let repositorySearchRequest = 0;
+ let filingMetadataRequest = 0;
let blockerSearchRequest = 0;
let duplicateState = {status: 'idle', key: '', candidates: []};
let acknowledgedDuplicateKey = '';
@@ -326,6 +327,32 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
);
}
+ async function loadFilingMetadata(repository) {
+ const selectedRepository = String(repository || '');
+ const encoded = selectedRepository.split('/').map(encodeURIComponent).join('/');
+ const request = ++filingMetadataRequest;
+ const payload = await fetchJson(
+ 'api/v1/repos/' + encoded + '/issue-filing-metadata'
+ );
+ if (request !== filingMetadataRequest) {
+ return {status:'stale', repository:selectedRepository};
+ }
+ const section = name => {
+ const value = payload?.[name];
+ const available = value?.available === true;
+ return {
+ available,
+ items:available && Array.isArray(value?.items) ? value.items : [],
+ ...(!available && typeof value?.error === 'string' ? {error:value.error} : {}),
+ };
+ };
+ return {
+ status:'ready', repository:selectedRepository,
+ labels:section('labels'), milestones:section('milestones'),
+ templates:section('templates'),
+ };
+ }
+
function applyTemplate(draft, template, availableLabels = []) {
const source = {...(draft || {})};
const capturedBody = source.templateName ? String(source.capturedBody || '') : String(source.body || '');
@@ -425,6 +452,28 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
load(repository, selectedId) {
return loadTemplateOptions(repository, elements, selectedId);
},
+ render(repository, section, selectedId = '') {
+ ++templateRequest;
+ issueTemplates = [];
+ elements.select.innerHTML = '';
+ elements.field.hidden = true;
+ if (!repository || elements.getRepository() !== repository) return;
+ if (section?.available !== true) {
+ elements.status.textContent = 'Issue types could not be loaded. Blank issue filing is still available.';
+ return;
+ }
+ issueTemplates = Array.isArray(section.items) ? section.items.slice(0, 20) : [];
+ issueTemplates.forEach(template => {
+ const option = elements.document.createElement('option');
+ option.value = template.id;
+ option.textContent = template.name;
+ elements.select.appendChild(option);
+ });
+ elements.field.hidden = issueTemplates.length === 0;
+ elements.select.value = issueTemplates.some(template => template.id === selectedId) ? selectedId : '';
+ elements.status.textContent = issueTemplates.length ?
+ 'Choose a repository guide or keep a blank issue.' : 'Blank issue selected.';
+ },
changeRepository(draft) {
const restored = restoreTemplate(draft, labels);
callbacks.setDraft(restored);
@@ -433,6 +482,92 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
};
}
+ function bindFilingMetadata(elements, templatePicker) {
+ function renderLabels(repository, section, selectedIds = []) {
+ elements.labelList.replaceChildren();
+ if (!repository) {
+ elements.labelStatus.textContent = 'Choose a repository to load labels.';
+ return;
+ }
+ if (section?.available !== true) {
+ templatePicker.setLabels([]);
+ elements.labelStatus.textContent = 'Labels could not be loaded. You can still create the issue without labels.';
+ return;
+ }
+ const priorities = new Set(['p0', 'priority-high', 'critical']);
+ const labels = (Array.isArray(section.items) ? section.items : []).slice().sort((left, right) =>
+ Number(priorities.has(String(right?.name || '').toLowerCase())) -
+ Number(priorities.has(String(left?.name || '').toLowerCase()))
+ );
+ templatePicker.setLabels(labels);
+ const selected = new Set(selectedIds.map(Number));
+ labels.forEach(label => {
+ const option = elements.document.createElement('label');
+ option.className = 'create-issue-label-option';
+ const input = elements.document.createElement('input');
+ input.type = 'checkbox';
+ input.name = 'create-issue-label';
+ input.value = String(Number(label.id));
+ input.checked = selected.has(Number(label.id));
+ const name = elements.document.createElement('span');
+ name.textContent = label.name;
+ option.append(input, name);
+ elements.labelList.appendChild(option);
+ });
+ elements.labelStatus.textContent = labels.length ?
+ 'Select labels to triage this issue.' : 'This repository has no labels.';
+ }
+
+ function renderMilestones(repository, section, selectedId = null) {
+ elements.milestoneSelect.replaceChildren();
+ const blank = elements.document.createElement('option');
+ blank.value = '';
+ blank.textContent = 'No milestone';
+ elements.milestoneSelect.appendChild(blank);
+ if (!repository) {
+ elements.milestoneStatus.textContent = 'Choose a repository to load milestones.';
+ return;
+ }
+ if (section?.available !== true) {
+ elements.milestoneStatus.textContent = 'Milestones could not be loaded. You can still create an unplanned issue.';
+ return;
+ }
+ const milestones = Array.isArray(section.items) ? section.items : [];
+ milestones.forEach(milestone => {
+ const option = elements.document.createElement('option');
+ option.value = String(Number(milestone.id));
+ option.textContent = milestone.title;
+ elements.milestoneSelect.appendChild(option);
+ });
+ if (selectedId) elements.milestoneSelect.value = String(selectedId);
+ elements.milestoneStatus.textContent = milestones.length ?
+ 'Choose the release lane for this issue.' : 'This repository has no open milestones.';
+ }
+
+ async function load(repository, selected = {}) {
+ renderLabels(repository, null, selected.labelIds || []);
+ renderMilestones(repository, null, selected.milestoneId);
+ templatePicker.render(repository, null, selected.templateId);
+ if (!repository) return;
+ elements.labelStatus.textContent = 'Loading labels…';
+ elements.milestoneStatus.textContent = 'Loading milestones…';
+ elements.templateStatus.textContent = 'Loading issue types…';
+ try {
+ const metadata = await loadFilingMetadata(repository);
+ if (metadata.status !== 'ready' || elements.getRepository() !== repository) return;
+ renderLabels(repository, metadata.labels, selected.labelIds || []);
+ renderMilestones(repository, metadata.milestones, selected.milestoneId);
+ templatePicker.render(repository, metadata.templates, selected.templateId);
+ } catch (_error) {
+ if (elements.getRepository() !== repository) return;
+ renderLabels(repository, {available:false}, selected.labelIds || []);
+ renderMilestones(repository, {available:false}, selected.milestoneId);
+ templatePicker.render(repository, {available:false}, selected.templateId);
+ }
+ }
+ return {load};
+ }
+
function loadRepositoryPage(page) {
const safePage = Math.max(1, Math.floor(Number(page) || 1));
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
@@ -555,8 +690,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
return {
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadTemplates,
- applyTemplate, setTemplateState, templateFields, restoreTemplate, loadTemplateOptions,
- selectTemplate, bindTemplatePicker, loadRepositoryPage,
+ loadFilingMetadata, applyTemplate, setTemplateState, templateFields, restoreTemplate, loadTemplateOptions,
+ selectTemplate, bindTemplatePicker, bindFilingMetadata, loadRepositoryPage,
searchRepositories, searchBlockers, findDuplicates,
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index efc9aef..0548e90 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -521,6 +521,7 @@
let issueCapture = null;
let issueOwnerPicker = null;
let issueTemplatePicker = null;
+ let issueFilingMetadata = null;
let updateFollowUp = null;
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
const unfiledCaptures = createUnfiledCaptures({
@@ -657,6 +658,15 @@
setDraft:draft=>{ qs('#create-issue-title').value=draft.title; qs('#create-issue-body').value=draft.body; },
changed:()=>{ saveIssueCaptureDraft(); scheduleIssueDuplicateCheck(); },
});
+ issueFilingMetadata = issueCapture.bindFilingMetadata({
+ document,
+ labelList:qs('#create-issue-label-list'),
+ labelStatus:qs('#create-issue-label-status'),
+ milestoneSelect:qs('#create-issue-milestone'),
+ milestoneStatus:qs('#create-issue-milestone-status'),
+ templateStatus:qs('#create-issue-template-status'),
+ getRepository:()=>qs('#create-issue-repository').value,
+ }, issueTemplatePicker);
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
}
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
@@ -3801,8 +3811,7 @@
qs('#create-issue-repository-results').hidden = true;
qs('#create-issue-repository-status').textContent = 'Selected ' + repository + '.';
issueOwnerPicker.reset(repository);
- loadIssueLabels(repository);
- loadIssueMilestones(repository);
+ loadIssueFilingMetadata(repository);
saveIssueCaptureDraft();
scheduleIssueDuplicateCheck();
updateIssueCreateActions();
@@ -3859,57 +3868,8 @@
}, 350);
}
- 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);
- if (qs('#create-issue-repository').value !== repository) return;
- issueTemplatePicker.setLabels(labels);
- const selected = new Set(selectedIds.map(Number));
- list.innerHTML = labels.map(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 loadIssueTemplates(repository, selectedId = '') {
- return issueTemplatePicker.load(repository, selectedId);
- }
-
- async function loadIssueMilestones(repository, selectedId = null) {
- const select = qs('#create-issue-milestone');
- const status = qs('#create-issue-milestone-status');
- select.innerHTML = '';
- if (!repository) {
- status.textContent = 'Choose a repository to load milestones.';
- return;
- }
- status.textContent = 'Loading milestones…';
- try {
- const milestones = await issueCapture.loadMilestones(repository);
- if (qs('#create-issue-repository').value !== repository) return;
- select.innerHTML += milestones.map(milestone =>
- ''
- ).join('');
- if (selectedId) select.value = String(selectedId);
- status.textContent = milestones.length ?
- 'Choose the release lane for this issue.' : 'This repository has no open milestones.';
- } catch (_error) {
- if (qs('#create-issue-repository').value !== repository) return;
- status.textContent = 'Milestones could not be loaded. You can still create an unplanned issue.';
- }
+ async function loadIssueFilingMetadata(repository, selected = {}) {
+ return issueFilingMetadata.load(repository, selected);
}
function clearSharedLaunchUrl() {
@@ -3964,9 +3924,7 @@
renderIssueCaptureBlockers(captureDraft.blockers || []);
qs('#create-issue-blocker-search').value = '';
qs('#create-issue-blocker-results').hidden = true;
- loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds).then(() =>
- loadIssueTemplates(qs('#create-issue-repository').value, captureDraft.templateId));
- loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
+ loadIssueFilingMetadata(qs('#create-issue-repository').value, captureDraft);
scheduleIssueDuplicateCheck();
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
qs('#create-follow-up-next').hidden = !updateFollowUp?.source();
@@ -5155,8 +5113,7 @@
qs('#create-issue-repository').addEventListener('change', event => {
issueTemplatePicker.changeRepository(currentIssueCaptureDraft());
issueOwnerPicker.reset(event.target.value);
- loadIssueLabels(event.target.value).then(() => loadIssueTemplates(event.target.value));
- loadIssueMilestones(event.target.value);
+ loadIssueFilingMetadata(event.target.value);
qs('#create-issue-repository-search').value = event.target.value;
saveIssueCaptureDraft();
scheduleIssueDuplicateCheck();
diff --git a/src/main.py b/src/main.py
index 23b7db7..a7d314f 100644
--- a/src/main.py
+++ b/src/main.py
@@ -4484,6 +4484,51 @@ async def update_assigned_issue_release_plan(
return JSONResponse(result)
+@app.get("/api/v1/repos/{owner}/{repo}/issue-filing-metadata")
+async def repository_issue_filing_metadata(owner: str, repo: str):
+ repository = f"{owner}/{repo}"
+
+ async def load_metadata():
+ if await gitea_proxy.repository_access(repository) is None:
+ raise HTTPException(status_code=404, detail="Repository not found")
+ names = ("labels", "milestones", "templates")
+ loaders = (
+ gitea_proxy.repo_labels(repository),
+ gitea_proxy.repo_milestones(repository),
+ gitea_proxy.repo_issue_templates(repository),
+ )
+ results = await asyncio.gather(*loaders, return_exceptions=True)
+ messages = {
+ "labels": "Labels could not be loaded.",
+ "milestones": "Milestones could not be loaded.",
+ "templates": "Issue types could not be loaded.",
+ }
+ payload = {}
+ for name, result in zip(names, results):
+ if isinstance(result, BaseException):
+ payload[name] = {
+ "available": False,
+ "items": [],
+ "error": messages[name],
+ }
+ else:
+ payload[name] = {"available": True, "items": result}
+ return JSONResponse(payload, headers={"Cache-Control": "no-store"})
+
+ try:
+ return await asyncio.wait_for(
+ load_metadata(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
+ )
+ except HTTPException:
+ raise
+ except Exception:
+ return JSONResponse(
+ {"error": "Issue filing details could not be loaded. Your draft is safe; please retry."},
+ status_code=503,
+ headers={"Retry-After": "1", "Cache-Control": "no-store"},
+ )
+
+
@app.get("/api/v1/repos/{owner}/{repo}/milestones")
async def repository_milestones(owner: str, repo: str):
repository = f"{owner}/{repo}"
diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py
index acf751d..9a39e3b 100644
--- a/tests/test_issue_api.py
+++ b/tests/test_issue_api.py
@@ -62,6 +62,67 @@ async def test_repository_issue_templates_route_checks_access_and_returns_blank_
assert calls == [("access", "stackchain/api"), ("templates", "stackchain/api")]
+@pytest.mark.anyio
+async def test_issue_filing_metadata_authorizes_once_and_isolates_concurrent_section_failures(monkeypatch):
+ calls = []
+ started = set()
+ all_started = asyncio.Event()
+
+ async def access(repository):
+ calls.append(("access", repository))
+ return {"full_name": repository}
+
+ async def section(name, result=None, error=None):
+ calls.append((name, "stackchain/api"))
+ started.add(name)
+ if len(started) == 3:
+ all_started.set()
+ await asyncio.wait_for(all_started.wait(), timeout=0.2)
+ if error:
+ raise error
+ return result
+
+ monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
+ monkeypatch.setattr(
+ main.gitea_proxy, "repo_labels",
+ lambda repository: section("labels", [{"id": 3, "name": "P0"}]),
+ )
+ monkeypatch.setattr(
+ main.gitea_proxy, "repo_milestones",
+ lambda repository: section("milestones", [{"id": 9, "title": "August RC"}]),
+ )
+ monkeypatch.setattr(
+ main.gitea_proxy, "repo_issue_templates",
+ lambda repository: section("templates", error=RuntimeError("templates unavailable")),
+ )
+
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ response = await client.get(
+ "/api/v1/repos/stackchain/api/issue-filing-metadata"
+ )
+
+ assert response.status_code == 200
+ assert response.json() == {
+ "labels": {"available": True, "items": [{"id": 3, "name": "P0"}]},
+ "milestones": {
+ "available": True,
+ "items": [{"id": 9, "title": "August RC"}],
+ },
+ "templates": {
+ "available": False,
+ "items": [],
+ "error": "Issue types could not be loaded.",
+ },
+ }
+ assert calls.count(("access", "stackchain/api")) == 1
+ assert set(calls[1:]) == {
+ ("labels", "stackchain/api"),
+ ("milestones", "stackchain/api"),
+ ("templates", "stackchain/api"),
+ }
+
+
@pytest.mark.anyio
async def test_owned_comment_mutations_verify_thread_and_author_before_writing():
requests = []
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index b03324c..54bb704 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -1844,7 +1844,7 @@ async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
assert 'id="create-issue-due-date" type="date"' in html
assert 'id="create-issue-milestone-status" aria-live="polite"' in html
assert '.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px;' in html
- assert 'issueCapture.loadMilestones(repository)' in html
+ assert 'issueFilingMetadata.load(repository, selected)' in html
assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html
@@ -4157,14 +4157,110 @@ async def test_mobile_issue_capture_lazily_selects_an_initial_owner():
@pytest.mark.anyio
async def test_mobile_issue_filing_exposes_repository_issue_types_without_blocking_blank_filing():
html = await dashboard()
+ source = CREATE_ISSUE_SHEET.read_text()
assert 'id="create-issue-template"' in html
assert '' in html
assert 'id="create-issue-template-status" class="small" aria-live="polite"' in html
- assert 'issueTemplatePicker.load(repository' in html
+ assert 'templatePicker.render(repository, metadata.templates' in source
assert 'issueCapture.bindTemplatePicker(' in html
assert 'id="issue-filing-review-template"' in html
+@pytest.mark.anyio
+async def test_every_mobile_repository_selection_uses_the_resilient_metadata_loader():
+ html = await dashboard()
+ source = CREATE_ISSUE_SHEET.read_text()
+
+ assert "async function loadIssueFilingMetadata(repository" in html
+ assert "issueCapture.bindFilingMetadata(" in html
+ assert "issueFilingMetadata.load(repository, selected)" in html
+ assert "const metadata = await loadFilingMetadata(repository)" in source
+ assert "renderLabels(repository, metadata.labels" in source
+ assert "renderMilestones(repository, metadata.milestones" in source
+ assert "templatePicker.render(repository, metadata.templates" in source
+ assert html.count("loadIssueFilingMetadata(") == 4
+ assert "loadIssueLabels(repository);\n loadIssueMilestones(repository);" not in html
+ assert "loadIssueLabels(event.target.value).then" not in html
+ assert "loadIssueLabels(qs('#create-issue-repository').value" not in html
+
+
+def test_issue_capture_loads_all_filing_metadata_in_one_section_isolated_request():
+ script = f"""
+const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
+const calls = [];
+const capture = createIssueCapture({{
+ storage:{{getItem:()=>null,setItem:()=>{{}},removeItem:()=>{{}}}},
+ fetchJson:url=>{{
+ calls.push(url);
+ return Promise.resolve({{
+ labels:{{available:true,items:[{{id:3,name:'P0'}}]}},
+ milestones:{{available:false,items:[],error:'Milestones could not be loaded.'}},
+ templates:{{available:true,items:[{{id:'bug',name:'Bug report'}}]}},
+ }});
+ }},
+}});
+capture.loadFilingMetadata('stackchain/api').then(result=>
+ process.stdout.write(JSON.stringify({{calls,result}})));
+"""
+ result = subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ )
+ output = json.loads(result.stdout)
+
+ assert output == {
+ "calls": ["api/v1/repos/stackchain/api/issue-filing-metadata"],
+ "result": {
+ "status": "ready",
+ "repository": "stackchain/api",
+ "labels": {"available": True, "items": [{"id": 3, "name": "P0"}]},
+ "milestones": {
+ "available": False,
+ "items": [],
+ "error": "Milestones could not be loaded.",
+ },
+ "templates": {
+ "available": True,
+ "items": [{"id": "bug", "name": "Bug report"}],
+ },
+ },
+ }
+
+
+def test_issue_capture_rejects_filing_metadata_from_a_stale_repository_request():
+ script = f"""
+const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
+const releases = {{}};
+const capture = createIssueCapture({{
+ storage:{{getItem:()=>null,setItem:()=>{{}},removeItem:()=>{{}}}},
+ fetchJson:url=>new Promise(resolve=>{{ releases[url] = resolve; }}),
+}});
+const first = capture.loadFilingMetadata('stackchain/old');
+const second = capture.loadFilingMetadata('stackchain/current');
+const payload = {{
+ labels:{{available:true,items:[]}}, milestones:{{available:true,items:[]}},
+ templates:{{available:true,items:[]}},
+}};
+releases['api/v1/repos/stackchain/current/issue-filing-metadata'](payload);
+Promise.resolve().then(()=>{{
+ releases['api/v1/repos/stackchain/old/issue-filing-metadata'](payload);
+ return Promise.all([first, second]);
+}}).then(results=>process.stdout.write(JSON.stringify(results)));
+"""
+ result = subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ )
+
+ assert json.loads(result.stdout) == [
+ {"status": "stale", "repository": "stackchain/old"},
+ {
+ "status": "ready", "repository": "stackchain/current",
+ "labels": {"available": True, "items": []},
+ "milestones": {"available": True, "items": []},
+ "templates": {"available": True, "items": []},
+ },
+ ]
+
+
@pytest.mark.anyio
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
html = await dashboard()
@@ -6451,7 +6547,7 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
assert 'createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage })' in html
assert 'lastMyWork = buildMyWork(lastContextSnapshot);' in html
assert 'openRoutedWork(created' in html
- assert 'issueCapture.loadLabels(repository)' in html
+ assert 'issueCapture.bindFilingMetadata({' in html
assert "input[name=\"create-issue-label\"]:checked" in html
assert '.create-issue-label-option' in html and 'min-height:44px' in html