feat: mute future updates during triage (Closes #751)
All checks were successful
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-13 15:55:02 +00:00
parent b93e63ab70
commit 8dc3880167
8 changed files with 190 additions and 3 deletions

View File

@ -401,6 +401,7 @@ textarea { resize: vertical; min-height: 120px; }
.update-ownership-actions button { min-width:0; width:100%; } .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 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 #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-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.update-retry { min-height:44px; width:100%; margin-top:10px; } .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); } .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); }

View File

@ -719,6 +719,18 @@
return payload; 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) { async function markNotificationsRead(ids) {
const response = await fetch('api/v1/notifications/read', { const response = await fetch('api/v1/notifications/read', {
method: 'PATCH', method: 'PATCH',
@ -927,6 +939,7 @@
qs('#send-update-reply').disabled = false; qs('#send-update-reply').disabled = false;
qs('#send-update-reply-read-next').disabled = false; qs('#send-update-reply-read-next').disabled = false;
qs('#acknowledge-update-next').hidden = true; qs('#acknowledge-update-next').hidden = true;
qs('#mute-update-next').hidden = true;
qs('#update-ownership-action').hidden = true; qs('#update-ownership-action').hidden = true;
qs('#update-ownership-start').hidden = true; qs('#update-ownership-start').hidden = true;
qs('#create-update-follow-up').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('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#'; qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported; 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); qs('#create-update-follow-up').hidden = !['Issue', 'Pull'].includes(detail.subject_type);
if (offlineWorkMode) { if (offlineWorkMode) {
setOfflineUpdateControls(true); setOfflineUpdateControls(true);
@ -2218,6 +2232,7 @@
qs('#mark-update-read-next').disabled = false; qs('#mark-update-read-next').disabled = false;
qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next'; qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next';
qs('#acknowledge-update-next').disabled = offline; qs('#acknowledge-update-next').disabled = offline;
qs('#mute-update-next').disabled = offline;
qs('#update-ownership-action').disabled = offline; qs('#update-ownership-action').disabled = offline;
qs('#update-ownership-start').disabled = offline; qs('#update-ownership-start').disabled = offline;
qs('#load-older-update-comments').disabled = offline; qs('#load-older-update-comments').disabled = offline;
@ -5593,6 +5608,27 @@
button.disabled = offlineWorkMode; 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 () => { qs('#undo-notification').addEventListener('click', async () => {
const button = qs('#undo-notification'); const button = qs('#undo-notification');
button.disabled = true; button.disabled = true;

View File

@ -794,6 +794,7 @@
</div> </div>
<button class="share-work-route" type="button">Share</button> <button class="share-work-route" type="button">Share</button>
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge &amp; next</button> <button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge &amp; next</button>
<button id="mute-update-next" type="button" hidden>Mute future updates &amp; next</button>
<button id="create-update-follow-up" type="button" hidden>Create follow-up</button> <button id="create-update-follow-up" type="button" hidden>Create follow-up</button>
<button id="mark-update-read-next" type="button">Mark read &amp; next</button> <button id="mark-update-read-next" type="button">Mark read &amp; next</button>
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a> <a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>

View File

@ -4,7 +4,7 @@ import re
import shlex import shlex
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any from typing import Any
from urllib.parse import urljoin, urlsplit from urllib.parse import quote, urljoin, urlsplit
import httpx import httpx
@ -916,6 +916,14 @@ def _gitea_api_path(value: Any) -> str:
return parsed.path[len(prefix):] + (("?" + parsed.query) if parsed.query else "") 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: async def notification_detail(thread_id: int) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}") thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict): if not isinstance(thread, dict):
@ -940,18 +948,20 @@ async def notification_detail(thread_id: int) -> dict:
) )
if supported_conversation: if supported_conversation:
assert conversation_match is not None 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(subject_path),
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}), fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
issue_conversation_page( issue_conversation_page(
conversation_match.group(1), int(conversation_match.group(3)) conversation_match.group(1), int(conversation_match.group(3))
), ),
_notification_subscription(conversation_match.group(1), conversation_match.group(3)),
) )
else: 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(subject_path) if subject_path else asyncio.sleep(0, result={}),
fetch(comment_path) if comment_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={"comments": [], "page": 1, "older_page": None, "total": 0}),
asyncio.sleep(0, result={}),
) )
subject_detail = subject_detail if isinstance(subject_detail, dict) else {} subject_detail = subject_detail if isinstance(subject_detail, dict) else {}
comment = comment if isinstance(comment, 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
and comment_match.group(1) == repository_name 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, "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)) 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: async def reply_to_notification(thread_id: int, body: str) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}") thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict): if not isinstance(thread, dict):

View File

@ -3685,6 +3685,43 @@ async def unread_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
return JSONResponse({"id": thread_id, "status": "unread"}) 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") @app.patch("/api/v1/notifications/{thread_id}/later")
async def defer_notification( async def defer_notification(
payload: NotificationLaterRequest, payload: NotificationLaterRequest,

View File

@ -254,6 +254,8 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"number": 7, "state": "open", "assignees": [], "number": 7, "state": "open", "assignees": [],
"body": "Deploy fails after **three** retries.", "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"): if request.url.path.endswith("/issues/comments/9"):
return httpx.Response(200, json={ return httpx.Response(200, json={
"id": 9, "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/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/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/comments?limit=20&page=1",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/subscriptions/check",
] ]
assert result == { assert result == {
"id": 42, "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}, "issue": {"number": 7, "assignees": [], "claimable": True},
"acknowledge_supported": True, "acknowledge_supported": True,
"mute_supported": True,
"conversation": { "conversation": {
"comments": [{ "comments": [{
"id": 9, "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", "created_at": "2026-08-07T19:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91", "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"),
]

View File

@ -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 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 &amp; next</button>" 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(): def test_notification_reader_keeps_current_update_retryable_when_detail_load_fails():
script = f""" script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))}); const buildMyWork = require({json.dumps(str(MY_WORK))});

View File

@ -215,6 +215,34 @@ async def test_mark_notification_unread_api_is_bounded_and_never_cacheable(monke
assert restored == [42] 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 @pytest.mark.anyio
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch): async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
calls = [] calls = []