diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js
index 05f7252..69cdd7a 100644
--- a/frontend/authored-outbox.js
+++ b/frontend/authored-outbox.js
@@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
);
const pending = new Map();
- const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close']);
+ const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker']);
function reviewFingerprint(message) {
return JSON.stringify({
@@ -91,6 +91,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
draftFingerprint: String(message.draftFingerprint || ''),
progressFingerprint: String(message.progressFingerprint || ''),
} : {}),
+ ...(message.kind === 'issue-blocker' ? {
+ blockerRepository: String(message.blockerRepository || ''),
+ blockerNumber: Number(message.blockerNumber || 0),
+ present: message.present === true,
+ } : {}),
};
items.push(item);
write(items, mirror);
@@ -163,6 +168,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
if (item.kind === 'issue-close') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close';
}
+ if (item.kind === 'issue-blocker') {
+ return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers';
+ }
if (item.kind === 'pull-review') {
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
}
@@ -212,6 +220,26 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
});
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
+ } else if (item.kind === 'issue-blocker') {
+ result = await fetchJson(endpoint(item), {
+ method: 'PATCH',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ 'Idempotency-Key': item.operationId,
+ },
+ body: JSON.stringify({
+ repository: item.blockerRepository,
+ number: item.blockerNumber,
+ present: item.present,
+ }),
+ });
+ const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
+ const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
+ Number(candidate?.number) === item.blockerNumber);
+ if (result?.number !== item.number || result?.dependencies_available !== true || present !== item.present) {
+ throw new Error('Blocker change was not confirmed.');
+ }
} else {
const body = item.kind === 'pull-review' ? {
body: item.body,
diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js
index 36e9c0a..e63d801 100644
--- a/frontend/background-issue-sync.js
+++ b/frontend/background-issue-sync.js
@@ -368,6 +368,24 @@ function createBackgroundIssueSync({
},
};
}
+ if (item.kind === 'issue-blocker') {
+ return {
+ url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers',
+ options: {
+ method: 'PATCH',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ 'Idempotency-Key': item.operationId,
+ },
+ body: JSON.stringify({
+ repository: item.blockerRepository,
+ number: item.blockerNumber,
+ present: item.present === true,
+ }),
+ },
+ };
+ }
if (item.kind === 'pull-review') {
return {
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
@@ -612,6 +630,16 @@ function createBackgroundIssueSync({
error.status = 422;
throw error;
}
+ if (item.kind === 'issue-blocker') {
+ const dependencies = Array.isArray(delivered?.dependencies) ? delivered.dependencies : [];
+ const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
+ Number(candidate?.number) === item.blockerNumber);
+ if (delivered?.number !== item.number || delivered?.dependencies_available !== true || present !== item.present) {
+ const error = new Error('Blocker change was not confirmed.');
+ error.status = 422;
+ throw error;
+ }
+ }
await completeClaim(item, delivered);
const receipt = receiptFor(item, 'confirmed', delivered);
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 6ac9ffd..6f68a83 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -395,6 +395,9 @@
backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin,
});
+ const queueOfflineIssueBlocker = createOfflineIssueBlocker({
+ enqueueDurably: message => authoredOutbox.enqueueDurably(message),
+ });
const notificationReadOutbox = createNotificationReadOutbox({
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
backgroundSync: backgroundIssueSync,
@@ -1342,7 +1345,7 @@
const addButton = qs('#add-plan-preview');
panel.hidden = false;
list.innerHTML = '';
- qs('#manage-issue-blockers').hidden = !assignedIssue || selectedIssueOffline;
+ qs('#manage-issue-blockers').hidden = !assignedIssue;
qs('#start-unblocked-issue').hidden = true;
if (!assignedIssue) qs('#issue-blocker-manager').hidden = true;
if (assignedIssue) {
@@ -1403,11 +1406,20 @@
}
async function mutateIssueBlocker(blocker, remove, button) {
- if (!selectedIssue || selectedIssueOffline) return;
+ if (!selectedIssue) return;
const item = selectedIssue;
button.disabled = true;
qs('#issue-blocker-status').textContent = remove ? 'Removing blocker…' : 'Adding blocker…';
try {
+ if (selectedIssueOffline) {
+ await queueOfflineIssueBlocker(item, blocker, !remove);
+ if (selectedIssue !== item) return;
+ button.textContent = 'Queued';
+ qs('#issue-blocker-status').textContent = remove ?
+ 'Blocker removal queued. Today remains blocked until Stackchain confirms delivery.' :
+ 'Blocker addition queued. Today readiness will update after Stackchain confirms delivery.';
+ return { queued:true };
+ }
const result = remove ?
await issueController.updateBlocker(selectedIssue, blocker, true) :
await issueController.updateBlocker(selectedIssue, blocker, false);
@@ -1437,8 +1449,8 @@
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 =>
+ if (blocker) mutateIssueBlocker(blocker, false, button).then(outcome => {
+ if (outcome?.queued || selectedIssueDetail?.dependencies?.some(candidate =>
candidate.repository === blocker.repository && candidate.number === blocker.number
)) {
qs('#issue-blocker-manager').hidden = true;
@@ -1474,10 +1486,14 @@
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 candidates = selectedIssueOffline ? workSession.items() :
+ (await api('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10')).items;
const existing = selectedIssueDetail?.dependencies || [];
- const items = (payload.items || []).filter(result =>
+ const normalizedQuery = query.toLowerCase();
+ const items = (candidates || []).filter(result =>
result.kind === 'issue' && result.state === 'open' &&
+ (!selectedIssueOffline || (String(result.repository || '') + '#' + result.number + ' ' + String(result.title || ''))
+ .toLowerCase().includes(normalizedQuery)) &&
!(result.repository === selectedIssue?.repository && result.number === selectedIssue?.number) &&
!existing.some(blocker => blocker.repository === result.repository && blocker.number === result.number)
);
diff --git a/frontend/index.html b/frontend/index.html
index 8f661ac..7ea3752 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -729,6 +729,7 @@
+
diff --git a/frontend/offline-issue-blocker.js b/frontend/offline-issue-blocker.js
new file mode 100644
index 0000000..5fc1a55
--- /dev/null
+++ b/frontend/offline-issue-blocker.js
@@ -0,0 +1,21 @@
+function createOfflineIssueBlocker({
+ enqueueDurably,
+ onQueued = () => {},
+ createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
+}) {
+ return async function queueOfflineIssueBlocker(item, blocker, present) {
+ const admission = await enqueueDurably({
+ kind: 'issue-blocker',
+ repository: String(item.repository || ''),
+ number: Number(item.number || 0),
+ blockerRepository: String(blocker.repository || ''),
+ blockerNumber: Number(blocker.number || 0),
+ present: present === true,
+ operationId: String(createOperationId()).slice(0, 128),
+ });
+ onQueued(item, blocker, present === true, admission);
+ return { admission };
+ };
+}
+
+if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueBlocker;
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 8ce435a..e0ef9e7 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -22,6 +22,7 @@ const SHELL = [
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
BASE + 'static/offline-issue-close.js',
+ BASE + 'static/offline-issue-blocker.js',
BASE + 'static/notification-read-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py
index 54649f5..e161532 100644
--- a/src/gitea_proxy.py
+++ b/src/gitea_proxy.py
@@ -1634,6 +1634,19 @@ async def mutate_assigned_issue_dependency(
):
raise IssueNotAvailableError("assigned issue not found")
+ current = await issue_dependencies(repository, number)
+ currently_present = any(
+ item["repository"] == blocker_repository and item["number"] == blocker_number
+ for item in current
+ )
+ if currently_present != remove:
+ return {
+ "repository": repository,
+ "number": number,
+ "dependencies_available": True,
+ "dependencies": current,
+ }
+
if not remove:
candidate = await fetch(f"repos/{blocker_repository}/issues/{blocker_number}")
if (
@@ -1643,13 +1656,6 @@ async def mutate_assigned_issue_dependency(
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",
diff --git a/src/main.py b/src/main.py
index a3d724a..35b33cf 100644
--- a/src/main.py
+++ b/src/main.py
@@ -511,6 +511,10 @@ class IssueBlockerUpdate(BaseModel):
number: PositiveInt
+class IssueBlockerDesiredState(IssueBlockerUpdate):
+ present: bool
+
+
class PullReviewComment(BaseModel):
path: str = Field(min_length=1, max_length=1_000)
body: str = Field(min_length=1, max_length=10_000)
@@ -3269,6 +3273,18 @@ async def remove_assigned_issue_blocker(
return await _mutate_assigned_issue_blocker(update, owner, repo, number, True)
+@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/blockers")
+async def set_assigned_issue_blocker_state(
+ update: IssueBlockerDesiredState,
+ owner: str,
+ repo: str,
+ number: int = PathParam(gt=0),
+) -> JSONResponse:
+ return await _mutate_assigned_issue_blocker(
+ update, owner, repo, number, not update.present
+ )
+
+
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/content")
async def update_assigned_issue_content(
update: IssueContentUpdate,
diff --git a/tests/test_authored_outbox.py b/tests/test_authored_outbox.py
index 85d0b9a..5c95054 100644
--- a/tests/test_authored_outbox.py
+++ b/tests/test_authored_outbox.py
@@ -77,6 +77,41 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
assert output["remaining"] == []
+def test_authored_outbox_persists_and_delivers_desired_blocker_state():
+ script = f"""
+const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
+const values = new Map(); const calls = [];
+const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
+const outbox = createAuthoredOutbox({{
+ storage, getOwnerLogin:()=> 'timmy',
+ fetchJson: async (url, options) => {{
+ calls.push({{url,method:options.method,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
+ return {{repository:'stackchain/dashboard',number:17,dependencies_available:true,dependencies:[]}};
+ }},
+}});
+const queued = outbox.enqueue({{
+ kind:'issue-blocker',repository:'stackchain/dashboard',number:17,operationId:'blocker-op',
+ blockerRepository:'stackchain/api',blockerNumber:9,present:false,
+}});
+const restored = createAuthoredOutbox({{storage}}).list()[0];
+outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{queued,restored,calls,result,remaining:outbox.list()}})));
+"""
+ output = run_node(script)
+
+ assert output["queued"]["blockerRepository"] == "stackchain/api"
+ assert output["queued"]["blockerNumber"] == 9
+ assert output["queued"]["present"] is False
+ assert output["restored"]["present"] is False
+ assert output["calls"] == [{
+ "url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
+ "method": "PATCH",
+ "key": "blocker-op",
+ "body": {"repository": "stackchain/api", "number": 9, "present": False},
+ }]
+ assert output["result"]["confirmed"][0]["dependencies"] == []
+ assert output["remaining"] == []
+
+
def test_authored_outbox_exposes_sending_then_preserves_transient_attempt_details():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py
index 2296d26..cc32789 100644
--- a/tests/test_background_issue_sync.py
+++ b/tests/test_background_issue_sync.py
@@ -64,6 +64,54 @@ const fetchJson = async (url, options = {{}}) => {{
}
+def test_closed_app_sync_delivers_desired_blocker_state():
+ script = f"""
+const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
+let item = {{
+ id:'blocker-1',operationId:'blocker-1',kind:'issue-blocker',ownerLogin:'timmy',status:'queued',
+ repository:'stackchain/dashboard',number:17,blockerRepository:'stackchain/api',blockerNumber:9,present:false,
+}};
+const state={{calls:[],completed:[]}};
+const store={{
+ claimNext:async()=>item?{{...item}}:null,
+ complete:async id=>{{state.completed.push(id);item=null;}},
+ release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
+}};
+const fetchJson=async(url,options={{}})=>{{
+ state.calls.push({{url,method:options.method,key:options.headers?.['Idempotency-Key']||'',body:options.body?JSON.parse(options.body):null}});
+ if(url==='api/v1/background-identity')return{{login:'timmy'}};
+ return{{repository:'stackchain/dashboard',number:17,dependencies_available:true,dependencies:[]}};
+}};
+(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
+"""
+ output = run_node(script)
+
+ assert output["state"]["calls"][1] == {
+ "url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
+ "method": "PATCH",
+ "key": "blocker-1",
+ "body": {"repository": "stackchain/api", "number": 9, "present": False},
+ }
+ assert output["state"]["completed"] == ["blocker-1"]
+ assert output["result"]["confirmed"][0]["dependencies"] == []
+
+
+def test_closed_app_sync_keeps_unconfirmed_blocker_change_actionable():
+ script = f"""
+const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
+let item={{id:'blocker-2',operationId:'blocker-2',kind:'issue-blocker',ownerLogin:'timmy',status:'queued',repository:'o/r',number:1,blockerRepository:'o/b',blockerNumber:2,present:false}};
+const state={{completed:[],failed:[]}};
+const store={{claimNext:async()=>item?{{...item}}:null,complete:async id=>{{state.completed.push(id);item=null;}},release:async()=>{{}},fail:async(id,message)=>{{state.failed.push([id,message]);item=null;}},countBlocked:async()=>0}};
+const fetchJson=async url=>url==='api/v1/background-identity'?{{login:'timmy'}}:{{repository:'o/r',number:1,dependencies_available:true,dependencies:[{{repository:'o/b',number:2}}]}};
+(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
+"""
+ output = run_node(script)
+
+ assert output["state"]["completed"] == []
+ assert output["state"]["failed"] == [["blocker-2", "Blocker change was not confirmed."]]
+ assert output["result"]["attention"] == 1
+
+
def test_capture_attachment_retry_resumes_after_creation_without_duplicate_issue():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py
index 84a71a5..85c69d3 100644
--- a/tests/test_issue_api.py
+++ b/tests/test_issue_api.py
@@ -1977,6 +1977,32 @@ async def test_assigned_issue_blocker_route_returns_canonical_dependencies(monke
]
+@pytest.mark.anyio
+async def test_assigned_issue_blocker_patch_converges_to_desired_state(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}],
+ }
+
+ 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.patch(
+ "/api/v1/repos/stackchain/dashboard/issues/17/blockers",
+ headers={"Idempotency-Key": "offline-blocker-1"},
+ json={"repository": "stackchain/api", "number": 9, "present": False},
+ )
+
+ assert response.status_code == 200
+ assert response.headers["cache-control"] == "no-store"
+ assert response.json()["dependencies"] == []
+ assert calls == [("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):
@@ -2037,14 +2063,49 @@ async def test_gitea_add_dependency_validates_assignment_candidate_and_confirmat
}]
+@pytest.mark.anyio
+async def test_gitea_dependency_retry_returns_canonical_state_without_duplicate_mutation():
+ mutations = []
+ dependency = {
+ "number": 9, "state": "open", "title": "Restore API",
+ "repository": {"full_name": "stackchain/api"},
+ "html_url": "https://forge.example/stackchain/api/issues/9",
+ }
+
+ async def handler(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"):
+ return httpx.Response(200, json=[dependency])
+ if path.endswith("/dependencies"):
+ mutations.append(request.method)
+ 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()
+
+ assert mutations == []
+ assert result["dependencies"][0]["number"] == 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
diff --git a/tests/test_offline_issue_blocker.py b/tests/test_offline_issue_blocker.py
new file mode 100644
index 0000000..8228ddf
--- /dev/null
+++ b/tests/test_offline_issue_blocker.py
@@ -0,0 +1,78 @@
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from tests.dashboard_bundle import dashboard
+
+
+MODULE = Path(__file__).parents[1] / "frontend" / "offline-issue-blocker.js"
+
+
+def run_node(script: str):
+ completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
+ return json.loads(completed.stdout)
+
+
+def test_offline_blocker_reports_queued_only_after_durable_admission():
+ script = f"""
+const createOfflineIssueBlocker=require({json.dumps(str(MODULE))});
+const events=[];
+const queue=createOfflineIssueBlocker({{
+ createOperationId:()=> 'blocker-op',
+ enqueueDurably:async message=>{{events.push(['admit',message]);return{{item:message,durability:'background'}};}},
+ onQueued:(item,blocker,present,admission)=>events.push(['queued',item.number,blocker.number,present,admission.durability]),
+}});
+(async()=>{{
+ const result=await queue(
+ {{repository:'stackchain/dashboard',number:17}},
+ {{repository:'stackchain/api',number:9,title:'Restore API'}},
+ false
+ );
+ process.stdout.write(JSON.stringify({{events,result}}));
+}})();
+"""
+ output = run_node(script)
+
+ assert output["events"] == [
+ ["admit", {
+ "kind": "issue-blocker", "repository": "stackchain/dashboard", "number": 17,
+ "blockerRepository": "stackchain/api", "blockerNumber": 9,
+ "present": False, "operationId": "blocker-op",
+ }],
+ ["queued", 17, 9, False, "background"],
+ ]
+ assert output["result"]["admission"]["durability"] == "background"
+
+
+def test_offline_blocker_does_not_report_queued_when_durable_admission_fails():
+ script = f"""
+const createOfflineIssueBlocker=require({json.dumps(str(MODULE))});
+let queued=false;
+const queue=createOfflineIssueBlocker({{
+ enqueueDurably:async()=>{{throw new Error('IndexedDB unavailable');}},
+ onQueued:()=>queued=true,
+}});
+queue({{repository:'o/r',number:1}},{{repository:'o/b',number:2}},true)
+ .then(()=>process.stdout.write(JSON.stringify({{queued}})))
+ .catch(error=>process.stdout.write(JSON.stringify({{queued,error:error.message}})));
+"""
+ output = run_node(script)
+
+ assert output == {"queued": False, "error": "IndexedDB unavailable"}
+
+
+@pytest.mark.anyio
+async def test_saved_issue_exposes_offline_blocker_queue_in_mobile_sheet():
+ html = await dashboard()
+ dashboard_js = (MODULE.parent / "dashboard.js").read_text()
+ service_worker = (MODULE.parent / "service-worker.js").read_text()
+
+ assert '' in html
+ assert "BASE + 'static/offline-issue-blocker.js'" in service_worker
+ assert "const queueOfflineIssueBlocker = createOfflineIssueBlocker({" in dashboard_js
+ assert "qs('#manage-issue-blockers').hidden = !assignedIssue;" in dashboard_js
+ assert "await queueOfflineIssueBlocker(item, blocker, !remove);" in dashboard_js
+ assert "Blocker removal queued. Today remains blocked until Stackchain confirms delivery." in dashboard_js
+ assert "const candidates = selectedIssueOffline ? workSession.items()" in dashboard_js
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 1307134..36613ec 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -429,6 +429,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js",
"/dashboard/static/offline-issue-close.js",
+ "/dashboard/static/offline-issue-blocker.js",
"/dashboard/static/notification-read-outbox.js",
"/dashboard/static/offline-work.js",
"/dashboard/static/offline-today.js",