From 2c8a1225e02a899028a80bed50b18db668f24128 Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 11 Aug 2026 02:02:23 +0000 Subject: [PATCH] feat: manage issue blockers in app (Closes #531) --- frontend/dashboard.css | 10 ++++ frontend/dashboard.js | 125 +++++++++++++++++++++++++++++++++++++- frontend/index.html | 10 ++++ frontend/issue-sheet.js | 19 ++++++ src/gitea_proxy.py | 65 ++++++++++++++++++++ src/main.py | 56 +++++++++++++++++ tests/test_issue_api.py | 129 ++++++++++++++++++++++++++++++++++++++++ tests/test_my_work.py | 71 ++++++++++++++++++++++ 8 files changed, 484 insertions(+), 1 deletion(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index d8cd931..fbaee25 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -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; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 3dac5f5..6ac9ffd 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -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) => + '
' + escapeHtml(blocker.repository + '#' + blocker.number) + + ' · ' + escapeHtml(blocker.title || 'Untitled blocker') + 'State: ' + + escapeHtml(blocker.state || 'open') + '
' + ).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) => + '' + ).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; diff --git a/frontend/index.html b/frontend/index.html index aa6e239..8f661ac 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -307,6 +307,16 @@

Blocked by

+ + +

Full conversation

diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js index b742e05..2a53ed3 100644 --- a/frontend/issue-sheet.js +++ b/frontend/issue-sheet.js @@ -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 ''; } diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 2d10f4d..54649f5 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -169,6 +169,10 @@ class IssueEditConflictError(ValueError): """Raised when an issue changed after the editor loaded it.""" +class IssueDependencyInvalidError(ValueError): + """Raised when a requested blocker relationship is not valid.""" + + def _auth() -> dict[str, str]: headers: dict[str, str] = {"Accept": "application/json"} if GITEA_TOKEN: @@ -1609,6 +1613,67 @@ async def issue_dependencies(repository: str, number: int, limit: int = 20) -> l return dependencies +async def mutate_assigned_issue_dependency( + repository: str, + number: int, + blocker_repository: str, + blocker_number: int, + remove: bool = False, +) -> dict: + """Add or remove one prerequisite and return the canonical open dependency set.""" + if (repository, number) == (blocker_repository, blocker_number): + raise IssueDependencyInvalidError("an issue cannot block itself") + + login, source = await _current_login_and_target( + f"repos/{repository}/issues/{number}" + ) + if ( + source.get("state") != "open" + or isinstance(source.get("pull_request"), dict) + or not _login_in_users(login, source.get("assignees")) + ): + raise IssueNotAvailableError("assigned issue not found") + + if not remove: + candidate = await fetch(f"repos/{blocker_repository}/issues/{blocker_number}") + if ( + not isinstance(candidate, dict) + or candidate.get("number") != blocker_number + or candidate.get("state") != "open" + or isinstance(candidate.get("pull_request"), dict) + ): + raise IssueDependencyInvalidError("blocker must be an accessible open issue") + current = await issue_dependencies(repository, number) + if any( + item["repository"] == blocker_repository and item["number"] == blocker_number + for item in current + ): + raise IssueDependencyInvalidError("that issue is already a blocker") + + owner, repo = blocker_repository.split("/", 1) + response = await _get_client().request( + "DELETE" if remove else "POST", + f"/api/v1/repos/{repository}/issues/{number}/dependencies", + headers=_auth(), + json={"owner": owner, "repo": repo, "index": blocker_number}, + ) + response.raise_for_status() + + confirmed = await issue_dependencies(repository, number) + present = any( + item["repository"] == blocker_repository and item["number"] == blocker_number + for item in confirmed + ) + if present == remove: + raise ValueError("Gitea did not confirm the dependency change") + return { + "repository": repository, + "number": number, + "dependencies_available": True, + "dependencies": confirmed, + } + + async def update_assigned_issue( repository: str, number: int, diff --git a/src/main.py b/src/main.py index 2388269..a3d724a 100644 --- a/src/main.py +++ b/src/main.py @@ -502,6 +502,15 @@ class IssueHandoff(BaseModel): ) +class IssueBlockerUpdate(BaseModel): + repository: str = Field( + min_length=3, + max_length=255, + pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", + ) + number: PositiveInt + + class PullReviewComment(BaseModel): path: str = Field(min_length=1, max_length=1_000) body: str = Field(min_length=1, max_length=10_000) @@ -3213,6 +3222,53 @@ async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(g ) +async def _mutate_assigned_issue_blocker( + update: IssueBlockerUpdate, + owner: str, + repo: str, + number: int, + remove: bool, +) -> JSONResponse: + try: + result = await asyncio.wait_for( + gitea_proxy.mutate_assigned_issue_dependency( + f"{owner}/{repo}", number, update.repository, update.number, remove + ), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, + ) + except gitea_proxy.IssueNotAvailableError: + raise HTTPException(status_code=404, detail="Assigned issue not found") + except gitea_proxy.IssueDependencyInvalidError as exc: + return JSONResponse({"error": str(exc)}, status_code=422) + except Exception: + return JSONResponse( + {"error": "The blocker change could not be confirmed. Reload the issue before retrying."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse(result, headers={"Cache-Control": "no-store"}) + + +@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/blockers") +async def add_assigned_issue_blocker( + update: IssueBlockerUpdate, + owner: str, + repo: str, + number: int = PathParam(gt=0), +) -> JSONResponse: + return await _mutate_assigned_issue_blocker(update, owner, repo, number, False) + + +@app.delete("/api/v1/repos/{owner}/{repo}/issues/{number}/blockers") +async def remove_assigned_issue_blocker( + update: IssueBlockerUpdate, + owner: str, + repo: str, + number: int = PathParam(gt=0), +) -> JSONResponse: + return await _mutate_assigned_issue_blocker(update, owner, repo, number, True) + + @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/content") async def update_assigned_issue_content( update: IssueContentUpdate, diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index fcdda96..84a71a5 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -1939,3 +1939,132 @@ async def test_gitea_update_issue_labels_rejects_unconfirmed_label_set(): await gitea_proxy.update_issue_labels("stackchain/api", 7, [3]) finally: await gitea_proxy.stop_client() + + +@pytest.mark.anyio +async def test_assigned_issue_blocker_route_returns_canonical_dependencies(monkeypatch): + calls = [] + + async def mutate(repository, number, blocker_repository, blocker_number, remove=False): + calls.append((repository, number, blocker_repository, blocker_number, remove)) + return { + "repository": repository, "number": number, "dependencies_available": True, + "dependencies": [] if remove else [{ + "repository": blocker_repository, "number": blocker_number, + "title": "Restore API", "state": "open", + }], + } + + monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + added = await client.post( + "/api/v1/repos/stackchain/dashboard/issues/17/blockers", + json={"repository": "stackchain/api", "number": 9}, + ) + removed = await client.request( + "DELETE", "/api/v1/repos/stackchain/dashboard/issues/17/blockers", + json={"repository": "stackchain/api", "number": 9}, + ) + + assert [added.status_code, removed.status_code] == [200, 200] + assert added.headers["cache-control"] == "no-store" + assert added.json()["dependencies"][0]["number"] == 9 + assert removed.json()["dependencies"] == [] + assert calls == [ + ("stackchain/dashboard", 17, "stackchain/api", 9, False), + ("stackchain/dashboard", 17, "stackchain/api", 9, True), + ] + + +@pytest.mark.anyio +async def test_assigned_issue_blocker_route_rejects_invalid_candidate(monkeypatch): + async def mutate(*_args, **_kwargs): + raise gitea_proxy.IssueDependencyInvalidError("blocker must be an open issue") + + monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/v1/repos/stackchain/dashboard/issues/17/blockers", + json={"repository": "stackchain/api", "number": 9}, + ) + + assert response.status_code == 422 + assert response.json() == {"error": "blocker must be an open issue"} + + +@pytest.mark.anyio +async def test_gitea_add_dependency_validates_assignment_candidate_and_confirmation(): + requests = [] + + async def handler(request): + requests.append(request) + path = request.url.path + if path == "/api/v1/user": + return httpx.Response(200, json={"login": "timmy"}) + if path == "/api/v1/repos/stackchain/dashboard/issues/17": + return httpx.Response(200, json={ + "number": 17, "state": "open", "assignees": [{"login": "timmy"}], + }) + if path == "/api/v1/repos/stackchain/api/issues/9": + return httpx.Response(200, json={"number": 9, "state": "open"}) + if request.method == "GET" and path.endswith("/dependencies"): + mutated = any(r.method == "POST" for r in requests) + return httpx.Response(200, json=[{ + "number": 9, "state": "open", "title": "Restore API", + "repository": {"full_name": "stackchain/api"}, + "html_url": "https://forge.example/stackchain/api/issues/9", + }] if mutated else []) + if request.method == "POST" and path.endswith("/dependencies"): + return httpx.Response(201, json={}) + raise AssertionError((request.method, path)) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.mutate_assigned_issue_dependency( + "stackchain/dashboard", 17, "stackchain/api", 9 + ) + finally: + await gitea_proxy.stop_client() + + mutation = next(request for request in requests if request.method == "POST") + assert mutation.url.path == "/api/v1/repos/stackchain/dashboard/issues/17/dependencies" + assert json.loads(mutation.content) == {"owner": "stackchain", "repo": "api", "index": 9} + assert result["dependencies"] == [{ + "repository": "stackchain/api", "number": 9, "title": "Restore API", + "state": "open", "url": "https://forge.example/stackchain/api/issues/9", + }] + + +@pytest.mark.anyio +@pytest.mark.parametrize("candidate,blocker_repository,blocker_number,existing", [ + ({"number": 17, "state": "open"}, "stackchain/dashboard", 17, []), + ({"number": 9, "state": "closed"}, "stackchain/api", 9, []), + ({"number": 9, "state": "open", "pull_request": {}}, "stackchain/api", 9, []), + ({"number": 9, "state": "open"}, "stackchain/api", 9, [ + {"number": 9, "state": "open", "repository": {"full_name": "stackchain/api"}}, + ]), +]) +async def test_gitea_dependency_rejects_invalid_candidates( + candidate, blocker_repository, blocker_number, existing +): + async def handler(request): + if request.url.path == "/api/v1/user": + return httpx.Response(200, json={"login": "timmy"}) + if request.url.path == "/api/v1/repos/stackchain/dashboard/issues/17": + return httpx.Response(200, json={ + "number": 17, "state": "open", "assignees": [{"login": "timmy"}], + }) + if request.url.path.endswith("/dependencies"): + return httpx.Response(200, json=existing) + return httpx.Response(200, json=candidate) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + with pytest.raises(gitea_proxy.IssueDependencyInvalidError): + await gitea_proxy.mutate_assigned_issue_dependency( + "stackchain/dashboard", 17, blocker_repository, blocker_number + ) + finally: + await gitea_proxy.stop_client() diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 72f01d5..52321e1 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -811,6 +811,77 @@ controller.loadHandoffCandidates({{repository:'stackchain/api', number:17}}) assert output["result"] == [{"login": "alex", "name": "Alexander"}] +def test_issue_blocker_mutation_is_single_flight_and_requires_canonical_confirmation(): + script = f""" +const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); +let calls = []; +let finish; +const controller = createIssueSheet({{ + storage:null, + fetchJson:(url, options) => {{ calls.push({{url, options}}); return new Promise(resolve => finish = resolve); }}, +}}); +const item = {{repository:'stackchain/dashboard', number:17}}; +const blocker = {{repository:'stackchain/api', number:9}}; +const first = controller.updateBlocker(item, blocker, false); +const duplicate = controller.updateBlocker(item, blocker, false); +finish({{repository:item.repository, number:item.number, dependencies_available:true, + dependencies:[{{...blocker,title:'Restore API',state:'open'}}]}}); +Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{ + calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})), + same:first === duplicate, result:results[0] +}}))); +""" + output = json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + assert output["same"] is True + assert output["calls"] == [{ + "url": "api/v1/repos/stackchain/dashboard/issues/17/blockers", + "method": "POST", + "body": {"repository": "stackchain/api", "number": 9}, + }] + assert output["result"]["dependencies"][0]["number"] == 9 + + +def test_issue_blocker_removal_rejects_unconfirmed_canonical_state(): + script = f""" +const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); +const controller = createIssueSheet({{ + storage:null, + fetchJson:async () => ({{dependencies_available:true, + dependencies:[{{repository:'stackchain/api',number:9,state:'open'}}]}}), +}}); +controller.updateBlocker( + {{repository:'stackchain/dashboard',number:17}}, + {{repository:'stackchain/api',number:9}}, true +).then(() => process.stdout.write('unexpected')) + .catch(error => process.stdout.write(error.message)); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + assert result.stdout == "Blocker change was not confirmed." + + +@pytest.mark.anyio +async def test_mobile_issue_sheet_manages_blockers_with_search_and_touch_safe_controls(): + html = await dashboard() + + assert 'id="manage-issue-blockers"' in html + assert 'id="issue-blocker-search" type="search"' in html + assert 'id="issue-blocker-results" role="listbox"' in html + assert 'id="cancel-issue-blocker"' in html + assert "issueController.updateBlocker(selectedIssue, blocker, false)" in html + assert "issueController.updateBlocker(selectedIssue, blocker, true)" in html + assert "api('api/v1/search?q='" in html + assert "result.kind === 'issue' && result.state === 'open'" in html + assert "renderPlanIssueDependencies(selectedIssueDetail)" in html + assert ".issue-blocker-manager :is(input,button) { min-height:44px;" in html + assert "width:100%; max-width:100%; box-sizing:border-box" in html + assert "overflow-wrap:anywhere" in html + + def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed(): script = f""" const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});