feat: sync Today blockers offline (Closes #533)
All checks were successful
CI / lint (pull_request) Successful in 1m14s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-11 02:29:56 +00:00
parent ee36b8824a
commit fff7bccf08
13 changed files with 357 additions and 17 deletions

View File

@ -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,

View File

@ -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 };

View File

@ -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)
);

View File

@ -729,6 +729,7 @@
<script src="static/issue-outbox.js"></script>
<script src="static/authored-outbox.js"></script>
<script src="static/offline-issue-close.js"></script>
<script src="static/offline-issue-blocker.js"></script>
<script src="static/notification-read-outbox.js"></script>
<script src="static/offline-work.js"></script>
<script src="static/offline-today.js"></script>

View File

@ -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;

View File

@ -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',

View File

@ -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",

View File

@ -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,

View File

@ -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))});

View File

@ -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))});

View File

@ -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

View File

@ -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 '<script src="static/offline-issue-blocker.js"></script>' 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

View File

@ -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",