diff --git a/README.md b/README.md index 61c0464..fa55e5c 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,14 @@ python3 -m pip install -r requirements.txt ``` Point the dashboard at the Gitea server root (without `/api/v1`) and provide a -token that can read dashboard data and update the authenticated user's -notification threads, then start the API and bundled frontend: +token that can read dashboard data, update the authenticated user's notification +threads, and create issue comments. Pull-request replies use Gitea's issue-comment +API. Serve the dashboard only to trusted users on its own origin; cross-origin API +access is intentionally disabled. Then start the API and bundled frontend: ```bash export GITEA_URL='https://forge.example.com' -export GITEA_TOKEN='' +export GITEA_TOKEN='' uvicorn src.main:app --host 127.0.0.1 --port 8000 ``` diff --git a/frontend/index.html b/frontend/index.html index 6da01a2..d71235e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -107,7 +107,11 @@ textarea { resize: vertical; min-height: 120px; } .update-sheet.open { display:flex; } .update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; } .update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } +.update-sheet-header button { min-height:44px; } .update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; } +.update-reply { display:grid; gap:8px; margin-top:16px; } +.update-reply textarea { width:100%; min-height:112px; resize:vertical; } +.update-reply button { min-height:44px; width:100%; } .update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } .update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; } .update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; } @@ -268,6 +272,13 @@ textarea { resize: vertical; min-height: 120px; }

Subject context

+
+

Reply

+ + + +
+
Open in Gitea @@ -433,6 +444,17 @@ textarea { resize: vertical; min-height: 120px; } return payload; } + async function postNotificationReply(notificationId, body) { + const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/reply', { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ body }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || 'Posting the reply failed.'); + return payload; + } + const notificationAcknowledger = createNotificationAcknowledger({ markRead: markNotificationRead, onItems: items => { @@ -468,6 +490,11 @@ textarea { resize: vertical; min-height: 120px; } }, onStatus: message => { qs('#my-work-action-status').textContent = message; }, }); + const notificationReplier = createNotificationReplier({ + post: postNotificationReply, + storage: localStorage, + onStatus: message => { qs('#update-reply-status').textContent = message; }, + }); const notificationReader = createNotificationReader({ load: fetchNotificationDetail, markRead: markNotificationRead, @@ -482,6 +509,9 @@ textarea { resize: vertical; min-height: 120px; } qs('#update-subject-type').textContent = item.subject_type || 'Update'; qs('#update-subject-state').textContent = item.state || ''; qs('#open-update-gitea').href = item.url || '#'; + qs('#update-reply').value = notificationReplier.loadDraft(item); + qs('#update-reply-status').textContent = ''; + qs('#send-update-reply').disabled = false; qs('#retry-update-load').hidden = true; qs('#keep-update-unread').focus(); }, @@ -910,6 +940,27 @@ textarea { resize: vertical; min-height: 120px; } qs('#retry-update-load').addEventListener('click', () => { if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork); }); + qs('#update-reply').addEventListener('input', event => { + if (selectedUpdate) notificationReplier.saveDraft(selectedUpdate, event.target.value); + }); + qs('#send-update-reply').addEventListener('click', async () => { + if (!selectedUpdate) return; + const body = qs('#update-reply').value.trim(); + if (!body) { + qs('#update-reply-status').textContent = 'Write a reply before sending.'; + qs('#update-reply').focus(); + return; + } + qs('#send-update-reply').disabled = true; + const result = await notificationReplier.submit(selectedUpdate, body); + qs('#send-update-reply').disabled = false; + if (result) { + qs('#update-reply').value = ''; + qs('#mark-update-read-next').focus(); + } else { + qs('#update-reply').focus(); + } + }); qs('#mark-update-read-next').addEventListener('click', async () => { qs('#mark-update-read-next').disabled = true; try { diff --git a/frontend/my-work.js b/frontend/my-work.js index 8230add..28d9010 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -226,6 +226,39 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o }; } +function createNotificationReplier({ post, storage, onStatus }) { + let pending = false; + const keyFor = item => 'stackchain.update-reply.v1.' + item.notification_id; + return { + loadDraft(item) { + try { return storage.getItem(keyFor(item)) || ''; } + catch (_error) { return ''; } + }, + saveDraft(item, body) { + try { storage.setItem(keyFor(item), body); } + catch (_error) { /* Keep the editable textarea as the fallback. */ } + }, + async submit(item, body) { + if (pending) return false; + pending = true; + this.saveDraft(item, body); + onStatus('Sending reply…'); + try { + const result = await post(item.notification_id, body); + try { storage.removeItem(keyFor(item)); } + catch (_error) { /* The posted reply is still authoritative. */ } + onStatus('Reply posted. You can mark this update read when ready.'); + return result; + } catch (_error) { + onStatus('Could not send reply. Your draft is safe; retry.'); + return false; + } finally { + pending = false; + } + }, + }; +} + function filterMyWork(items, selectedFilter) { if (selectedFilter === 'all') return items; if (selectedFilter === 'review') return items.filter((item) => item.is_review); @@ -263,5 +296,6 @@ if (typeof module !== 'undefined' && module.exports) { buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger; buildMyWork.createNotificationPager = createNotificationPager; buildMyWork.createNotificationReader = createNotificationReader; + buildMyWork.createNotificationReplier = createNotificationReplier; module.exports = buildMyWork; } diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index ab9deb6..7d1f2e7 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -1,5 +1,6 @@ import asyncio import os +import re import shlex from typing import Any from urllib.parse import urlsplit @@ -279,6 +280,40 @@ async def notification_detail(thread_id: int) -> dict: } +async def reply_to_notification(thread_id: int, body: str) -> 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") + repository_name = repository.get("full_name") + 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_name + or subject.get("type") not in {"Issue", "Pull"} + ): + raise ValueError("Notification subject is not a supported conversation") + response = await _get_client().post( + f"/api/v1/repos/{match.group(1)}/issues/{match.group(3)}/comments", + headers=_auth(), + json={"body": body}, + ) + response.raise_for_status() + 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")), + } + + async def pull_requests() -> list[dict]: assigned, review_requested = await asyncio.gather( fetch("repos/issues/search?state=open&assigned=true&type=pulls&limit=50"), diff --git a/src/main.py b/src/main.py index 4f5e4ad..f1ae60c 100644 --- a/src/main.py +++ b/src/main.py @@ -5,10 +5,9 @@ from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, HTTPException, Path as PathParam, Query -from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel, Field, PositiveInt +from pydantic import BaseModel, Field, PositiveInt, field_validator from src import gitea_proxy from src.gitea_proxy import ( @@ -82,6 +81,18 @@ class NotificationReadBatch(BaseModel): ids: list[PositiveInt] = Field(min_length=1, max_length=50) +class NotificationReply(BaseModel): + body: str = Field(min_length=1, max_length=10_000) + + @field_validator("body") + @classmethod + def strip_body(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("reply must not be blank") + return value + + def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict: user_model = User( id=user_data["id"], @@ -127,14 +138,6 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict: ] return compute(user_model, repo_models, issue_models, pr_models).model_dump() -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=False, - allow_methods=["*"], - allow_headers=["*"], -) - app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static") app.include_router(frontend_router) @@ -626,6 +629,28 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse: return JSONResponse({"id": thread_id, "status": "read"}) +@app.post("/api/v1/notifications/{thread_id}/reply", status_code=201) +async def reply_to_notification( + reply: NotificationReply, thread_id: int = PathParam(gt=0) +) -> JSONResponse: + try: + result = await asyncio.wait_for( + gitea_proxy.reply_to_notification(thread_id, reply.body), + timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS, + ) + except Exception: + return JSONResponse( + { + "error": ( + "The reply could not be posted. Your draft is safe; please retry." + ) + }, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse(result, status_code=201) + + @app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review") async def review_detail(owner: str, repo: str, number: int): async def load_requested_review(): diff --git a/tests/test_gitea_notifications.py b/tests/test_gitea_notifications.py index f71f89d..f51e351 100644 --- a/tests/test_gitea_notifications.py +++ b/tests/test_gitea_notifications.py @@ -200,3 +200,51 @@ async def test_notification_detail_never_follows_foreign_api_urls(): assert result["url"] == "" assert result["subject_body"] == "" assert result["latest_comment"]["body"] == "" + + +@pytest.mark.anyio +@pytest.mark.parametrize("subject_kind", ["issues", "pulls"]) +async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kind): + requests = [] + + def upstream(request): + requests.append((request.method, str(request.url), request.content)) + if request.method == "GET": + return httpx.Response(200, json={ + "id": 42, + "repository": {"full_name": "stackchain/api"}, + "subject": { + "type": "Pull" if subject_kind == "pulls" else "Issue", + "url": ( + "http://127.0.0.1:3000/api/v1/repos/stackchain/api/" + f"{subject_kind}/7" + ), + }, + }) + return httpx.Response(201, json={ + "id": 91, + "html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-91", + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(upstream)) + try: + result = await gitea_proxy.reply_to_notification(42, "Please retry the worker.") + finally: + await gitea_proxy.stop_client() + + assert requests == [ + ( + "GET", + "http://127.0.0.1:3000/api/v1/notifications/threads/42", + b"", + ), + ( + "POST", + "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments", + b'{"body":"Please retry the worker."}', + ), + ] + assert result == { + "id": 91, + "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 ecf2382..3d3f9e2 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -596,6 +596,78 @@ async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable(): assert "notificationReader.markReadAndNext(lastMyWork)" in html +def test_notification_replier_preserves_failed_draft_and_clears_only_after_success(): + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +const values = new Map(); +const storage = {{ + getItem: key => values.has(key) ? values.get(key) : null, + setItem: (key, value) => values.set(key, value), + removeItem: key => values.delete(key), +}}; +const statuses = []; +let calls = 0; +let fail = true; +const replier = buildMyWork.createNotificationReplier({{ + storage, + post: async (id, body) => {{ + calls += 1; + await new Promise(resolve => setTimeout(resolve, 5)); + if (fail) throw new Error('offline'); + return {{id:91, url:'https://forge.example/comment/91'}}; + }}, + onStatus: status => statuses.push(status), +}}); +const item = {{notification_id:42}}; +replier.saveDraft(item, 'Please retry.'); +Promise.all([replier.submit(item, 'Please retry.'), replier.submit(item, 'Please retry.')]) + .then(async first => {{ + const afterFailure = replier.loadDraft(item); + fail = false; + const success = await replier.submit(item, afterFailure); + process.stdout.write(JSON.stringify({{ + first, afterFailure, success, afterSuccess:replier.loadDraft(item), calls, statuses, + }})); + }}); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + output = json.loads(result.stdout) + + assert output["first"] == [False, False] + assert output["afterFailure"] == "Please retry." + assert output["success"]["id"] == 91 + assert output["afterSuccess"] == "" + assert output["calls"] == 2 + assert output["statuses"] == [ + "Sending reply…", + "Could not send reply. Your draft is safe; retry.", + "Sending reply…", + "Reply posted. You can mark this update read when ready.", + ] + + +@pytest.mark.anyio +async def test_mobile_update_sheet_has_persistent_accessible_reply_composer(): + html = await dashboard() + + assert 'id="update-reply"' in html + assert 'maxlength="10000"' in html + assert 'id="send-update-reply"' in html + assert 'id="update-reply-status"' in html + assert 'aria-live="assertive"' in html + assert '.update-reply textarea { width:100%;' in html + assert '.update-reply button { min-height:44px;' in html + assert '.update-sheet-header button { min-height:44px;' in html + assert 'createNotificationReplier' in html + assert "method: 'POST'" in html + assert "'/reply'" in html + assert "notificationReplier.loadDraft(item)" in html + assert "notificationReplier.saveDraft(selectedUpdate" in html + assert "notificationReplier.submit(selectedUpdate" in html + + @pytest.mark.anyio async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement(): html = await dashboard() diff --git a/tests/test_notification_reply.py b/tests/test_notification_reply.py new file mode 100644 index 0000000..a78d062 --- /dev/null +++ b/tests/test_notification_reply.py @@ -0,0 +1,90 @@ +import asyncio + +import httpx +import pytest + +from src import main + + +@pytest.mark.anyio +async def test_notification_reply_api_validates_and_posts_without_marking_read(monkeypatch): + calls = [] + + async def reply(thread_id, body): + calls.append((thread_id, body)) + return { + "id": 91, + "url": "https://forge.example/stackchain/api/issues/7#issuecomment-91", + } + + async def must_not_mark_read(_thread_id): + raise AssertionError("replying must not mark the update read") + + monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply) + monkeypatch.setattr(main, "mark_notification_read", must_not_mark_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/reply", + json={"body": " Please retry the worker. "}, + ) + empty = await client.post( + "/api/v1/notifications/42/reply", json={"body": " "} + ) + oversized = await client.post( + "/api/v1/notifications/42/reply", json={"body": "x" * 10_001} + ) + + assert response.status_code == 201 + assert response.json() == { + "id": 91, + "url": "https://forge.example/stackchain/api/issues/7#issuecomment-91", + } + assert response.headers["cache-control"] == "no-store" + assert empty.status_code == 422 + assert oversized.status_code == 422 + assert calls == [(42, "Please retry the worker.")] + + +@pytest.mark.anyio +@pytest.mark.parametrize("failure", ["timeout", "upstream"]) +async def test_notification_reply_failure_is_sanitized_retryable_and_no_store( + monkeypatch, failure +): + async def reply(_thread_id, _body): + if failure == "timeout": + await asyncio.sleep(0.05) + raise httpx.HTTPError("token=secret upstream exploded") + + monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply) + monkeypatch.setattr(main, "NOTIFICATION_MUTATION_TIMEOUT_SECONDS", 0.01) + transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/v1/notifications/42/reply", json={"body": "Retry it"} + ) + + assert response.status_code == 503 + assert response.json() == { + "error": "The reply could not be posted. Your draft is safe; please retry." + } + assert response.headers["retry-after"] == "1" + assert response.headers["cache-control"] == "no-store" + assert "secret" not in response.text + + +@pytest.mark.anyio +async def test_notification_reply_cannot_be_invoked_by_a_foreign_browser_origin(): + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.options( + "/api/v1/notifications/42/reply", + headers={ + "Origin": "https://evil.example", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type", + }, + ) + + assert response.status_code != 200 + assert "access-control-allow-origin" not in response.headers