diff --git a/frontend/search-preview.js b/frontend/search-preview.js index 9d89276..19ed3b7 100644 --- a/frontend/search-preview.js +++ b/frontend/search-preview.js @@ -48,6 +48,7 @@ watching:'Starting watch…', unwatching:'Stopping watch…', watched:'Watching · available in Following. Future activity will appear in Updates.', unwatched:'Stopped watching · removed from Following. Assignment and planning are unchanged.', + 'watch-partial':'Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch issue to repair.', 'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.', })[state.status] || ''; root.renderSearchPreviewWatch = (detail, state, button) => { @@ -385,7 +386,9 @@ publish({ status:watching ? 'watching' : 'unwatching', item:current, detail }); watchRequest = watch(detail, watching).then(result => { current = { ...current, watching:result?.watching === true }; - publish({ status:watching ? 'watched' : 'unwatched', item:current, detail:current, result }); + const status = result?.following_synced === false + ? 'watch-partial' : (watching ? 'watched' : 'unwatched'); + publish({ status, item:current, detail:current, result }); return result; }).catch(error => { publish({ status:'watch-error', item:current, detail, error }); diff --git a/src/following_store.py b/src/following_store.py index 799bbb9..fbb9d21 100644 --- a/src/following_store.py +++ b/src/following_store.py @@ -113,6 +113,27 @@ class FollowingStore: ) return snapshot + def preflight(self, login: str, raw_item: dict, watching: bool) -> dict: + """Validate a requested change and capacity without mutating the registry.""" + login = self._login(login) + item = self._normalize_item(raw_item) + if not isinstance(watching, bool): + raise ValueError("watching is invalid") + if not watching: + return item + identity = self._identity(item) + with self._connect() as connection: + row = connection.execute( + "SELECT revision, items FROM following_issues WHERE login = ?", (login,) + ).fetchone() + current, _legacy = self._snapshot(row, login) + if ( + len(current["items"]) >= self.limit + and not any(self._identity(candidate) == identity for candidate in current["items"]) + ): + raise ValueError(f"following is limited to {self.limit} issues") + return item + def set_watching(self, login: str, raw_item: dict, watching: bool) -> dict: login = self._login(login) item = self._normalize_item(raw_item) diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 788a2eb..1b5ec6e 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -608,6 +608,8 @@ async def work_preview(repository: str, kind: str, number: int) -> dict: "title": issue.get("title", "") if isinstance(issue.get("title"), str) else "", "body": issue.get("body", "") if isinstance(issue.get("body"), str) else "", "state": state, + "updated_at": issue.get("updated_at", "") + if isinstance(issue.get("updated_at"), str) else "", "author": author.get("login", "") if isinstance(author.get("login"), str) else "", "labels": [ label["name"] for label in labels diff --git a/src/main.py b/src/main.py index 179c82a..9f45ed3 100644 --- a/src/main.py +++ b/src/main.py @@ -3738,26 +3738,43 @@ async def mutate_global_search_preview_subscription( repository, preview = await _search_preview_subscription_target(owner, repo, number, kind) login = await _confirmed_login() store = _following_store() - await asyncio.to_thread(store.get, login) + following_item = { + "repository": repository, + "number": number, + "title": preview.get("title", ""), + "state": preview.get("state", ""), + "updated_at": preview.get("updated_at", ""), + "url": preview.get("url", ""), + } + await asyncio.to_thread(store.preflight, login, following_item, watching) result = await asyncio.wait_for( gitea_proxy.set_issue_subscription(repository, number, watching), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) if result.get("watching") is not watching: raise RuntimeError("Gitea did not confirm subscription state") - following = await asyncio.to_thread( - store.set_watching, - login, - { - "repository": repository, - "number": number, - "title": preview.get("title", ""), - "state": preview.get("state", ""), - "updated_at": preview.get("updated_at", ""), - "url": preview.get("url", ""), - }, - watching, - ) + try: + following = await asyncio.to_thread( + store.set_watching, + login, + following_item, + watching, + ) + except Exception: + try: + compensated = await asyncio.wait_for( + gitea_proxy.set_issue_subscription(repository, number, not watching), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, + ) + except Exception: + compensated = None + if not isinstance(compensated, dict) or compensated.get("watching") is not (not watching): + return JSONResponse({ + **result, + "following_synced": False, + "error": "Watching in Gitea, but Following could not sync. Retry this action.", + }) + raise except HTTPException: raise except Exception: diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py index 17383ef..2e844a8 100644 --- a/tests/test_command_palette.py +++ b/tests/test_command_palette.py @@ -838,6 +838,37 @@ process.stdout.write(JSON.stringify({{calls,status:final.status,number:final.det } +def test_search_preview_preserves_authoritative_watch_and_offers_following_repair(): + script = f""" +const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))}); +(async () => {{ +const states = []; +const detail = {{repository:'stackchain/api',number:42,kind:'issue',state:'open',claimable:true,watching:false}}; +const preview = createSearchPreview({{ + fetchJson:async()=>detail, + watch:async()=>({{watching:true,following_synced:false,error:'Watching in Gitea, but Following could not sync. Retry this action.'}}), + onState:state=>states.push(state), +}}); +await preview.open(detail); +await preview.setWatching(true); +const final=states.at(-1); +process.stdout.write(JSON.stringify({{ + status:final.status, + watching:final.detail.watching, + message:globalThis.searchPreviewWatchStatus(final), +}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "status": "watch-partial", + "watching": True, + "message": "Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch issue to repair.", + } + + def test_mobile_search_preview_exposes_touch_safe_watch_action(): html = dashboard_bundle_text() css = (FRONTEND / "dashboard.css").read_text() diff --git a/tests/test_following_api.py b/tests/test_following_api.py index 0568ed7..8cf1596 100644 --- a/tests/test_following_api.py +++ b/tests/test_following_api.py @@ -53,3 +53,125 @@ async def test_confirmed_watch_updates_account_following_collection(monkeypatch, "updated_at": "2026-08-23T03:00:00Z", "url": "https://forge.example/stackchain/api/issues/42", }]} + + +@pytest.mark.anyio +async def test_full_following_collection_rejects_watch_before_gitea_mutation(monkeypatch, tmp_path): + store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"b" * 32, limit=1) + store.set_watching("timmy", { + "repository": "stackchain/api", "number": 7, "title": "Already followed", + "state": "open", "updated_at": "2026-08-23T02:00:00Z", + "url": "https://forge.example/stackchain/api/issues/7", + }, True) + calls = [] + + async def preview(repository, kind, number): + return { + "repository": repository, "kind": kind, "number": number, + "title": "Another issue", "state": "open", + "updated_at": "2026-08-23T03:00:00Z", + "url": "https://forge.example/stackchain/api/issues/42", "claimable": True, + } + + async def set_subscription(*args): + calls.append(args) + return {"watching": True} + + async def user(): + return {"login": "timmy"} + + monkeypatch.setattr(main, "_following_store", lambda: store) + monkeypatch.setattr(main.gitea_proxy, "work_preview", preview) + monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription) + monkeypatch.setattr(main, "current_user", user) + + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.put( + "/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue" + ) + + assert response.status_code == 503 + assert calls == [] + assert store.get("timmy")["items"][0]["number"] == 7 + + +@pytest.mark.anyio +async def test_following_write_failure_compensates_confirmed_gitea_watch(monkeypatch, tmp_path): + store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"c" * 32) + calls = [] + + async def preview(repository, kind, number): + return { + "repository": repository, "kind": kind, "number": number, + "title": "Recoverable watch", "state": "open", + "updated_at": "2026-08-23T03:00:00Z", + "url": "https://forge.example/stackchain/api/issues/42", "claimable": True, + } + + async def set_subscription(repository, number, watching): + calls.append(watching) + return {"watching": watching} + + async def user(): + return {"login": "timmy"} + + def fail_write(*args): + raise OSError("disk unavailable") + + monkeypatch.setattr(main, "_following_store", lambda: store) + monkeypatch.setattr(main.gitea_proxy, "work_preview", preview) + monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription) + monkeypatch.setattr(main, "current_user", user) + monkeypatch.setattr(store, "set_watching", fail_write) + + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.put( + "/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue" + ) + + assert response.status_code == 503 + assert response.json() == {"error": "Watch status was not changed. Please retry."} + assert calls == [True, False] + + +@pytest.mark.anyio +async def test_uncompensated_following_write_reports_authoritative_partial_outcome(monkeypatch, tmp_path): + store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"d" * 32) + calls = [] + + async def preview(repository, kind, number): + return { + "repository": repository, "kind": kind, "number": number, + "title": "Split watch", "state": "open", + "updated_at": "2026-08-23T03:00:00Z", + "url": "https://forge.example/stackchain/api/issues/42", "claimable": True, + } + + async def set_subscription(repository, number, watching): + calls.append(watching) + return {"watching": True} + + async def user(): + return {"login": "timmy"} + + monkeypatch.setattr(main, "_following_store", lambda: store) + monkeypatch.setattr(main.gitea_proxy, "work_preview", preview) + monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription) + monkeypatch.setattr(main, "current_user", user) + monkeypatch.setattr(store, "set_watching", lambda *args: (_ for _ in ()).throw(OSError())) + + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.put( + "/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue" + ) + + assert response.status_code == 200 + assert response.json() == { + "watching": True, + "following_synced": False, + "error": "Watching in Gitea, but Following could not sync. Retry this action.", + } + assert calls == [True, False] diff --git a/tests/test_global_search.py b/tests/test_global_search.py index 34fc61f..9b5d243 100644 --- a/tests/test_global_search.py +++ b/tests/test_global_search.py @@ -784,6 +784,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i "title": "Repair queue", "body": "Keep mobile operators moving.", "state": "open", + "updated_at": "2026-08-23T03:00:00Z", "html_url": "http://127.0.0.1:3000/stackchain/api/issues/42", "user": {"login": "alex"}, "labels": [{"name": "P1"}, None], @@ -803,6 +804,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i "title": "Repair queue", "body": "Keep mobile operators moving.", "state": "open", + "updated_at": "2026-08-23T03:00:00Z", "author": "alex", "labels": ["P1"], "assignees": [],