diff --git a/frontend/index.html b/frontend/index.html index 1d47425..ee9cece 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -548,9 +548,10 @@ textarea { resize: vertical; min-height: 120px; } Update -

Latest comment

-
-

+

Full conversation

+
+ +

Subject context

@@ -845,6 +846,16 @@ textarea { resize: vertical; min-height: 120px; } return payload; } + async function fetchNotificationConversation(notificationId, page) { + const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + + '/conversation?page=' + encodeURIComponent(page) + '&limit=20', { + headers: { Accept: 'application/json' }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || 'Loading older messages failed.'); + return payload; + } + async function fetchWorkPage(stream, page) { const response = await fetch('api/v1/work/' + encodeURIComponent(stream) + '?page=' + encodeURIComponent(page), { headers: { Accept: 'application/json' }, @@ -924,14 +935,16 @@ textarea { resize: vertical; min-height: 120px; } }); const notificationReader = createNotificationReader({ load: fetchNotificationDetail, + loadConversation: fetchNotificationConversation, markRead: markNotificationRead, onOpen: item => { selectedUpdate = item; qs('#update-sheet').classList.add('open'); qs('#update-sheet-key').textContent = item.key || ''; qs('#update-sheet-title').textContent = item.title || 'Unread update'; - qs('#update-comment-meta').textContent = ''; - qs('#update-comment-body').textContent = ''; + qs('#update-comments').textContent = ''; + qs('#update-conversation-status').textContent = ''; + qs('#load-older-update-comments').hidden = true; qs('#update-subject-body').textContent = ''; qs('#update-subject-type').textContent = item.subject_type || 'Update'; qs('#update-subject-state').textContent = item.state || ''; @@ -943,18 +956,14 @@ textarea { resize: vertical; min-height: 120px; } qs('#keep-update-unread').focus(); }, onDetail: detail => { - const comment = detail.latest_comment || {}; qs('#update-sheet-title').textContent = detail.title || 'Unread update'; qs('#update-subject-type').textContent = detail.subject_type || 'Update'; qs('#update-subject-state').textContent = detail.state || ''; - qs('#update-comment-meta').textContent = comment.author ? - comment.author + (comment.created_at ? ' · ' + fmt(comment.created_at) : '') : - (comment.created_at ? fmt(comment.created_at) : 'No comment author reported'); - qs('#update-comment-body').textContent = comment.body || 'No comment body was provided.'; qs('#update-subject-body').textContent = detail.subject_body || 'No subject context was provided.'; qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#'; qs('#retry-update-load').hidden = true; }, + onConversation: renderUpdateConversation, onItems: items => { const readId = selectedUpdate?.notification_id; lastMyWork = items; @@ -1365,6 +1374,15 @@ textarea { resize: vertical; min-height: 120px; } comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.'; } + function renderUpdateConversation(state) { + const comments = state?.comments || []; + qs('#update-comments').innerHTML = comments.length ? + comments.map(renderIssueComment).join('') : '
No comments yet.
'; + qs('#load-older-update-comments').hidden = !Number.isInteger(state?.older_page); + qs('#update-conversation-status').textContent = comments.length ? + comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.'; + } + function renderPullConversation(state) { const comments = state?.comments || []; qs('#pull-comments').innerHTML = comments.length ? comments.map(comment => @@ -2713,6 +2731,16 @@ textarea { resize: vertical; min-height: 120px; } qs('#retry-update-load').addEventListener('click', () => { if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork); }); + qs('#load-older-update-comments').addEventListener('click', async () => { + const button = qs('#load-older-update-comments'); + const panel = qs('#update-sheet .update-sheet-panel'); + const previousHeight = panel.scrollHeight; + const previousTop = panel.scrollTop; + button.disabled = true; + await notificationReader.loadOlder(); + panel.scrollTop = previousTop + (panel.scrollHeight - previousHeight); + button.disabled = false; + }); qs('#update-reply').addEventListener('input', event => { if (selectedUpdate) notificationReplier.saveDraft(selectedUpdate, event.target.value); }); @@ -2728,6 +2756,7 @@ textarea { resize: vertical; min-height: 120px; } const result = await notificationReplier.submit(selectedUpdate, body); qs('#send-update-reply').disabled = false; if (result) { + notificationReader.appendReply(result); qs('#update-reply').value = ''; qs('#mark-update-read-next').focus(); } else { diff --git a/frontend/my-work.js b/frontend/my-work.js index 39f4988..c1c08a2 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -249,10 +249,16 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) { }; } -function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, onStatus, onClose }) { +function createNotificationReader({ + load, markRead, onOpen, onDetail, onItems, onStatus, onClose, + loadConversation = null, + onConversation = () => {}, + createPager = typeof createConversationPager === 'function' ? createConversationPager : null, +}) { let selected = null; let loadVersion = 0; let marking = false; + let conversationPager = null; async function open(item) { selected = item; @@ -263,6 +269,14 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o const detail = await load(item.notification_id); if (selected !== item || version !== loadVersion) return false; onDetail(detail); + if (createPager && loadConversation && detail.conversation) { + conversationPager = createPager({ + loadPage: page => loadConversation(item.notification_id, page), + }); + onConversation(conversationPager.reset(detail.conversation)); + } else { + conversationPager = null; + } onStatus('Update ready.'); return true; } catch (_error) { @@ -275,6 +289,29 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o return { open, + appendReply(comment) { + if (!conversationPager) return false; + onConversation(conversationPager.append(comment)); + return true; + }, + async loadOlder() { + if (!conversationPager || !selected) return false; + const pager = conversationPager; + const version = loadVersion; + onStatus('Loading older messages…'); + try { + const state = await pager.loadOlder(); + if (pager !== conversationPager || version !== loadVersion) return false; + onConversation(state); + onStatus(state.comments.length + ' of ' + state.total + ' messages loaded.'); + return true; + } catch (_error) { + if (pager === conversationPager && version === loadVersion) { + onStatus('Could not load older messages. Retry.'); + } + return false; + } + }, async markReadAndNext(items) { if (!selected || marking) return false; const current = selected; diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index bb26042..5a7f9a5 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -533,8 +533,30 @@ async def notification_detail(thread_id: int) -> dict: subject = subject_value if isinstance(subject_value, dict) else {} subject_path = _gitea_api_path(subject.get("url")) comment_path = _gitea_api_path(subject.get("latest_comment_url")) - subject_detail = await fetch(subject_path) if subject_path else {} - comment = await fetch(comment_path) if comment_path else {} + conversation_match = re.fullmatch( + r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path + ) + repository_name = repository.get("full_name") + supported_conversation = ( + conversation_match + and conversation_match.group(1) == repository_name + and subject.get("type") in {"Issue", "Pull"} + ) + if supported_conversation: + assert conversation_match is not None + subject_detail, comment, conversation = 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)) + ), + ) + else: + subject_detail, comment, conversation = 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}), + ) subject_detail = subject_detail if isinstance(subject_detail, dict) else {} comment = comment if isinstance(comment, dict) else {} user_value = comment.get("user") @@ -573,9 +595,33 @@ async def notification_detail(thread_id: int) -> dict: else "", "url": latest_url, }, + "conversation": conversation, } +async def notification_conversation_page( + thread_id: int, page: int, limit: int = 20 +) -> dict: + thread = await fetch(f"notifications/threads/{thread_id}") + if not isinstance(thread, dict): + raise ValueError("Gitea notification thread response was not an object") + repository = thread.get("repository") + subject = thread.get("subject") + if not isinstance(repository, dict) or not isinstance(subject, dict): + raise ValueError("Notification does not identify a conversation") + subject_path = _gitea_api_path(subject.get("url")) + match = re.fullmatch(r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path) + if ( + not match + or match.group(1) != repository.get("full_name") + or subject.get("type") not in {"Issue", "Pull"} + ): + raise ValueError("Notification subject is not a supported conversation") + return await issue_conversation_page( + match.group(1), int(match.group(3)), page=page, limit=limit + ) + + async def reply_to_notification(thread_id: int, body: str) -> dict: thread = await fetch(f"notifications/threads/{thread_id}") if not isinstance(thread, dict): @@ -604,10 +650,7 @@ async def reply_to_notification(thread_id: int, body: str) -> dict: comment = response.json() if not isinstance(comment, dict): raise ValueError("Gitea comment response was not an object") - return { - "id": comment.get("id"), - "url": _safe_web_url(comment.get("html_url")), - } + return _normalize_issue_comment(comment) async def close_issue(repository: str, number: int) -> dict: diff --git a/src/main.py b/src/main.py index 1dc1e32..cace81e 100644 --- a/src/main.py +++ b/src/main.py @@ -1095,6 +1095,32 @@ async def notification_thread_detail( return JSONResponse(result) +@app.get("/api/v1/notifications/{thread_id}/conversation") +async def notification_thread_conversation( + thread_id: int = PathParam(gt=0), + page: int = Query(ge=1), + limit: int = Query(default=20, ge=1, le=50), +) -> JSONResponse: + try: + result = await asyncio.wait_for( + gitea_proxy.notification_conversation_page(thread_id, page, limit), + timeout=NOTIFICATION_DETAIL_TIMEOUT_SECONDS, + ) + except TimeoutError: + return JSONResponse( + {"error": "Loading older messages timed out. Please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + except Exception: + return JSONResponse( + {"error": "Older messages are temporarily unavailable. Please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse(result) + + @app.patch("/api/v1/notifications/{thread_id}/read") async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse: try: diff --git a/tests/test_gitea_notifications.py b/tests/test_gitea_notifications.py index f51e351..255826c 100644 --- a/tests/test_gitea_notifications.py +++ b/tests/test_gitea_notifications.py @@ -136,11 +136,20 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re return httpx.Response(200, json={"body": "Deploy fails after **three** retries."}) if request.url.path.endswith("/issues/comments/9"): return httpx.Response(200, json={ + "id": 9, "body": "Logs point to the worker timeout.", "created_at": "2026-08-06T12:30:00Z", "user": {"login": "alexander"}, "html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9", }) + if request.url.path.endswith("/issues/7/comments"): + return httpx.Response(200, json=[{ + "id": 9, + "body": "Logs point to the worker timeout.", + "created_at": "2026-08-06T12:30:00Z", + "user": {"login": "alexander"}, + "html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9", + }], headers={"X-Total-Count": "1"}) return httpx.Response(404) gitea_proxy.start_client(transport=httpx.MockTransport(upstream)) @@ -153,6 +162,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re "http://127.0.0.1:3000/api/v1/notifications/threads/42", "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", ] assert result == { "id": 42, @@ -168,6 +178,18 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re "created_at": "2026-08-06T12:30:00Z", "url": "https://forge.example/stackchain/api/issues/7#issuecomment-9", }, + "conversation": { + "comments": [{ + "id": 9, + "author": "alexander", + "body": "Logs point to the worker timeout.", + "created_at": "2026-08-06T12:30:00Z", + "url": "https://forge.example/stackchain/api/issues/7#issuecomment-9", + }], + "page": 1, + "older_page": None, + "total": 1, + }, } @@ -223,6 +245,9 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin }) return httpx.Response(201, json={ "id": 91, + "user": {"login": "timmy"}, + "body": "Please retry the worker.", + "created_at": "2026-08-07T19:00:00Z", "html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-91", }) @@ -246,5 +271,8 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin ] assert result == { "id": 91, + "author": "timmy", + "body": "Please retry the worker.", + "created_at": "2026-08-07T19:00:00Z", "url": "https://forge.example/stackchain/api/issues/7#issuecomment-91", } diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 2786b57..95fef44 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -1619,6 +1619,72 @@ reader.open(item, [item]).then(() => } +def test_notification_reader_pages_conversation_single_flight_and_ignores_stale_update(): + script = f""" +const build = require({json.dumps(str(MY_WORK))}); +const createPager = require({json.dumps(str(CONVERSATION))}); +const details = {{ + 42: {{id:42, conversation:{{comments:[{{id:41,created_at:'2026-08-07T12:41:00Z'}}],page:3,older_page:2,total:47}}}}, + 43: {{id:43, conversation:{{comments:[{{id:90,created_at:'2026-08-07T13:00:00Z'}}],page:1,older_page:null,total:1}}}}, +}}; +let release; +const requested = []; +const states = []; +const reader = build.createNotificationReader({{ + load: async id => details[id], createPager, + loadConversation: (id, page) => {{ + requested.push([id, page]); + return new Promise(resolve => {{ release = () => resolve({{comments:[{{id:21,created_at:'2026-08-07T12:21:00Z'}}],page:2,older_page:1,total:47}}); }}); + }}, + markRead: async () => {{}}, onOpen: () => {{}}, onDetail: () => {{}}, + onConversation: state => states.push(state.comments.map(item => item.id)), + onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}}, +}}); +const firstItem = {{notification_id:42}}; +const secondItem = {{notification_id:43}}; +reader.open(firstItem).then(async () => {{ + const first = reader.loadOlder(); + const duplicate = reader.loadOlder(); + await reader.open(secondItem); + release(); + await Promise.all([first, duplicate]); + process.stdout.write(JSON.stringify({{requested, states}})); +}}); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + output = json.loads(result.stdout) + + assert output["requested"] == [[42, 2]] + assert output["states"] == [[41], [90]] + + +def test_notification_reader_appends_a_confirmed_reply_exactly_once(): + script = f""" +const build = require({json.dumps(str(MY_WORK))}); +const createPager = require({json.dumps(str(CONVERSATION))}); +const states = []; +const reader = build.createNotificationReader({{ + load: async () => ({{conversation:{{comments:[{{id:41}}],page:1,older_page:null,total:1}}}}), + loadConversation: async () => ({{}}), createPager, + markRead: async () => {{}}, onOpen: () => {{}}, onDetail: () => {{}}, + onConversation: state => states.push(state.comments.map(item => item.id)), + onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}}, +}}); +reader.open({{notification_id:42}}).then(() => {{ + reader.appendReply({{id:91, author:'timmy', body:'Ship it'}}); + reader.appendReply({{id:91, author:'timmy', body:'Ship it'}}); + process.stdout.write(JSON.stringify(states)); +}}); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + + assert json.loads(result.stdout) == [[41], [41, 91], [41, 91]] + + def test_notification_reader_wraps_to_an_earlier_visible_unread_update(): script = f""" const buildMyWork = require({json.dumps(str(MY_WORK))}); @@ -2283,7 +2349,7 @@ async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable(): assert 'id="keep-update-unread"' in html assert 'id="mark-update-read-next"' in html assert 'id="retry-update-load"' in html - assert 'id="update-comment-body"' in html + assert 'id="update-comments"' in html assert 'id="update-subject-body"' in html assert 'id="open-update-gitea"' in html assert '.update-sheet-panel { width:min(560px,100%);' in html @@ -2882,6 +2948,23 @@ async def test_mobile_review_failure_offers_an_in_place_retry_for_the_same_item( assert "qs('#retry-review-load').focus()" in html +@pytest.mark.anyio +async def test_mobile_update_sheet_renders_and_pages_the_complete_conversation(): + html = await dashboard() + + update_sheet = html[html.index('id="update-sheet"'):html.index('id="pull-sheet"')] + assert '

Full conversation

' in update_sheet + assert 'id="update-comments"' in update_sheet + assert 'id="load-older-update-comments"' in update_sheet + assert 'id="update-conversation-status"' in update_sheet + assert '.conversation-more' in html and 'min-height:44px' in html + assert "'/conversation?page='" in html + assert "loadConversation: fetchNotificationConversation" in html + assert "onConversation: renderUpdateConversation" in html + assert "notificationReader.loadOlder()" in html + assert "notificationReader.appendReply(result)" in html + + def test_issue_comment_reuses_operation_key_after_reload_until_success(): script = f""" const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); diff --git a/tests/test_notification_detail.py b/tests/test_notification_detail.py index 1f963d7..d2974a5 100644 --- a/tests/test_notification_detail.py +++ b/tests/test_notification_detail.py @@ -3,7 +3,59 @@ import asyncio import httpx import pytest -from src import main +from src import gitea_proxy, main + + +@pytest.mark.anyio +async def test_notification_detail_opens_the_newest_conversation_page_in_chronological_order(monkeypatch): + requested_pages = [] + + def comment(comment_id): + return { + "id": comment_id, + "user": {"login": f"user-{comment_id}"}, + "body": f"message {comment_id}", + "created_at": f"2026-08-06T12:{comment_id:02d}:00Z", + "html_url": f"https://forge.example/stackchain/api/issues/7#issuecomment-{comment_id}", + } + + def handler(request): + if request.url.path.endswith("/notifications/threads/42"): + return httpx.Response(200, json={ + "repository": {"full_name": "stackchain/api"}, + "subject": { + "type": "Issue", "title": "Retry failed deploy", "state": "open", + "url": "https://forge.example/api/v1/repos/stackchain/api/issues/7", + "latest_comment_url": "https://forge.example/api/v1/repos/stackchain/api/issues/comments/47", + "html_url": "https://forge.example/stackchain/api/issues/7", + }, + }) + if request.url.path.endswith("/repos/stackchain/api/issues/7/comments"): + page = int(request.url.params["page"]) + requested_pages.append(page) + comments = [comment(i) for i in (range(1, 21) if page == 1 else range(41, 48))] + return httpx.Response(200, json=comments, headers={"X-Total-Count": "47"}) + if request.url.path.endswith("/repos/stackchain/api/issues/7"): + return httpx.Response(200, json={"body": "Deploy fails after retries."}) + if request.url.path.endswith("/repos/stackchain/api/issues/comments/47"): + return httpx.Response(200, json=comment(47)) + raise AssertionError(f"unexpected request: {request.url}") + + monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example") + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.notification_detail(42) + finally: + await gitea_proxy.stop_client() + + assert requested_pages == [1, 3] + assert [item["id"] for item in result["conversation"]["comments"]] == list(range(41, 48)) + assert result["conversation"] == { + "comments": result["conversation"]["comments"], + "page": 3, + "older_page": 2, + "total": 47, + } @pytest.mark.anyio @@ -41,6 +93,32 @@ async def test_notification_detail_api_is_bounded_and_never_cacheable(monkeypatc assert calls == [42] +@pytest.mark.anyio +async def test_notification_conversation_api_loads_one_bounded_older_page(monkeypatch): + calls = [] + + async def conversation(thread_id, page, limit): + calls.append((thread_id, page, limit)) + return { + "comments": [{"id": 21, "author": "timmy", "body": "Earlier context"}], + "page": page, + "older_page": 1, + "total": 47, + } + + monkeypatch.setattr(main.gitea_proxy, "notification_conversation_page", conversation, 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/notifications/42/conversation?page=2&limit=20") + invalid = await client.get("/api/v1/notifications/42/conversation?page=0&limit=51") + + assert response.status_code == 200 + assert response.json()["comments"][0]["id"] == 21 + assert response.headers["cache-control"] == "no-store" + assert invalid.status_code == 422 + assert calls == [(42, 2, 20)] + + @pytest.mark.anyio async def test_notification_detail_timeout_is_sanitized_and_retryable(monkeypatch): async def detail(_thread_id): diff --git a/tests/test_notification_reply.py b/tests/test_notification_reply.py index a78d062..fc972ba 100644 --- a/tests/test_notification_reply.py +++ b/tests/test_notification_reply.py @@ -3,7 +3,42 @@ import asyncio import httpx import pytest -from src import main +from src import gitea_proxy, main + + +@pytest.mark.anyio +async def test_notification_reply_returns_the_confirmed_comment_for_exact_once_append(monkeypatch): + def handler(request): + if request.method == "GET": + return httpx.Response(200, json={ + "repository": {"full_name": "stackchain/api"}, + "subject": { + "type": "Issue", + "url": "https://forge.example/api/v1/repos/stackchain/api/issues/7", + }, + }) + return httpx.Response(201, json={ + "id": 91, + "user": {"login": "timmy"}, + "body": "Please retry the worker.", + "created_at": "2026-08-07T19:00:00Z", + "html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-91", + }) + + monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example") + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.reply_to_notification(42, "Please retry the worker.") + finally: + await gitea_proxy.stop_client() + + assert result == { + "id": 91, + "author": "timmy", + "body": "Please retry the worker.", + "created_at": "2026-08-07T19:00:00Z", + "url": "https://forge.example/stackchain/api/issues/7#issuecomment-91", + } @pytest.mark.anyio