From 6acc8866edc2a6d40262df15bb037c72ba1bb014 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 08:27:19 +0000 Subject: [PATCH] feat: sync completed Filed reviews across devices (Closes #880) --- README.md | 7 ++ frontend/dashboard.js | 35 +++++- frontend/my-work.js | 50 +++++++-- src/completed_filed_review_store.py | 121 +++++++++++++++++++++ src/main.py | 60 +++++++++- tests/test_completed_filed_review_store.py | 73 +++++++++++++ tests/test_my_work.py | 43 ++++++++ 7 files changed, 380 insertions(+), 9 deletions(-) create mode 100644 src/completed_filed_review_store.py create mode 100644 tests/test_completed_filed_review_store.py diff --git a/README.md b/README.md index 075b068..b1069c7 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,13 @@ device can reopen the exact Search with one tap while stale writes surface a con overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default `.stackchain-state/saved-searches.sqlite3` path. +Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged. +Acknowledgements hide the exact Gitea `updated_at` revision immediately on the current device, synchronize in +bounded batches to the confirmed account, and suppress that outcome on other signed-in devices. A later Gitea +update reopens review. Offline or failed synchronization keeps the local acknowledgement and retries on the next +healthy dashboard refresh without blocking **Acknowledge & next**. Set +`STACKCHAIN_COMPLETED_FILED_REVIEW_DB` to override the default +`.stackchain-state/completed-filed-reviews.sqlite3` path. Search previews also let operators assign an eligible issue and add it to Today without starting or replacing active work. The queue action keeps the Search query, filters, results, and scroll position available for continued diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 9cebe31..8f37b97 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -220,6 +220,7 @@ storage: localStorage, getLogin() { return planningOwnerLogin; }, }); + let completedFiledSyncFlight = null; let activeMyWork = []; let laterMyWork = []; let todayMyWork = []; @@ -330,6 +331,35 @@ return payload; } + function syncCompletedFiledReviews() { + if (!planningOwnerLogin) return Promise.resolve(false); + if (completedFiledSyncFlight) return completedFiledSyncFlight; + const pending = completedFiledReview.pending(); + const options = pending.length ? { + method:'POST', + headers:{Accept:'application/json', 'Content-Type':'application/json'}, + body:JSON.stringify({ receipts:pending }), + } : { headers:{Accept:'application/json'} }; + completedFiledSyncFlight = fetchReviewJson('api/v1/completed-filed-reviews', options) + .then(snapshot => { + const changed = completedFiledReview.adopt(snapshot); + if (lastContextSnapshot && changed) { + lastMyWork = completedFiledReview.visible(buildMyWork(lastContextSnapshot)); + refreshMyWorkView(); + } + if (pending.length) qs('#my-work-action-status').textContent = + 'Completed Filed review saved to account.'; + return true; + }) + .catch(() => { + if (pending.length) qs('#my-work-action-status').textContent = + 'Acknowledged on this device ยท sync pending.'; + return false; + }) + .finally(() => { completedFiledSyncFlight = null; }); + return completedFiledSyncFlight; + } + let commentActions = { isOwned: () => false, wire: () => {} }; function loadMentionCandidates(repository, query) { @@ -2517,6 +2547,7 @@ function paintMyWork(data) { lastMyWork = completedFiledReview.visible(buildMyWork(data)); refreshMyWorkView(); + void syncCompletedFiledReviews(); } function listDrafts() { @@ -3446,7 +3477,9 @@ refreshMyWorkView(); const target = filedFollowUpTarget(lastMyWork); qs('#my-work-action-status').textContent = 'Reviewed ' + acknowledged.key + '.' + - (target ? ' Opening the next Filed item.' : ' Filed review is complete.'); + (target ? ' Opening the next Filed item. Acknowledgement sync pending.' : + ' Filed review is complete. Acknowledgement sync pending.'); + void syncCompletedFiledReviews(); if (target) { const index = lastMyWork.indexOf(target.item); const trigger = qs('#my-work-list [data-' + target.kind + '-index="' + index + '"]'); diff --git a/frontend/my-work.js b/frontend/my-work.js index 580ec4a..48fb565 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -678,28 +678,64 @@ function createCompletedFiledReview({ const ownerKey = () => key + ':' + login(); const identity = item => String(item?.repository || '') + '#' + String(item?.number || ''); const read = () => { - if (!login()) return {}; + if (!login()) return { items:{}, pending:{} }; try { const value = JSON.parse(storage?.getItem(ownerKey()) || '{}'); - return value && value.version === 1 && value.items && typeof value.items === 'object' ? - value.items : {}; - } catch (_error) { return {}; } + return value && value.version === 1 && value.items && typeof value.items === 'object' ? { + items:value.items, + pending:value.pending && typeof value.pending === 'object' ? value.pending : {}, + } : { items:{}, pending:{} }; + } catch (_error) { return { items:{}, pending:{} }; } }; + const receipt = (item, stamp = String(item?.updated_at || '')) => ({ + repository:String(item?.repository || ''), number:item?.number, updated_at:stamp, + }); + const save = value => storage?.setItem(ownerKey(), JSON.stringify({ version:1, ...value })); return { visible(items) { - const acknowledged = read(); + const acknowledged = read().items; return (items || []).filter(item => !item?.is_completed || acknowledged[identity(item)] !== String(item.updated_at || '') ); }, + pending() { + const state = read(); + return Object.entries(state.pending).map(([itemIdentity, stamp]) => { + const boundary = itemIdentity.lastIndexOf('#'); + return receipt({ + repository:itemIdentity.slice(0, boundary), + number:Number(itemIdentity.slice(boundary + 1)), + }, stamp); + }); + }, + adopt(snapshot) { + const state = read(); + let changed = false; + (snapshot?.receipts || []).forEach(remote => { + const itemIdentity = identity(remote); + const stamp = String(remote?.updated_at || ''); + if (!remote?.repository || !Number.isInteger(remote?.number) || !stamp) return; + if (!state.items[itemIdentity] || state.items[itemIdentity] < stamp) { + state.items[itemIdentity] = stamp; + changed = true; + } + if (state.pending[itemIdentity] && state.pending[itemIdentity] <= stamp) { + delete state.pending[itemIdentity]; + } + }); + try { save(state); } catch (_error) { return false; } + return changed; + }, acknowledge(item) { const stamp = String(item?.updated_at || ''); if (!login() || !item?.is_completed || !item?.repository || !Number.isInteger(item?.number) || !stamp) { return false; } - const entries = Object.entries({ ...read(), [identity(item)]: stamp }).slice(-200); + const state = read(); + const entries = Object.entries({ ...state.items, [identity(item)]: stamp }).slice(-200); + const pending = Object.entries({ ...state.pending, [identity(item)]: stamp }).slice(-200); try { - storage?.setItem(ownerKey(), JSON.stringify({ version:1, items:Object.fromEntries(entries) })); + save({ items:Object.fromEntries(entries), pending:Object.fromEntries(pending) }); return true; } catch (_error) { return false; } }, diff --git a/src/completed_filed_review_store.py b/src/completed_filed_review_store.py new file mode 100644 index 0000000..4282e3a --- /dev/null +++ b/src/completed_filed_review_store.py @@ -0,0 +1,121 @@ +"""Durable, account-scoped completed Filed review receipts.""" + +import re +import sqlite3 +from datetime import datetime +from pathlib import Path + + +_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") + + +class CompletedFiledReviewStore: + def __init__(self, path: str | Path, *, limit: int = 200, timeout: float = 1.0): + self.path = Path(path) + self.limit = limit + self.timeout = timeout + self._initialize() + + def _initialize(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + with self._connect() as connection: + connection.execute("PRAGMA journal_mode=WAL") + connection.execute( + """ + CREATE TABLE IF NOT EXISTS completed_filed_reviews ( + login TEXT NOT NULL, + repository TEXT NOT NULL, + issue_number INTEGER NOT NULL, + updated_at TEXT NOT NULL, + touched_at INTEGER NOT NULL, + PRIMARY KEY (login, repository, issue_number) + ) + """ + ) + + def _connect(self) -> sqlite3.Connection: + return sqlite3.connect(self.path, timeout=self.timeout) + + @staticmethod + def _login(login: str) -> str: + normalized = login.strip().lower() + if not normalized: + raise ValueError("login is required") + return normalized + + @staticmethod + def _receipt(raw: dict) -> tuple[str, int, str]: + if not isinstance(raw, dict): + raise ValueError("receipt must be an object") + repository = raw.get("repository") + number = raw.get("number") + updated_at = raw.get("updated_at") + if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository): + raise ValueError("repository is invalid") + if not isinstance(number, int) or isinstance(number, bool) or number < 1: + raise ValueError("number is invalid") + if not isinstance(updated_at, str): + raise ValueError("updated_at is invalid") + try: + parsed = datetime.fromisoformat(updated_at.replace("Z", "+00:00")) + except ValueError as error: + raise ValueError("updated_at is invalid") from error + if parsed.tzinfo is None: + raise ValueError("updated_at is invalid") + return repository, number, updated_at + + @staticmethod + def _snapshot(rows) -> dict: + return {"receipts": [ + {"repository": row[0], "number": int(row[1]), "updated_at": row[2]} + for row in rows + ]} + + def get(self, login: str) -> dict: + with self._connect() as connection: + rows = connection.execute( + "SELECT repository, issue_number, updated_at FROM completed_filed_reviews " + "WHERE login = ? ORDER BY touched_at", + (self._login(login),), + ).fetchall() + return self._snapshot(rows) + + def merge(self, login: str, receipts: list[dict]) -> dict: + login = self._login(login) + if not isinstance(receipts, list) or len(receipts) > self.limit: + raise ValueError(f"receipts are limited to {self.limit}") + normalized = [self._receipt(receipt) for receipt in receipts] + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + touched = int(connection.execute( + "SELECT COALESCE(MAX(touched_at), 0) FROM completed_filed_reviews WHERE login = ?", + (login,), + ).fetchone()[0]) + for repository, number, updated_at in normalized: + current = connection.execute( + "SELECT updated_at FROM completed_filed_reviews " + "WHERE login = ? AND repository = ? AND issue_number = ?", + (login, repository, number), + ).fetchone() + if current is not None and current[0] >= updated_at: + continue + touched += 1 + connection.execute( + "INSERT INTO completed_filed_reviews " + "(login, repository, issue_number, updated_at, touched_at) VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT(login, repository, issue_number) DO UPDATE SET " + "updated_at=excluded.updated_at, touched_at=excluded.touched_at", + (login, repository, number, updated_at, touched), + ) + connection.execute( + "DELETE FROM completed_filed_reviews WHERE login = ? AND rowid NOT IN (" + "SELECT rowid FROM completed_filed_reviews WHERE login = ? " + "ORDER BY touched_at DESC LIMIT ?)", + (login, login, self.limit), + ) + rows = connection.execute( + "SELECT repository, issue_number, updated_at FROM completed_filed_reviews " + "WHERE login = ? ORDER BY touched_at", + (login,), + ).fetchall() + return self._snapshot(rows) diff --git a/src/main.py b/src/main.py index b075362..1569171 100644 --- a/src/main.py +++ b/src/main.py @@ -26,6 +26,7 @@ from starlette.datastructures import UploadFile from src import dashboard_auth, gitea_proxy, passkeys from src.available_issue_snapshot_store import AvailableIssueSnapshotStore +from src.completed_filed_review_store import CompletedFiledReviewStore from src.compression import NegotiatedGZipMiddleware from src.gitea_proxy import ( activity_events, @@ -633,6 +634,20 @@ class SavedSearchCollection(BaseModel): views: list[SavedSearchView] = Field(max_length=20) +class CompletedFiledReviewReceipt(BaseModel): + repository: str = Field( + min_length=3, + max_length=200, + pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", + ) + number: PositiveInt + updated_at: str = Field(min_length=1, max_length=100) + + +class CompletedFiledReviewBatch(BaseModel): + receipts: list[CompletedFiledReviewReceipt] = Field(max_length=200) + + class UnfiledDraftBlocker(BaseModel): repository: str = Field(min_length=3, max_length=200) number: int = Field(ge=1) @@ -1273,7 +1288,7 @@ async def require_operator_session(request: Request, call_next): async def prevent_live_api_caching(request, call_next): response = await call_next(request) path = dashboard_auth.application_path(request) - if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or ( + if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or ( path.startswith("/api/v1/repos/") and path.endswith("/review") ) or path.startswith("/api/v1/notifications") or ( @@ -2210,6 +2225,15 @@ def _saved_search_store() -> SavedSearchStore: ) +def _completed_filed_review_store() -> CompletedFiledReviewStore: + return CompletedFiledReviewStore( + os.getenv( + "STACKCHAIN_COMPLETED_FILED_REVIEW_DB", + str(_state_dir / "completed-filed-reviews.sqlite3"), + ) + ) + + def _unfiled_draft_store() -> UnfiledDraftStore: return UnfiledDraftStore( os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3")) @@ -2231,6 +2255,40 @@ async def _confirmed_login() -> str: return login.strip().lower() +@app.get("/api/v1/completed-filed-reviews") +async def get_completed_filed_reviews(response: Response): + login = await _confirmed_login() + try: + snapshot = await asyncio.to_thread(_completed_filed_review_store().get, login) + except (OSError, sqlite3.Error): + raise HTTPException( + status_code=503, + detail="Completed Filed review synchronization is unavailable", + headers={"Retry-After": "1"}, + ) + response.headers["Cache-Control"] = "no-store" + return snapshot + + +@app.post("/api/v1/completed-filed-reviews") +async def merge_completed_filed_reviews(payload: CompletedFiledReviewBatch): + login = await _confirmed_login() + try: + return await asyncio.to_thread( + _completed_filed_review_store().merge, + login, + [receipt.model_dump() for receipt in payload.receipts], + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + except (OSError, sqlite3.Error): + raise HTTPException( + status_code=503, + detail="Completed Filed review synchronization is unavailable", + headers={"Retry-After": "1"}, + ) + + @app.get("/api/v1/saved-searches") async def get_saved_searches(response: Response): login = await _confirmed_login() diff --git a/tests/test_completed_filed_review_store.py b/tests/test_completed_filed_review_store.py new file mode 100644 index 0000000..111f656 --- /dev/null +++ b/tests/test_completed_filed_review_store.py @@ -0,0 +1,73 @@ +import httpx +import pytest + +from src import main +from src.completed_filed_review_store import CompletedFiledReviewStore + + +def receipt(repository="stackchain/api", number=9, updated_at="2026-08-15T12:00:00Z"): + return {"repository": repository, "number": number, "updated_at": updated_at} + + +def test_receipts_merge_without_lost_updates_and_remain_account_scoped(tmp_path): + store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3") + + first = store.merge(" Timmy ", [receipt()]) + second = store.merge("timmy", [receipt("stackchain/web", 4)]) + + assert first == {"receipts": [receipt()]} + assert second == {"receipts": [receipt(), receipt("stackchain/web", 4)]} + assert store.get("TIMMY") == second + assert store.get("alexander") == {"receipts": []} + + +def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path): + store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3", limit=2) + newer = receipt(updated_at="2026-08-16T12:00:00Z") + + store.merge("timmy", [newer]) + assert store.merge("timmy", [receipt()]) == {"receipts": [newer]} + bounded = store.merge("timmy", [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)]) + + assert bounded == {"receipts": [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)]} + + +@pytest.mark.parametrize("candidate", [ + {"repository": "bad", "number": 1, "updated_at": "2026-08-15T12:00:00Z"}, + {"repository": "stackchain/api", "number": 0, "updated_at": "2026-08-15T12:00:00Z"}, + {"repository": "stackchain/api", "number": 1, "updated_at": "yesterday"}, +]) +def test_receipts_reject_invalid_records(tmp_path, candidate): + store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3") + with pytest.raises(ValueError): + store.merge("timmy", [candidate]) + + +@pytest.mark.anyio +async def test_completed_filed_review_api_is_authenticated_csrf_protected_and_no_store(monkeypatch, tmp_path): + monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator") + monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple") + monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy") + monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3")) + monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3")) + monkeypatch.setenv("STACKCHAIN_COMPLETED_FILED_REVIEW_DB", str(tmp_path / "completed.sqlite3")) + + async def user(): + return {"id": 1, "login": "Timmy"} + + monkeypatch.setattr(main, "current_user", user) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"}) + forbidden = await client.post("/api/v1/completed-filed-reviews", json={"receipts": [receipt()]}) + headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]} + saved = await client.post( + "/api/v1/completed-filed-reviews", json={"receipts": [receipt()]}, headers=headers + ) + fetched = await client.get("/api/v1/completed-filed-reviews") + + assert forbidden.status_code == 403 + assert saved.status_code == 200 + assert saved.json() == {"receipts": [receipt()]} + assert fetched.json() == saved.json() + assert fetched.headers["cache-control"] == "no-store" diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 20890d4..8732392 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -141,6 +141,46 @@ if (typeof create !== 'function') {{ } +def test_completed_filed_review_merges_remote_receipts_and_keeps_local_work_pending(): + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +const values = new Map(); +const storage = {{ + getItem:key => values.has(key) ? values.get(key) : null, + setItem:(key, value) => values.set(key, value), +}}; +const item = (repository, number, updated_at) => ({{ + kind:'issue', key:repository + '#' + number, repository, number, + is_filed:true, is_completed:true, updated_at, +}}); +const local = item('stackchain/api', 9, '2026-08-15T12:00:00Z'); +const remote = item('stackchain/web', 4, '2026-08-14T12:00:00Z'); +const review = buildMyWork.createCompletedFiledReview({{storage, getLogin:() => 'timmy'}}); +review.acknowledge(local); +const pendingBefore = review.pending(); +const changed = review.adopt({{receipts:[remote]}}); +const visible = review.visible([local, remote]).map(value => value.key); +const pendingAfterRemote = review.pending(); +review.adopt({{receipts:[ + {{repository:local.repository, number:local.number, updated_at:local.updated_at}}, + {{repository:remote.repository, number:remote.number, updated_at:remote.updated_at}}, +]}}); +process.stdout.write(JSON.stringify({{pendingBefore, changed, visible, pendingAfterRemote, pendingFinal:review.pending()}})); +""" + completed = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == { + "pendingBefore": [receipt := { + "repository": "stackchain/api", "number": 9, "updated_at": "2026-08-15T12:00:00Z" + }], + "changed": True, + "visible": [], + "pendingAfterRemote": [receipt], + "pendingFinal": [], + } + + @pytest.mark.anyio async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow(): markup = (Path(__file__).parents[1] / "frontend" / "index.html").read_text() @@ -155,6 +195,9 @@ async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow(): assert "filed: 'filed issues'" in source handler = source.split("qs('#acknowledge-completed-filed').addEventListener('click'", 1)[1] assert "completedFiledReview.acknowledge(selectedIssue)" in handler + assert "syncCompletedFiledReviews()" in source + assert "api/v1/completed-filed-reviews" in source + assert "sync pending" in source assert "filedFollowUpTarget(lastMyWork)" in handler assert "openRoutedWork" in handler assert ".completed-filed-actions" in css