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) =>
+ '
'
+ ).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))});