From 9c187b5ae5b07178d2d1c8954d5c97a76f5845e6 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 23 Aug 2026 01:55:53 +0000 Subject: [PATCH] feat: watch issues from mobile search (Closes #1290) --- frontend/dashboard.js | 10 ++- frontend/index.html | 2 +- frontend/search-preview.js | 47 +++++++++- src/gitea_proxy.py | 33 +++++++ src/main.py | 69 ++++++++++++++ .../test_mobile_search_preview_navigation.py | 6 ++ tests/test_command_palette.py | 43 +++++++++ tests/test_global_search.py | 90 +++++++++++++++++++ 8 files changed, 297 insertions(+), 3 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index c7e24f2..4503ccf 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -5617,6 +5617,7 @@ const queueButton = qs('#queue-search-result'); const planButton = qs('#plan-search-result'); const startButton = qs('#start-search-result'); + const watchButton = qs('#watch-search-result'); const shareButton = qs('#share-search-result'); qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness' @@ -5636,6 +5637,8 @@ planButton.disabled = false; startButton.hidden = true; startButton.disabled = false; + watchButton.hidden = true; + watchButton.disabled = false; shareButton.disabled = true; renderSearchPreviewReply(state, null, searchPreview, document); @@ -5690,6 +5693,7 @@ startButton.textContent = detail.reviewable ? 'Review now' : detail.reopenable ? 'Reopen & resume' : (detail.assigned_to_me ? 'Start in Today' : 'Assign & start'); startButton.disabled = state.status === 'claiming' || state.status === 'reopening'; + renderSearchPreviewWatch(detail, state, watchButton); const shareStatus = { sharing:'Opening share options…', shared:'Search result shared.', copied:'Search result link copied.', 'share-canceled':'Share canceled.', 'share-error':'Could not share this result. Try again.', @@ -5698,13 +5702,15 @@ else if (state.status === 'reopening') status.textContent = 'Reopening…'; else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…'; else if (state.status === 'claimed') status.textContent = 'Assigned. Opening My Work…'; + else if (state.status.includes('watch')) status.textContent = searchPreviewWatchStatus(state); else if (detail.claimable) status.textContent = 'Open and unassigned.'; else if (detail.assigned_to_me) status.textContent = 'Already in My Work.'; else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.'; else status.textContent = 'Ready.'; } + const searchSubscription = searchPreviewSubscriptionOptions(fetchReviewJson); const searchPreview = createSearchPreview({ - fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }), + fetchJson:searchSubscription.preview, fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page),{ headers:{Accept:'application/json'}, }), @@ -5713,6 +5719,7 @@ '/issues/' + encodeURIComponent(detail.number) + '/' + action, { method:'PATCH', headers:{ Accept:'application/json' } } ), + watch:searchSubscription.watch, ...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto), queueReply:async (item,body,operationId) => { searchReplyAttachmentTarget = item; @@ -6078,6 +6085,7 @@ qs('#share-search-result').addEventListener('click', () => { searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {}); }); + wireSearchPreviewWatch(qs('#watch-search-result'), searchPreview, () => searchPreviewDetail); document.querySelectorAll('.share-work-route').forEach(button => { button.addEventListener('click', async () => { const status = qs('#work-route-share-status'); diff --git a/frontend/index.html b/frontend/index.html index 179e38b..bda8c10 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -957,7 +957,7 @@ - + Open in Gitea diff --git a/frontend/search-preview.js b/frontend/search-preview.js index e758719..2dccca1 100644 --- a/frontend/search-preview.js +++ b/frontend/search-preview.js @@ -32,6 +32,33 @@ }; root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') + '/comments?kind=' + encodeURIComponent(item.kind); + root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') + + '/subscription?kind=' + encodeURIComponent(item.kind); + root.searchPreviewSubscriptionOptions = fetchJson => ({ + load:async detail => { + if (!(detail.kind === 'issue' && detail.state === 'open' && detail.claimable)) return detail; + const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}}); + return {...detail, watching:result.watching === true}; + }, + watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), { + method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'}, + }), + }); + 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.', + 'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.', + })[state.status] || ''; + root.renderSearchPreviewWatch = (detail, state, button) => { + button.hidden = !(detail.kind === 'issue' && detail.state === 'open' && detail.claimable); + button.textContent = detail.watching ? 'Stop watching' : 'Watch issue'; + button.disabled = state.status === 'watching' || state.status === 'unwatching'; + }; + root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => { + const detail = getDetail(); + if (detail) preview.setWatching(detail.watching !== true).catch(() => {}); + }); root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({ storage, createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(), @@ -92,7 +119,7 @@ }; } })(typeof globalThis !== 'undefined' ? globalThis : this, function () { - return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) { + return function createSearchPreview({ fetchJson, fetchConversation, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) { if (Array.isArray(session)) { getSession = session[0]; loadMore = () => session[1].loadMore(); @@ -105,6 +132,7 @@ let shareRequest = null; let moveRequest = null; let replyRequest = null; + let watchRequest = null; let conversation = null; function sameItem(left, right) { @@ -348,6 +376,23 @@ reopen(detail) { return run('reopen', 'reopening', 'reopened', detail); }, + setWatching(watching) { + if (watchRequest) return watchRequest; + if (!current || typeof watch !== 'function') { + return Promise.reject(new Error('Watching is unavailable.')); + } + const detail = current; + 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 }); + return result; + }).catch(error => { + publish({ status:'watch-error', item:current, detail, error }); + throw error; + }).finally(() => { watchRequest = null; }); + return watchRequest; + }, }; if (navigationRoot) { navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {})); diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index aa3793d..788a2eb 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -1007,6 +1007,39 @@ async def _notification_subscription(repository: str, number: str) -> dict: return value if isinstance(value, dict) else {} +async def issue_subscription(repository: str, number: int) -> dict: + """Return the current operator's authoritative issue subscription state.""" + value = await fetch(f"repos/{repository}/issues/{number}/subscriptions/check") + if not isinstance(value, dict): + raise ValueError("Gitea subscription response was not an object") + return { + "watching": value.get("subscribed") is True and value.get("ignored") is not True + } + + +async def set_issue_subscription(repository: str, number: int, watching: bool) -> dict: + """Set and then confirm the current operator's issue subscription state.""" + user = await fetch("user") + login = user.get("login") if isinstance(user, dict) else None + if not isinstance(login, str) or not login: + raise ValueError("Gitea did not identify the current operator") + path = ( + f"/api/v1/repos/{repository}/issues/{number}/subscriptions/" + f"{quote(login, safe='')}" + ) + client = _get_client() + response = ( + await client.put(path, headers=_auth()) + if watching + else await client.delete(path, headers=_auth()) + ) + response.raise_for_status() + confirmed = await issue_subscription(repository, number) + if confirmed["watching"] is not watching: + raise ValueError("Gitea did not confirm the requested subscription state") + return confirmed + + async def notification_detail(thread_id: int) -> dict: thread = await fetch(f"notifications/threads/{thread_id}") if not isinstance(thread, dict): diff --git a/src/main.py b/src/main.py index 4bc5fb6..8dac989 100644 --- a/src/main.py +++ b/src/main.py @@ -3660,6 +3660,75 @@ async def global_search_preview( return JSONResponse(preview) +async def _search_preview_subscription_target( + owner: str, repo: str, number: int, kind: str +) -> str: + repository = f"{owner}/{repo}" + preview = await gitea_proxy.work_preview(repository, kind, number) + if ( + kind != "issue" + or preview.get("repository") != repository + or preview.get("kind") != "issue" + or preview.get("number") != number + or preview.get("state") != "open" + ): + raise HTTPException(status_code=404, detail="Watchable search result not found") + return repository + + +@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/subscription") +async def global_search_preview_subscription( + owner: str, + repo: str, + number: int = PathParam(gt=0), + kind: Literal["issue", "pull"] = Query(), +) -> JSONResponse: + try: + repository = 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, + ) + except HTTPException: + raise + except Exception: + return JSONResponse( + {"error": "Watch status is temporarily unavailable. Please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse(result) + + +@app.api_route( + "/api/v1/repos/{owner}/{repo}/issues/{number}/preview/subscription", + methods=["PUT", "DELETE"], +) +async def mutate_global_search_preview_subscription( + request: Request, + owner: str, + repo: str, + number: int = PathParam(gt=0), + kind: Literal["issue", "pull"] = Query(), +) -> JSONResponse: + watching = request.method == "PUT" + try: + repository = await _search_preview_subscription_target(owner, repo, number, kind) + result = await asyncio.wait_for( + gitea_proxy.set_issue_subscription(repository, number, watching), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, + ) + except HTTPException: + raise + except Exception: + return JSONResponse( + {"error": "Watch status was not changed. Please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse(result) + + @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/conversation") async def global_search_preview_conversation( owner: str, diff --git a/tests/e2e/test_mobile_search_preview_navigation.py b/tests/e2e/test_mobile_search_preview_navigation.py index 0d47826..16a7dfb 100644 --- a/tests/e2e/test_mobile_search_preview_navigation.py +++ b/tests/e2e/test_mobile_search_preview_navigation.py @@ -32,8 +32,14 @@ def test_rendered_mobile_search_preview_navigation_preserves_reading_space_and_d page.locator("#search-preview-conversation").evaluate("node => node.style.minHeight = '500px'") page.locator("#search-preview-reply-workspace").evaluate("node => node.style.minHeight = '500px'") page.locator("#search-preview-actions").evaluate("node => node.style.minHeight = '300px'") + page.locator("#watch-search-result").evaluate("node => { node.hidden = false; node.textContent = 'Watch issue'; }") page.add_script_tag(path=FRONTEND / "mobile-search-preview-nav.js") + watch = page.locator("#watch-search-result") + expect(watch).to_be_visible() + expect(watch).to_have_text("Watch issue") + watch_bounds = watch.bounding_box() + assert watch_bounds and watch_bounds["height"] >= 44 navigation = page.locator(".mobile-search-preview-nav") expect(navigation).to_be_visible() buttons = navigation.locator("button") diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py index 6cc06ad..17383ef 100644 --- a/tests/test_command_palette.py +++ b/tests/test_command_palette.py @@ -806,6 +806,49 @@ if (!states.some(state => state.status === 'reopened')) throw new Error('reopen subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) +def test_search_preview_watch_is_single_flight_and_keeps_visible_context_on_failure(): + script = f""" +const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))}); +(async () => {{ +let calls = 0; let rejectWatch; const states = []; +const detail = {{repository:'stackchain/api',number:42,kind:'issue',claimable:true,watching:false}}; +const preview = createSearchPreview({{ + fetchJson:async()=>detail, mutate:async()=>{{}}, + watch:()=>{{calls+=1;return new Promise((_resolve,reject)=>rejectWatch=reject);}}, + onState:state=>states.push(state), +}}); +await preview.open(detail); +const first=preview.setWatching(true); const second=preview.setWatching(true); +if (calls !== 1 || first !== second) throw new Error('watch mutation was not single-flight'); +rejectWatch(new Error('offline')); await Promise.allSettled([first,second]); +const final=states.at(-1); +process.stdout.write(JSON.stringify({{calls,status:final.status,number:final.detail.number, + watching:final.detail.watching,error:final.error.message}})); +}})().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) == { + "calls": 1, + "status": "watch-error", + "number": 42, + "watching": False, + "error": "offline", + } + + +def test_mobile_search_preview_exposes_touch_safe_watch_action(): + html = dashboard_bundle_text() + css = (FRONTEND / "dashboard.css").read_text() + dashboard = (FRONTEND / "dashboard.js").read_text() + + assert '' in html + assert "wireSearchPreviewWatch" in dashboard + assert "preview.setWatching" in SEARCH_PREVIEW.read_text() + assert ".search-preview-actions button" in css and "min-height:44px" in css + + def test_search_preview_shares_canonical_url_without_closing_the_preview(): script = f""" const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))}); diff --git a/tests/test_global_search.py b/tests/test_global_search.py index ffb4105..478799d 100644 --- a/tests/test_global_search.py +++ b/tests/test_global_search.py @@ -218,6 +218,96 @@ async def test_global_search_preview_returns_normalized_action_context(monkeypat assert response.json()["claimable"] is True +@pytest.mark.anyio +async def test_search_preview_subscription_reads_server_truth_without_caching(monkeypatch): + calls = [] + + async def preview(repository, kind, number): + calls.append(("preview", repository, kind, number)) + return {"repository": repository, "kind": kind, "number": number, + "state": "open", "claimable": True} + + async def subscription(repository, number): + calls.append(("subscription", repository, number)) + return {"watching": True} + + monkeypatch.setattr(main.gitea_proxy, "work_preview", preview) + monkeypatch.setattr(main.gitea_proxy, "issue_subscription", subscription, raising=False) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue" + ) + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.json() == {"watching": True} + assert calls == [ + ("preview", "stackchain/api", "issue", 42), + ("subscription", "stackchain/api", 42), + ] + + +@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 +): + calls = [] + + async def preview(repository, kind, number): + calls.append(("preview", repository, kind, number)) + return {"repository": repository, "kind": kind, "number": number, + "state": "open", "claimable": True} + + async def set_subscription(repository, number, desired): + calls.append(("set", repository, number, desired)) + return {"watching": desired} + + 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) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.request( + method, + "/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue", + ) + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.json() == {"watching": watching} + assert calls == [ + ("preview", "stackchain/api", "issue", 42), + ("set", "stackchain/api", 42, watching), + ] + + +@pytest.mark.anyio +async def test_issue_subscription_mutation_uses_current_login_and_requires_confirmation(): + requests = [] + + async def handler(request): + requests.append((request.method, request.url.path)) + if request.url.path.endswith("/user"): + return httpx.Response(200, json={"login": "timmy"}) + if request.url.path.endswith("/subscriptions/check"): + return httpx.Response(200, json={"subscribed": True, "ignored": False}) + return httpx.Response(201, json={}) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.set_issue_subscription("stackchain/api", 42, True) + finally: + await gitea_proxy.stop_client() + + assert result == {"watching": True} + assert requests == [ + ("GET", "/api/v1/user"), + ("PUT", "/api/v1/repos/stackchain/api/issues/42/subscriptions/timmy"), + ("GET", "/api/v1/repos/stackchain/api/issues/42/subscriptions/check"), + ] + + @pytest.mark.anyio @pytest.mark.parametrize("kind", ["issue", "pull"]) async def test_global_search_preview_conversation_returns_a_bounded_authorized_page( -- 2.43.0