From 8dc388016700fd28a6ba62f75d92acdf0c1a5f82 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 13 Aug 2026 15:55:02 +0000 Subject: [PATCH] feat: mute future updates during triage (Closes #751) --- frontend/dashboard.css | 1 + frontend/dashboard.js | 36 +++++++++++++++++++++++++++++ frontend/index.html | 1 + src/gitea_proxy.py | 37 +++++++++++++++++++++++++++--- src/main.py | 37 ++++++++++++++++++++++++++++++ tests/test_gitea_notifications.py | 38 +++++++++++++++++++++++++++++++ tests/test_my_work.py | 15 ++++++++++++ tests/test_notification_read.py | 28 +++++++++++++++++++++++ 8 files changed, 190 insertions(+), 3 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 70ac167..a1b8e37 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -401,6 +401,7 @@ textarea { resize: vertical; min-height: 120px; } .update-ownership-actions button { min-width:0; width:100%; } .update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; } .update-sheet-actions #acknowledge-update-next { min-height:44px; width:100%; } +.update-sheet-actions #mute-update-next { min-height:44px; width:100%; } .update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; } .update-retry { min-height:44px; width:100%; margin-top:10px; } .issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 068d193..0a06bed 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -719,6 +719,18 @@ return payload; } + async function muteNotification(notificationId) { + const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + + '/mute', { method: 'POST', headers: { Accept: 'application/json' } }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const error = new Error(payload.error || 'Muting future updates failed.'); + error.muted = payload.muted === true; + throw error; + } + return payload; + } + async function markNotificationsRead(ids) { const response = await fetch('api/v1/notifications/read', { method: 'PATCH', @@ -927,6 +939,7 @@ qs('#send-update-reply').disabled = false; qs('#send-update-reply-read-next').disabled = false; qs('#acknowledge-update-next').hidden = true; + qs('#mute-update-next').hidden = true; qs('#update-ownership-action').hidden = true; qs('#update-ownership-start').hidden = true; qs('#create-update-follow-up').hidden = true; @@ -942,6 +955,7 @@ qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.'); qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#'; qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported; + qs('#mute-update-next').hidden = !detail.mute_supported; qs('#create-update-follow-up').hidden = !['Issue', 'Pull'].includes(detail.subject_type); if (offlineWorkMode) { setOfflineUpdateControls(true); @@ -2218,6 +2232,7 @@ qs('#mark-update-read-next').disabled = false; qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next'; qs('#acknowledge-update-next').disabled = offline; + qs('#mute-update-next').disabled = offline; qs('#update-ownership-action').disabled = offline; qs('#update-ownership-start').disabled = offline; qs('#load-older-update-comments').disabled = offline; @@ -5593,6 +5608,27 @@ button.disabled = offlineWorkMode; } }); + qs('#mute-update-next').addEventListener('click', async () => { + if (!selectedUpdate || offlineWorkMode) return; + const button = qs('#mute-update-next'); + const item = selectedUpdate; + button.disabled = true; + qs('#update-sheet-status').textContent = 'Muting future updates…'; + try { + await muteNotification(item.notification_id); + const result = await notificationReader.acceptReadAndNext(lastMyWork, item); + if (result && updateTriage.active()) updateTriage.acceptCompleted(); + } catch (error) { + qs('#update-sheet-status').textContent = error.muted ? + 'Future updates are muted; current item is still unread. Retry mark read & next.' : error.message; + if (error.muted) { + button.hidden = true; + qs('#mark-update-read-next').focus(); + } + } finally { + button.disabled = offlineWorkMode; + } + }); qs('#undo-notification').addEventListener('click', async () => { const button = qs('#undo-notification'); button.disabled = true; diff --git a/frontend/index.html b/frontend/index.html index 7bc9300..94f7c8f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -794,6 +794,7 @@ + Open in Gitea diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 640b2ff..6c42ff9 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -4,7 +4,7 @@ import re import shlex from contextlib import asynccontextmanager from typing import Any -from urllib.parse import urljoin, urlsplit +from urllib.parse import quote, urljoin, urlsplit import httpx @@ -916,6 +916,14 @@ def _gitea_api_path(value: Any) -> str: return parsed.path[len(prefix):] + (("?" + parsed.query) if parsed.query else "") +async def _notification_subscription(repository: str, number: str) -> dict: + try: + value = await fetch(f"repos/{repository}/issues/{number}/subscriptions/check") + except Exception: + return {} + return value if isinstance(value, dict) else {} + + async def notification_detail(thread_id: int) -> dict: thread = await fetch(f"notifications/threads/{thread_id}") if not isinstance(thread, dict): @@ -940,18 +948,20 @@ async def notification_detail(thread_id: int) -> dict: ) if supported_conversation: assert conversation_match is not None - subject_detail, comment, conversation = await asyncio.gather( + subject_detail, comment, conversation, subscription = await asyncio.gather( fetch(subject_path), fetch(comment_path) if comment_path else asyncio.sleep(0, result={}), issue_conversation_page( conversation_match.group(1), int(conversation_match.group(3)) ), + _notification_subscription(conversation_match.group(1), conversation_match.group(3)), ) else: - subject_detail, comment, conversation = await asyncio.gather( + subject_detail, comment, conversation, subscription = await asyncio.gather( fetch(subject_path) if subject_path else asyncio.sleep(0, result={}), fetch(comment_path) if comment_path else asyncio.sleep(0, result={}), asyncio.sleep(0, result={"comments": [], "page": 1, "older_page": None, "total": 0}), + asyncio.sleep(0, result={}), ) subject_detail = subject_detail if isinstance(subject_detail, dict) else {} comment = comment if isinstance(comment, dict) else {} @@ -1012,6 +1022,12 @@ async def notification_detail(thread_id: int) -> dict: and comment_match and comment_match.group(1) == repository_name ), + "mute_supported": bool( + supported_conversation + and isinstance(subscription, dict) + and subscription.get("subscribed") is True + and subscription.get("ignored") is not True + ), "conversation": conversation, } @@ -1058,6 +1074,21 @@ async def notification_conversation_target(thread_id: int) -> tuple[str, int]: return match.group(1), int(match.group(3)) +async def mute_notification(thread_id: int) -> dict: + """Unsubscribe the current operator from the trusted notification conversation.""" + repository, number = await notification_conversation_target(thread_id) + 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") + response = await _get_client().delete( + f"/api/v1/repos/{repository}/issues/{number}/subscriptions/{quote(login, safe='')}", + headers=_auth(), + ) + response.raise_for_status() + return {"id": thread_id, "repository": repository, "number": number, "muted": True} + + async def reply_to_notification(thread_id: int, body: str) -> 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 6cdca0e..27ef9a3 100644 --- a/src/main.py +++ b/src/main.py @@ -3685,6 +3685,43 @@ async def unread_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse: return JSONResponse({"id": thread_id, "status": "unread"}) +@app.post("/api/v1/notifications/{thread_id}/mute") +async def mute_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse: + try: + await asyncio.wait_for( + gitea_proxy.mute_notification(thread_id), + timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS, + ) + except TimeoutError: + return JSONResponse( + {"error": "Muting future updates timed out. Please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + except Exception: + return JSONResponse( + {"error": "Future updates could not be muted. Please retry."}, + status_code=503, + ) + try: + await asyncio.wait_for( + mark_notification_read(thread_id), + timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS, + ) + except Exception: + return JSONResponse( + { + "id": thread_id, + "muted": True, + "status": "unread", + "error": "Future updates are muted; current item is still unread. Retry mark read & next.", + }, + status_code=409, + ) + await _remove_notifications_from_live_snapshot([thread_id]) + return JSONResponse({"id": thread_id, "muted": True, "status": "read"}) + + @app.patch("/api/v1/notifications/{thread_id}/later") async def defer_notification( payload: NotificationLaterRequest, diff --git a/tests/test_gitea_notifications.py b/tests/test_gitea_notifications.py index 74e9dd5..cf5316b 100644 --- a/tests/test_gitea_notifications.py +++ b/tests/test_gitea_notifications.py @@ -254,6 +254,8 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re "number": 7, "state": "open", "assignees": [], "body": "Deploy fails after **three** retries.", }) + if request.url.path.endswith("/issues/7/subscriptions/check"): + return httpx.Response(200, json={"subscribed": True, "ignored": False}) if request.url.path.endswith("/issues/comments/9"): return httpx.Response(200, json={ "id": 9, @@ -283,6 +285,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7", "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9", "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments?limit=20&page=1", + "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/subscriptions/check", ] assert result == { "id": 42, @@ -300,6 +303,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re }, "issue": {"number": 7, "assignees": [], "claimable": True}, "acknowledge_supported": True, + "mute_supported": True, "conversation": { "comments": [{ "id": 9, @@ -398,3 +402,37 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin "created_at": "2026-08-07T19:00:00Z", "url": "https://forge.example/stackchain/api/issues/7#issuecomment-91", } + + +@pytest.mark.anyio +async def test_mute_notification_unsubscribes_current_user_from_trusted_thread_target(): + requests = [] + + def upstream(request): + requests.append((request.method, request.url.path)) + if request.url.path.endswith("/notifications/threads/42"): + return httpx.Response(200, json={ + "repository": {"full_name": "stackchain/api"}, + "subject": { + "type": "Pull", + "url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/pulls/7", + }, + }) + if request.url.path.endswith("/user"): + return httpx.Response(200, json={"login": "timmy"}) + if request.method == "DELETE": + return httpx.Response(204) + return httpx.Response(404) + + gitea_proxy.start_client(transport=httpx.MockTransport(upstream)) + try: + result = await gitea_proxy.mute_notification(42) + finally: + await gitea_proxy.stop_client() + + assert result == {"id": 42, "repository": "stackchain/api", "number": 7, "muted": True} + assert requests == [ + ("GET", "/api/v1/notifications/threads/42"), + ("GET", "/api/v1/user"), + ("DELETE", "/api/v1/repos/stackchain/api/issues/7/subscriptions/timmy"), + ] diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 14e9b6a..39a0087 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -4958,6 +4958,21 @@ async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next( assert '.update-sheet-actions #acknowledge-update-next { min-height:44px;' in html +@pytest.mark.anyio +async def test_mobile_update_sheet_mutes_future_updates_and_advances_only_on_read_success(): + html = await dashboard() + + assert 'id="mute-update-next" type="button" hidden' in html + assert ">Mute future updates & next" in html + assert "qs('#mute-update-next').hidden = !detail.mute_supported;" in html + assert "async function muteNotification(notificationId)" in html + assert "'/mute', { method: 'POST'" in html + assert "notificationReader.acceptReadAndNext(lastMyWork, item)" in html + assert "Future updates are muted; current item is still unread" in html + assert "qs('#mute-update-next').disabled = offline;" in html + assert ".update-sheet-actions #mute-update-next" in html + + def test_notification_reader_keeps_current_update_retryable_when_detail_load_fails(): script = f""" const buildMyWork = require({json.dumps(str(MY_WORK))}); diff --git a/tests/test_notification_read.py b/tests/test_notification_read.py index dda6152..fcf83c8 100644 --- a/tests/test_notification_read.py +++ b/tests/test_notification_read.py @@ -215,6 +215,34 @@ async def test_mark_notification_unread_api_is_bounded_and_never_cacheable(monke assert restored == [42] +@pytest.mark.anyio +async def test_mute_notification_then_marks_read_and_reports_partial_success(monkeypatch): + calls = [] + + async def mute(thread_id): + calls.append(("mute", thread_id)) + return {"id": thread_id, "muted": True} + + async def fail_read(thread_id): + calls.append(("read", thread_id)) + raise httpx.HTTPError("unavailable") + + monkeypatch.setattr(main.gitea_proxy, "mute_notification", mute, raising=False) + monkeypatch.setattr(main, "mark_notification_read", fail_read) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/api/v1/notifications/42/mute") + + assert response.status_code == 409 + assert response.json() == { + "id": 42, + "muted": True, + "status": "unread", + "error": "Future updates are muted; current item is still unread. Retry mark read & next.", + } + assert calls == [("mute", 42), ("read", 42)] + + @pytest.mark.anyio async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch): calls = []