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