diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index dc3407e..2f1c328 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -57,7 +57,7 @@ jobs: pip install -r requirements-e2e.txt python3 -m playwright install --with-deps chromium - name: Exercise packaged mobile work journeys - run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q + run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py -q release-candidate: runs-on: ubuntu-latest diff --git a/README.md b/README.md index a99f694..ab12b55 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,15 @@ 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. + +Confirmed **Watch issue** actions also feed the mobile **Following** queue, completing the +Search → watch → revisit flow without assigning or scheduling work. Following is a read-first, +account-scoped collection: it is encrypted at rest, revisioned, bounded to 50 canonical issues, +and synchronized across signed-in devices. Opening a row reuses Search Preview; confirmed +**Stop watching** removes it, while failed or unconfirmed Gitea mutations leave the collection +unchanged. Following counts never influence the recommended Work queue. Set +`STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`. + Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged. The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, and Filed work and opens the highest-priority non-empty review queue. Starting it saves a confirmed-account, local-day diff --git a/frontend/dashboard.css b/frontend/dashboard.css index a91791e..87c2a80 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -1083,6 +1083,16 @@ textarea { resize: vertical; min-height: 120px; } } @media (min-width:601px) { .mobile-search-preview-nav { display:none; } } .search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; } +.following-sheet { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; margin:auto 0 0 auto; padding:0; border:1px solid #2a496e; color:var(--text); background:#102641; } +.following-sheet::backdrop { background:rgba(3,9,18,.74); } +.following-panel { box-sizing:border-box; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; } +.following-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } +.following-panel h2, .following-panel p { margin:0; } +.following-panel header button, #retry-following { min-height:44px; } +.following-list { display:grid; gap:8px; min-width:0; max-height:70dvh; overflow:auto; } +.following-card { box-sizing:border-box; display:flex; align-items:center; justify-content:space-between; gap:12px; width:100%; min-width:0; min-height:52px; padding:10px 12px; text-align:left; } +.following-card span:first-child { min-width:0; display:grid; gap:3px; } +.following-card strong, .following-card small { overflow-wrap:anywhere; } .markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; } .markdown-content > :first-child { margin-top:0; } .markdown-content > :last-child { margin-bottom:0; } @@ -1367,6 +1377,7 @@ textarea { resize: vertical; min-height: 120px; } .composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); } .mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); } .mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; } + .following-sheet { width:100%; max-width:none; border:0; border-radius:18px 18px 0 0; } .mobile-first-task { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; } .mobile-first-task::backdrop { background:rgba(3,9,18,.78); } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 4503ccf..f149994 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -105,6 +105,7 @@ } let mobileQueueCounts = {}; let mobilePreparationItems = {}; + const followingQueue = attachFollowing(item => searchPreview.open(item)); const mobileDeliveryRecovery = createMobileDeliveryRecovery({ getItems: () => draftInbox.partition(lastDrafts).deliveries, getIndex: item => lastDrafts.indexOf(item), @@ -132,6 +133,7 @@ openToday: () => mobileWorkEntry.open(), openAgenda: openAgendaSession, openUpdates: openUpdateTriage, + openFollowing:followingQueue.open, openFiled: openFiledFollowUp, selectFilter: selectMobileQueue, firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'), @@ -5719,7 +5721,8 @@ '/issues/' + encodeURIComponent(detail.number) + '/' + action, { method:'PATCH', headers:{ Accept:'application/json' } } ), - watch:searchSubscription.watch, + watch:(detail,watching) => searchSubscription.watch(detail, watching).then(result => + followingQueue.load().catch(() => {}).then(() => result)), ...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto), queueReply:async (item,body,operationId) => { searchReplyAttachmentTarget = item; diff --git a/frontend/following.js b/frontend/following.js new file mode 100644 index 0000000..9171d8f --- /dev/null +++ b/frontend/following.js @@ -0,0 +1,108 @@ +(function (root, factory) { + const exports = factory(); + if (typeof module === 'object' && module.exports) { + module.exports = exports.createFollowing; + module.exports.attachFollowing = exports.attachFollowing; + } else { + root.createFollowing = exports.createFollowing; + root.attachFollowing = exports.attachFollowing; + } +})(typeof self !== 'undefined' ? self : this, function () { + function createFollowing(options) { + let generation = 0; + let snapshot = {revision:0, items:[]}; + + function publish(status, error) { + const state = {status, revision:snapshot.revision, items:[...snapshot.items]}; + if (error) state.error = error; + options.render?.(state); + if (status === 'ready') options.onCount?.(snapshot.items.length); + return state; + } + + async function load() { + const requestGeneration = ++generation; + publish('loading'); + try { + const result = await options.fetchJson('api/v1/following', {headers:{Accept:'application/json'}}); + if (requestGeneration !== generation) return snapshot; + snapshot = { + revision:Number(result?.revision) || 0, + items:Array.isArray(result?.items) ? result.items.slice(0, 50) : [], + }; + publish('ready'); + return snapshot; + } catch (error) { + if (requestGeneration === generation) publish('error', error); + throw error; + } + } + + function open(index) { + const item = snapshot.items[Number(index)]; + if (!item) return false; + Promise.resolve(options.onOpen?.({...item, kind:'issue'})).catch(() => {}); + return true; + } + + return {load, open, count:() => snapshot.items.length}; + } + + function attachFollowing(onOpen) { + const document = globalThis.document; + const query = selector => document.querySelector(selector); + const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character => + ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[character]); + const formatTime = value => new Date(value).toLocaleString(); + const fetchJson = async (url, options) => { + const response = await fetch(url, options); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.detail || payload.error || 'Following is temporarily unavailable.'); + return payload; + }; + let feature; + function render(state) { + const list = query('#following-list'); + const status = query('#following-status'); + query('#retry-following').hidden = state.status !== 'error'; + if (state.status === 'loading') return void (status.textContent = 'Loading watched issues…'); + if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.'); + status.textContent = state.items.length + ? state.items.length + (state.items.length === 1 ? ' watched issue.' : ' watched issues.') + : 'No watched issues yet. Watch one from Search to keep it here.'; + list.innerHTML = state.items.map((item, index) => + '' + + escapeHtml(item.title) + '' + escapeHtml(item.repository + ' #' + item.number + + ' · ' + item.state + ' · ' + formatTime(item.updated_at)) + + '›').join(''); + list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => { + query('#following-sheet').close(); + feature.open(Number(button.dataset.followingIndex)); + })); + } + feature = createFollowing({ + fetchJson, render, + onCount:count => { + const value = query('[data-mobile-queue-count="following"]'); + value.textContent = count; + value.closest('button').setAttribute('aria-label', 'Following, ' + count + + (count === 1 ? ' watched issue' : ' watched issues')); + }, + onOpen, + }); + query('#close-following').addEventListener('click', () => query('#following-sheet').close()); + query('#retry-following').addEventListener('click', () => feature.load().catch(() => {})); + feature.load().catch(() => {}); + return { + load:feature.load, + open() { + const sheet = query('#following-sheet'); + if (!sheet.open) sheet.showModal(); + feature.load().catch(() => {}); + return 'opened-following'; + }, + }; + } + + return {createFollowing, attachFollowing}; +}); diff --git a/frontend/index.html b/frontend/index.html index bda8c10..f99d0a3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2016,6 +2016,7 @@ DeliveryNeeds recovery0 AttentionNeeds a response0 UpdatesUnread conversations0 + FollowingIssues you watch0 FiledIssues you delegated0 LaterDeferred work0 DraftsUnfiled captures0 @@ -2025,6 +2026,15 @@ + + + Read without taking ownershipFollowingClose + + + Retry + + + @@ -2076,6 +2086,7 @@ + diff --git a/frontend/mobile-queue-launcher.js b/frontend/mobile-queue-launcher.js index 5aa1d50..caf4ed2 100644 --- a/frontend/mobile-queue-launcher.js +++ b/frontend/mobile-queue-launcher.js @@ -33,6 +33,7 @@ if (name === 'today') return options.openToday(); if (name === 'agenda') return options.openAgenda(); if (name === 'update' && options.openUpdates) return options.openUpdates(); + if (name === 'following' && options.openFollowing) return options.openFollowing(); if (name === 'filed' && options.openFiled) return options.openFiled(); options.selectFilter(name); const action = options.firstAction(name); diff --git a/frontend/search-preview.js b/frontend/search-preview.js index 2dccca1..9d89276 100644 --- a/frontend/search-preview.js +++ b/frontend/search-preview.js @@ -46,8 +46,8 @@ }); root.searchPreviewWatchStatus = state => ({ watching:'Starting watch…', unwatching:'Stopping watch…', - watched:'Watching. Future activity will appear in Updates.', - unwatched:'Stopped watching. Assignment and planning are unchanged.', + watched:'Watching · available in Following. Future activity will appear in Updates.', + unwatched:'Stopped watching · removed from Following. Assignment and planning are unchanged.', 'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.', })[state.status] || ''; root.renderSearchPreviewWatch = (detail, state, button) => { diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 337f500..78f23d9 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -130,6 +130,7 @@ const SHELL = [ BASE + 'static/commands.js', BASE + 'static/saved-searches.js', BASE + 'static/search-preview.js', + BASE + 'static/following.js', BASE + 'static/search-reply-draft-store.js', BASE + 'static/conversation-reply-draft-store.js', BASE + 'static/conversation-photo-drafts.js', diff --git a/src/following_store.py b/src/following_store.py new file mode 100644 index 0000000..799bbb9 --- /dev/null +++ b/src/following_store.py @@ -0,0 +1,154 @@ +"""Encrypted, account-scoped registry of explicitly followed Gitea issues.""" + +import re +import sqlite3 +from pathlib import Path + +from src.private_state import connect_private_sqlite +from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config + + +_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +_STATES = {"open", "closed"} + + +class FollowingStore: + def __init__( + self, + path: str | Path, + *, + limit: int = 50, + timeout: float = 1.0, + encryption_key: bytes | None = None, + ): + self.path = Path(path) + self.limit = limit + self.timeout = timeout + self._cipher = PrivateStateCipher( + encryption_key if encryption_key is not None else private_state_encryption_config(), + store="following", + ) + self._initialize() + + def _connect(self) -> sqlite3.Connection: + return connect_private_sqlite(self.path, timeout=self.timeout) + + def _initialize(self) -> None: + with self._connect() as connection: + connection.execute("PRAGMA journal_mode=WAL") + connection.execute( + """ + CREATE TABLE IF NOT EXISTS following_issues ( + login TEXT PRIMARY KEY, + revision INTEGER NOT NULL, + items TEXT NOT NULL + ) + """ + ) + + @staticmethod + def _login(login: str) -> str: + normalized = str(login).strip().lower() + if not normalized: + raise ValueError("login is required") + return normalized + + def _snapshot(self, row, login: str) -> tuple[dict, bool]: + if row is None: + return {"revision": 0, "items": []}, False + items, legacy = self._cipher.open(row[1], binding=f"items:{login}") + if not isinstance(items, list): + raise PrivateStateEncryptionError("private state could not be decrypted") + return {"revision": int(row[0]), "items": items}, legacy + + def _seal(self, login: str, items: list[dict]) -> str: + return self._cipher.seal(items, binding=f"items:{login}") + + @staticmethod + def _normalize_item(raw: dict) -> dict: + if not isinstance(raw, dict): + raise ValueError("following item must be an object") + repository = raw.get("repository") + if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository): + raise ValueError("repository is invalid") + number = raw.get("number") + if not isinstance(number, int) or isinstance(number, bool) or number < 1: + raise ValueError("number is invalid") + title = raw.get("title") + if not isinstance(title, str) or not title.strip() or len(title.strip()) > 300: + raise ValueError("title is invalid") + state = raw.get("state") + if state not in _STATES: + raise ValueError("state is invalid") + updated_at = raw.get("updated_at") + if not isinstance(updated_at, str) or not updated_at or len(updated_at) > 64: + raise ValueError("updated_at is invalid") + url = raw.get("url") + if not isinstance(url, str) or not url.startswith(("http://", "https://")) or len(url) > 2048: + raise ValueError("url is invalid") + return { + "repository": repository, + "number": number, + "title": title.strip(), + "state": state, + "updated_at": updated_at, + "url": url, + } + + @staticmethod + def _identity(item: dict) -> tuple[str, int]: + return item["repository"].lower(), item["number"] + + def get(self, login: str) -> dict: + login = self._login(login) + with self._connect() as connection: + row = connection.execute( + "SELECT revision, items FROM following_issues WHERE login = ?", (login,) + ).fetchone() + snapshot, legacy = self._snapshot(row, login) + if row is not None and legacy: + connection.execute( + "UPDATE following_issues SET items = ? WHERE login = ? AND items = ?", + (self._seal(login, snapshot["items"]), login, row[1]), + ) + return snapshot + + def set_watching(self, login: str, raw_item: dict, watching: bool) -> dict: + login = self._login(login) + item = self._normalize_item(raw_item) + if not isinstance(watching, bool): + raise ValueError("watching is invalid") + identity = self._identity(item) + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + "SELECT revision, items FROM following_issues WHERE login = ?", (login,) + ).fetchone() + current, _legacy = self._snapshot(row, login) + items = list(current["items"]) + index = next( + (position for position, candidate in enumerate(items) + if self._identity(candidate) == identity), + None, + ) + if watching: + if index is None: + if len(items) >= self.limit: + raise ValueError(f"following is limited to {self.limit} issues") + items.insert(0, item) + elif items[index] == item: + return current + else: + items.pop(index) + items.insert(0, item) + elif index is None: + return current + else: + items.pop(index) + revision = current["revision"] + 1 + connection.execute( + "INSERT INTO following_issues(login, revision, items) VALUES (?, ?, ?) " + "ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, items=excluded.items", + (login, revision, self._seal(login, items)), + ) + return {"revision": revision, "items": items} diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index e325250..54483db 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -39,7 +39,7 @@ FEATURE_SOURCES = { "static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js", ), "today-timer": ( - "static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", + "static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/following.js", "static/mobile-composer-viewport.js", "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js", "static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js", diff --git a/src/main.py b/src/main.py index 8dac989..179c82a 100644 --- a/src/main.py +++ b/src/main.py @@ -63,6 +63,7 @@ from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_en from src.push_subscription_store import build_push_subscription_store from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit from src.saved_search_store import SavedSearchConflict, SavedSearchStore +from src.following_store import FollowingStore from src.unfiled_draft_store import ( UnfiledDraftConflict, UnfiledDraftEncryptionError, @@ -2557,6 +2558,12 @@ def _saved_search_store() -> SavedSearchStore: ) +def _following_store() -> FollowingStore: + return FollowingStore( + os.getenv("STACKCHAIN_FOLLOWING_DB", str(_state_dir / "following.sqlite3")) + ) + + def _completed_filed_review_store() -> CompletedFiledReviewStore: return CompletedFiledReviewStore( os.getenv( @@ -2601,6 +2608,21 @@ async def _confirmed_login() -> str: return login.strip().lower() +@app.get("/api/v1/following") +async def get_following(response: Response): + login = await _confirmed_login() + try: + snapshot = await asyncio.to_thread(_following_store().get, login) + except (OSError, sqlite3.Error, PrivateStateEncryptionError): + raise HTTPException( + status_code=503, + detail="Following synchronization is unavailable", + headers={"Retry-After": "1"}, + ) + response.headers["Cache-Control"] = "no-store" + return snapshot + + @app.get("/api/v1/completed-filed-reviews") async def get_completed_filed_reviews(response: Response): login = await _confirmed_login() @@ -3662,7 +3684,7 @@ async def global_search_preview( async def _search_preview_subscription_target( owner: str, repo: str, number: int, kind: str -) -> str: +) -> tuple[str, dict]: repository = f"{owner}/{repo}" preview = await gitea_proxy.work_preview(repository, kind, number) if ( @@ -3673,7 +3695,7 @@ async def _search_preview_subscription_target( or preview.get("state") != "open" ): raise HTTPException(status_code=404, detail="Watchable search result not found") - return repository + return repository, preview @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/subscription") @@ -3684,7 +3706,7 @@ async def global_search_preview_subscription( kind: Literal["issue", "pull"] = Query(), ) -> JSONResponse: try: - repository = await _search_preview_subscription_target(owner, repo, number, kind) + repository, _preview = await _search_preview_subscription_target(owner, repo, number, kind) result = await asyncio.wait_for( gitea_proxy.issue_subscription(repository, number), timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS, @@ -3713,11 +3735,29 @@ async def mutate_global_search_preview_subscription( ) -> JSONResponse: watching = request.method == "PUT" try: - repository = await _search_preview_subscription_target(owner, repo, number, kind) + 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) 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, + ) except HTTPException: raise except Exception: @@ -3726,7 +3766,11 @@ async def mutate_global_search_preview_subscription( status_code=503, headers={"Retry-After": "1"}, ) - return JSONResponse(result) + return JSONResponse({ + **result, + "following_revision": following["revision"], + "following_count": len(following["items"]), + }) @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/conversation") diff --git a/tests/e2e/test_mobile_following_release.py b/tests/e2e/test_mobile_following_release.py new file mode 100644 index 0000000..d5c894e --- /dev/null +++ b/tests/e2e/test_mobile_following_release.py @@ -0,0 +1,36 @@ +import os +from pathlib import Path + +import pytest + +if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1": + pytest.skip("packaged mobile Following journey runs only in its gated CI job", allow_module_level=True) +pytest.importorskip("playwright.sync_api") +from playwright.sync_api import expect, sync_playwright + + +ROOT = Path(__file__).parents[2] +FRONTEND = ROOT / "frontend" + + +def test_following_queue_is_phone_usable_at_narrow_viewport(): + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 320, "height": 568}) + page.set_content((FRONTEND / "index.html").read_text()) + page.add_style_tag(path=FRONTEND / "dashboard.css") + + row = page.locator('[data-mobile-queue="following"]') + expect(row).to_have_count(1) + page.locator("#following-list").evaluate("""node => { + node.innerHTML = 'Quiet issue with a long mobile titlestackchain/api #42 · open · just now›'; + }""") + page.locator("#following-sheet").evaluate("node => node.showModal()") + + expect(page.locator("#following-sheet")).to_be_visible() + expect(page.locator(".following-card")).to_be_visible() + assert page.locator(".following-card").bounding_box()["height"] >= 44 + assert page.locator("#close-following").bounding_box()["height"] >= 44 + overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth") + assert overflow is False + browser.close() diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py index f51501f..d497f30 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -71,7 +71,8 @@ def test_release_promotion_waits_for_packaged_mobile_journeys(): "tests/e2e/test_mobile_tomorrow_conflict_release.py " "tests/e2e/test_mobile_week_ahead_release.py " "tests/e2e/test_mobile_today_week_reschedule_release.py " - "tests/e2e/test_mobile_wrap_up_handoff_release.py -q" + "tests/e2e/test_mobile_wrap_up_handoff_release.py " + "tests/e2e/test_mobile_following_release.py -q" ) in browser assert "needs: [lint, build-release, browser-journey]" in release diff --git a/tests/test_following_api.py b/tests/test_following_api.py new file mode 100644 index 0000000..0568ed7 --- /dev/null +++ b/tests/test_following_api.py @@ -0,0 +1,55 @@ +import httpx +import pytest + +from src import main +from src.following_store import FollowingStore + + +@pytest.mark.anyio +async def test_confirmed_watch_updates_account_following_collection(monkeypatch, tmp_path): + store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"a" * 32) + detail = { + "repository": "stackchain/api", + "kind": "issue", + "number": 42, + "title": "Make mobile review useful", + "state": "open", + "updated_at": "2026-08-23T03:00:00Z", + "url": "https://forge.example/stackchain/api/issues/42", + "claimable": True, + } + + async def preview(repository, kind, number): + assert (repository, kind, number) == ("stackchain/api", "issue", 42) + return detail + + async def set_subscription(repository, number, watching): + return {"watching": watching} + + async def user(): + return {"login": "Timmy"} + + monkeypatch.setattr(main, "_following_store", lambda: store, raising=False) + 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: + watched = await client.put( + "/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue" + ) + following = await client.get("/api/v1/following") + + assert watched.status_code == 200 + assert watched.json() == {"watching": True, "following_revision": 1, "following_count": 1} + assert following.status_code == 200 + assert following.headers["cache-control"] == "no-store" + assert following.json() == {"revision": 1, "items": [{ + "repository": "stackchain/api", + "number": 42, + "title": "Make mobile review useful", + "state": "open", + "updated_at": "2026-08-23T03:00:00Z", + "url": "https://forge.example/stackchain/api/issues/42", + }]} diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py new file mode 100644 index 0000000..b8ceaeb --- /dev/null +++ b/tests/test_following_frontend.py @@ -0,0 +1,71 @@ +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).parents[1] +MODULE = ROOT / "frontend" / "following.js" + + +def run(script: str) -> dict: + harness = f""" +const createFollowing = require({json.dumps(str(MODULE))}); +const state = {{ renders:[], counts:[], opened:[], requests:[] }}; +const feature = createFollowing({{ + fetchJson: async path => {{ + state.requests.push(path); + return {{revision:3,items:[{{repository:'stackchain/api',number:42,title:'Quiet issue',state:'open',updated_at:'2026-08-23T03:00:00Z',url:'https://forge.example/issue/42'}}]}}; + }}, + render: snapshot => state.renders.push(snapshot), + onCount: count => state.counts.push(count), + onOpen: item => state.opened.push(item), +}}); +(async () => {{ {script} }})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + completed = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True) + return json.loads(completed.stdout) + + +def test_following_loads_account_collection_and_opens_existing_preview(): + result = run(""" +await feature.load(); +feature.open(0); +process.stdout.write(JSON.stringify(state)); +""") + + assert result["requests"] == ["api/v1/following"] + assert result["counts"] == [1] + assert result["renders"][-1]["status"] == "ready" + assert result["renders"][-1]["items"][0]["title"] == "Quiet issue" + assert result["opened"] == [{ + "repository": "stackchain/api", + "number": 42, + "title": "Quiet issue", + "state": "open", + "updated_at": "2026-08-23T03:00:00Z", + "url": "https://forge.example/issue/42", + "kind": "issue", + }] + + +def test_following_opens_explicitly_but_never_becomes_work_recommendation(): + launcher = ROOT / "frontend" / "mobile-queue-launcher.js" + script = f""" +const createLauncher = require({json.dumps(str(launcher))}); +const calls = []; +const feature = createLauncher({{ + getCounts:() => ({{following:7}}), + openFollowing:() => {{ calls.push('following'); return 'opened-following'; }}, + selectFilter:name => calls.push(name), firstAction:() => null, + announce:() => {{}}, openFindWork:() => calls.push('find'), +}}); +process.stdout.write(JSON.stringify({{opened:feature.open('following'),recommended:feature.recommend(),calls}})); +""" + result = json.loads(subprocess.run( + ["node", "-e", script], text=True, capture_output=True, check=True + ).stdout) + assert result == { + "opened": "opened-following", + "recommended": {"name": "find", "count": 0, "label": "Find Work"}, + "calls": ["following"], + } diff --git a/tests/test_following_store.py b/tests/test_following_store.py new file mode 100644 index 0000000..47cf0da --- /dev/null +++ b/tests/test_following_store.py @@ -0,0 +1,30 @@ +import sqlite3 + +from src.following_store import FollowingStore + + +KEY = b"f" * 32 +ITEM = { + "repository": "stackchain/api", + "number": 42, + "title": "Make mobile review useful", + "state": "open", + "updated_at": "2026-08-23T03:00:00Z", + "url": "https://forge.example/stackchain/api/issues/42", +} + + +def test_confirmed_watch_is_account_scoped_idempotent_and_encrypted(tmp_path): + path = tmp_path / "following.sqlite3" + store = FollowingStore(path, encryption_key=KEY, limit=50) + + first = store.set_watching("Timmy", ITEM, True) + repeated = store.set_watching("timmy", ITEM, True) + + assert first == repeated == {"revision": 1, "items": [ITEM]} + assert store.get("other") == {"revision": 0, "items": []} + stored = sqlite3.connect(path).execute( + "SELECT items FROM following_issues WHERE login = ?", ("timmy",) + ).fetchone()[0] + assert ITEM["title"] not in stored + assert ITEM["repository"] not in stored diff --git a/tests/test_global_search.py b/tests/test_global_search.py index 478799d..34fc61f 100644 --- a/tests/test_global_search.py +++ b/tests/test_global_search.py @@ -251,19 +251,26 @@ async def test_search_preview_subscription_reads_server_truth_without_caching(mo @pytest.mark.anyio @pytest.mark.parametrize(("method", "watching"), [("PUT", True), ("DELETE", False)]) async def test_search_preview_subscription_mutation_revalidates_target_and_confirms_truth( - monkeypatch, method, watching + monkeypatch, tmp_path, method, watching ): calls = [] + store = main.FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"g" * 32) async def preview(repository, kind, number): calls.append(("preview", repository, kind, number)) return {"repository": repository, "kind": kind, "number": number, - "state": "open", "claimable": True} + "title": "Quiet issue", "state": "open", "claimable": True, + "updated_at": "2026-08-23T03:00:00Z", "url": "https://forge.example/issue/42"} async def set_subscription(repository, number, desired): calls.append(("set", repository, number, desired)) return {"watching": desired} + async def user(): + return {"login": "timmy"} + + monkeypatch.setattr(main, "_following_store", lambda: store) + monkeypatch.setattr(main, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "work_preview", preview) monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription, raising=False) transport = httpx.ASGITransport(app=main.app) @@ -275,7 +282,12 @@ async def test_search_preview_subscription_mutation_revalidates_target_and_confi assert response.status_code == 200 assert response.headers["cache-control"] == "no-store" - assert response.json() == {"watching": watching} + expected_count = 1 if watching else 0 + assert response.json() == { + "watching": watching, + "following_revision": expected_count, + "following_count": expected_count, + } assert calls == [ ("preview", "stackchain/api", "issue", 42), ("set", "stackchain/api", 42, watching), diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index b438b83..5c4b252 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -1333,6 +1333,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/commands.js", "/dashboard/static/saved-searches.js", "/dashboard/static/search-preview.js", + "/dashboard/static/following.js", "/dashboard/static/search-reply-draft-store.js", "/dashboard/static/conversation-reply-draft-store.js", "/dashboard/static/conversation-photo-drafts.js",
Read without taking ownership