diff --git a/frontend/index.html b/frontend/index.html index d9e50cc..2e8635b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -131,6 +131,7 @@ textarea { resize: vertical; min-height: 120px; } +
@@ -300,11 +301,12 @@ textarea { resize: vertical; min-height: 120px; } let selectedWorkFilter = 'all'; try { const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY); - if (['all', 'issue', 'pull', 'review'].includes(savedFilter)) selectedWorkFilter = savedFilter; + if (['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)) selectedWorkFilter = savedFilter; } catch (e) { console.warn('Could not restore My Work filter', e); } let lastMyWork = []; + let lastNotifications = []; let hasContextSnapshot = false; let selectedReview = null; let reviewTrigger = null; @@ -381,7 +383,7 @@ textarea { resize: vertical; min-height: 120px; } }); qs('#my-work').removeAttribute('data-stale'); qs('#my-work-status').textContent = lastMyWork.length ? - summarizeMyWork(lastMyWork) : 'No assigned work or review requests.'; + summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.'; renderMyWork(); } @@ -389,7 +391,7 @@ textarea { resize: vertical; min-height: 120px; } const visible = filterMyWork(lastMyWork, selectedWorkFilter); qs('#my-work-list').innerHTML = visible.length ? visible.map(item => { const contents = - '' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : 'Issue') + '' + + '' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '' + '' + escapeHtml(item.title) + '' + '' + escapeHtml(item.reason) + '' + (item.updated_at ? ' · Updated ' + escapeHtml(fmt(item.updated_at)) + '' : ''); @@ -398,7 +400,7 @@ textarea { resize: vertical; min-height: 120px; } return ''; } return '' + contents + ''; - }).join('') : '
No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')) + '.
'; + }).join('') : '
No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.
'; document.querySelectorAll('[data-review-index]').forEach(button => { button.addEventListener('click', () => openReviewSheet(lastMyWork[Number(button.dataset.reviewIndex)], button)); }); @@ -533,6 +535,13 @@ textarea { resize: vertical; min-height: 120px; } 'Update failed · showing last known work' : 'Work inbox unavailable.'; } + function markNotificationsStale() { + qs('#my-work').setAttribute('data-stale', 'true'); + qs('#my-work-status').textContent = lastNotifications.length ? + 'Unread updates unavailable · showing last known updates' : + 'Unread updates unavailable · assigned work is fresh'; + } + function paintDeltas(deltas) { const el = qs('#ai'); el.innerHTML = deltas.length ? deltas.map(d => '
' + escapeHtml(d.priority) + ' ' + escapeHtml(d.action) + ' ' + escapeHtml(d.target || '') + '
' + escapeHtml(d.panel) + '
').join('') : '
No suggestions yet.
'; @@ -553,8 +562,13 @@ textarea { resize: vertical; min-height: 120px; } } function renderLiveSnapshot(snapshot) { - if (snapshot.context) renderContextSnapshot(snapshot.context); - else handleContextError(new Error('Context section unavailable')); + const notificationsFresh = Array.isArray(snapshot.notifications); + if (notificationsFresh) lastNotifications = snapshot.notifications; + if (snapshot.context) { + snapshot.context.notifications = lastNotifications; + renderContextSnapshot(snapshot.context); + if (!notificationsFresh) markNotificationsStale(); + } else handleContextError(new Error('Context section unavailable')); if (snapshot.events) { paintEventStream(snapshot.events); setEventStreamStatus('Updated ' + fmt(new Date())); diff --git a/frontend/my-work.js b/frontend/my-work.js index 9c53d83..306a549 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -4,7 +4,7 @@ function buildMyWork(data) { const pulls = (data.pull_requests || []).map((item) => ({ ...item, kind: 'pull' })); const priorityLabels = ['p0', 'priority-high', 'critical']; - return issues.concat(pulls).map((item) => { + const work = issues.concat(pulls).map((item) => { const labels = item.labels || []; const priorityLabel = labels.find((label) => priorityLabels.includes(String(label).toLowerCase()) @@ -16,11 +16,41 @@ function buildMyWork(data) { key: (item.repository || 'unknown') + '#' + item.number, is_review: isReview, is_assigned: assigned, + has_update: false, reason: priorityLabel ? priorityLabel + ' priority' : (isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work')), - _priority: priorityLabel ? 0 : (isReview ? 1 : (assigned ? 2 : 3)), + _priority: priorityLabel ? 0 : (isReview ? 2 : (assigned ? 3 : 4)), }; - }).sort((left, right) => + }); + + const byKey = new Map(work.map((item) => [item.kind + ':' + item.key, item])); + (data.notifications || []).filter((item) => item && item.unread).forEach((update) => { + const key = (update.repository || 'unknown') + '#' + update.number; + const subjectKind = String(update.subject_type || '').toLowerCase().includes('pull') ? 'pull' : 'issue'; + const existing = byKey.get(subjectKind + ':' + key); + if (existing) { + existing.has_update = true; + existing.url = update.url || existing.url; + existing.updated_at = update.updated_at || existing.updated_at; + existing._priority = Math.min(existing._priority, 1); + return; + } + if (!update.url) return; + const item = { + ...update, + key, + kind: 'update', + is_review: false, + is_assigned: false, + has_update: true, + reason: 'Unread update', + _priority: 1, + }; + work.push(item); + byKey.set(subjectKind + ':' + key, item); + }); + + return work.sort((left, right) => left._priority - right._priority || String(right.updated_at || '').localeCompare(String(left.updated_at || '')) || left.key.localeCompare(right.key) @@ -30,15 +60,18 @@ function buildMyWork(data) { function filterMyWork(items, selectedFilter) { if (selectedFilter === 'all') return items; if (selectedFilter === 'review') return items.filter((item) => item.is_review); + if (selectedFilter === 'update') return items.filter((item) => item.has_update); return items.filter((item) => item.kind === selectedFilter); } function summarizeMyWork(items) { + const updates = items.filter((item) => item.has_update).length; const reviews = items.filter((item) => item.is_review).length; const assigned = items.filter((item) => item.is_assigned).length; + const updateLabel = updates + ' unread update' + (updates === 1 ? '' : 's'); const reviewLabel = reviews + ' review' + (reviews === 1 ? '' : 's'); const assignedLabel = assigned + ' assigned'; - return reviewLabel + ' · ' + assignedLabel; + return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel; } function countMyWork(items) { @@ -47,6 +80,7 @@ function countMyWork(items) { issue: items.filter((item) => item.kind === 'issue').length, pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length, review: items.filter((item) => item.is_review).length, + update: items.filter((item) => item.has_update).length, }; } diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index f16ce0f..75574fe 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -2,6 +2,7 @@ import asyncio import os import shlex from typing import Any +from urllib.parse import urlsplit import httpx @@ -117,6 +118,69 @@ async def issues() -> list[dict]: ) +def _safe_web_url(value: Any) -> str: + if not isinstance(value, str): + return "" + parsed = urlsplit(value) + return value if parsed.scheme in {"http", "https"} and parsed.netloc else "" + + +async def notifications() -> list[dict]: + threads = await fetch("notifications?status-types=unread&limit=50") + if not isinstance(threads, list): + raise ValueError("Gitea notification response was not a list") + normalized = [] + for thread in threads: + if not isinstance(thread, dict): + continue + repository = thread.get("repository") + subject = thread.get("subject") + repository = repository if isinstance(repository, dict) else {} + subject = subject if isinstance(subject, dict) else {} + subject_url = _safe_web_url(subject.get("html_url")) + latest_url = _safe_web_url(subject.get("latest_comment_html_url")) + number_text = ( + urlsplit(subject_url).path.rstrip("/").rsplit("/", 1)[-1] + if subject_url + else "" + ) + normalized.append( + { + "id": thread.get("id"), + "unread": thread.get("unread") is True, + "updated_at": ( + thread.get("updated_at") + if isinstance(thread.get("updated_at"), str) + else "" + ), + "repository": ( + repository.get("full_name") + if isinstance(repository.get("full_name"), str) + else "" + ), + "number": int(number_text) if number_text.isdigit() else None, + "title": ( + subject.get("title") + if isinstance(subject.get("title"), str) and subject.get("title") + else "Untitled update" + ), + "subject_type": ( + subject.get("type") + if isinstance(subject.get("type"), str) and subject.get("type") + else "Update" + ), + "state": ( + subject.get("state") + if isinstance(subject.get("state"), str) + else "" + ), + "url": latest_url or subject_url, + "subject_url": subject_url, + } + ) + return normalized + + 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 77f163e..de4a651 100644 --- a/src/main.py +++ b/src/main.py @@ -14,6 +14,7 @@ from src.gitea_proxy import ( current_user, is_requested_review, issues, + notifications, pull_requests, pull_review_detail, repos, @@ -224,19 +225,23 @@ async def _build_live_snapshot() -> dict: user_data = await current_user() if not isinstance(user_data, dict) or not user_data.get("login"): raise ContextPayloadError("Gitea current-user response was invalid") - context_result, events_result = await asyncio.gather( + context_result, events_result, notifications_result = await asyncio.gather( _load_context_for_user(user_data), activity_events(user_data), + notifications(), return_exceptions=True, ) context_ok = not isinstance(context_result, BaseException) events_ok = not isinstance(events_result, BaseException) + notifications_ok = not isinstance(notifications_result, BaseException) return { "context": context_result if context_ok else None, "events": events_result if events_ok else None, + "notifications": notifications_result if notifications_ok else None, "sections": { "context": "fresh" if context_ok else "temporarily unavailable", "events": "fresh" if events_ok else "temporarily unavailable", + "notifications": "fresh" if notifications_ok else "temporarily unavailable", }, } diff --git a/tests/test_gitea_notifications.py b/tests/test_gitea_notifications.py new file mode 100644 index 0000000..567e8e7 --- /dev/null +++ b/tests/test_gitea_notifications.py @@ -0,0 +1,88 @@ +import pytest + +from src import gitea_proxy + + +@pytest.mark.anyio +async def test_unread_notifications_are_bounded_and_normalized_for_mobile_handoff(monkeypatch): + requested_paths = [] + + async def fake_fetch(path): + requested_paths.append(path) + return [ + { + "id": 42, + "unread": True, + "updated_at": "2026-08-06T12:30:00Z", + "repository": {"full_name": "stackchain/api"}, + "subject": { + "title": "Retry failed deploy", + "type": "Issue", + "state": "open", + "html_url": "https://forge.example/stackchain/api/issues/7", + "latest_comment_html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9", + }, + }, + {"id": 43, "repository": None, "subject": None}, + { + "id": 44, + "repository": {"full_name": "stackchain/web"}, + "subject": {"title": "Unsafe", "html_url": "javascript:alert(1)"}, + }, + "malformed", + ] + + monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch) + + result = await gitea_proxy.notifications() + + assert requested_paths == ["notifications?status-types=unread&limit=50"] + assert result == [ + { + "id": 42, + "unread": True, + "updated_at": "2026-08-06T12:30:00Z", + "repository": "stackchain/api", + "number": 7, + "title": "Retry failed deploy", + "subject_type": "Issue", + "state": "open", + "url": "https://forge.example/stackchain/api/issues/7#issuecomment-9", + "subject_url": "https://forge.example/stackchain/api/issues/7", + }, + { + "id": 43, + "unread": False, + "updated_at": "", + "repository": "", + "number": None, + "title": "Untitled update", + "subject_type": "Update", + "state": "", + "url": "", + "subject_url": "", + }, + { + "id": 44, + "unread": False, + "updated_at": "", + "repository": "stackchain/web", + "number": None, + "title": "Unsafe", + "subject_type": "Update", + "state": "", + "url": "", + "subject_url": "", + }, + ] + + +@pytest.mark.anyio +async def test_notification_collection_rejects_non_list_payload(monkeypatch): + async def fake_fetch(_path): + return {"message": "unexpected"} + + monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch) + + with pytest.raises(ValueError, match="notification response was not a list"): + await gitea_proxy.notifications() diff --git a/tests/test_live_snapshot.py b/tests/test_live_snapshot.py index 4f11111..ae6874a 100644 --- a/tests/test_live_snapshot.py +++ b/tests/test_live_snapshot.py @@ -32,11 +32,15 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon assert authenticated_user["login"] == "timmy" return [{"type": "push"}] + async def updates(): + return [{"id": 42, "title": "Mentioned you"}] + monkeypatch.setattr(main, "current_user", user) monkeypatch.setattr(main, "repos", empty) monkeypatch.setattr(main, "issues", empty) monkeypatch.setattr(main, "pull_requests", empty) monkeypatch.setattr(main, "activity_events", events) + monkeypatch.setattr(main, "notifications", updates) response = await main.live_snapshot() result = payload(response) @@ -44,7 +48,10 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon assert calls["user"] == 1 assert result["context"]["user"]["login"] == "timmy" assert result["events"] == [{"type": "push"}] - assert result["sections"] == {"context": "fresh", "events": "fresh"} + assert result["notifications"] == [{"id": 42, "title": "Mentioned you"}] + assert result["sections"] == { + "context": "fresh", "events": "fresh", "notifications": "fresh" + } @pytest.mark.anyio @@ -63,6 +70,7 @@ async def test_live_snapshot_keeps_fresh_context_when_activity_fails(monkeypatch monkeypatch.setattr(main, "issues", empty) monkeypatch.setattr(main, "pull_requests", empty) monkeypatch.setattr(main, "activity_events", failing_events) + monkeypatch.setattr(main, "notifications", empty) result = payload(await main.live_snapshot()) @@ -71,10 +79,41 @@ async def test_live_snapshot_keeps_fresh_context_when_activity_fails(monkeypatch assert result["sections"] == { "context": "fresh", "events": "temporarily unavailable", + "notifications": "fresh", } assert "secret" not in json.dumps(result) +@pytest.mark.anyio +async def test_live_snapshot_keeps_work_and_activity_when_notifications_fail(monkeypatch): + async def user(): + return {"id": 1, "login": "timmy"} + + async def empty(): + return [] + + async def events(_authenticated_user): + return [{"type": "push"}] + + async def failing_updates(): + raise ConnectionError("private notification failure") + + monkeypatch.setattr(main, "current_user", user) + monkeypatch.setattr(main, "repos", empty) + monkeypatch.setattr(main, "issues", empty) + monkeypatch.setattr(main, "pull_requests", empty) + monkeypatch.setattr(main, "activity_events", events) + monkeypatch.setattr(main, "notifications", failing_updates) + + result = payload(await main.live_snapshot()) + + assert result["context"]["user"]["login"] == "timmy" + assert result["events"] == [{"type": "push"}] + assert result["notifications"] is None + assert result["sections"]["notifications"] == "temporarily unavailable" + assert "private" not in json.dumps(result) + + @pytest.mark.anyio async def test_live_snapshot_keeps_fresh_activity_when_work_fails(monkeypatch): async def user(): @@ -94,6 +133,7 @@ async def test_live_snapshot_keeps_fresh_activity_when_work_fails(monkeypatch): monkeypatch.setattr(main, "issues", empty) monkeypatch.setattr(main, "pull_requests", empty) monkeypatch.setattr(main, "activity_events", events) + monkeypatch.setattr(main, "notifications", empty) result = payload(await main.live_snapshot()) @@ -102,6 +142,7 @@ async def test_live_snapshot_keeps_fresh_activity_when_work_fails(monkeypatch): assert result["sections"] == { "context": "temporarily unavailable", "events": "fresh", + "notifications": "fresh", } @@ -130,6 +171,7 @@ async def test_live_snapshot_coalesces_only_simultaneous_requests(monkeypatch): monkeypatch.setattr(main, "issues", empty) monkeypatch.setattr(main, "pull_requests", empty) monkeypatch.setattr(main, "activity_events", events) + monkeypatch.setattr(main, "notifications", empty) first = asyncio.create_task(main.live_snapshot()) await asyncio.sleep(0) diff --git a/tests/test_my_work.py b/tests/test_my_work.py index fefa0b0..748f737 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -113,7 +113,80 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)}) ["node", "-e", script], check=True, capture_output=True, text=True ) - assert json.loads(result.stdout) == {"all": 3, "issue": 1, "pull": 1, "review": 1} + assert json.loads(result.stdout) == {"all": 3, "issue": 1, "pull": 1, "review": 1, "update": 0} + + +def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable(): + payload = { + "user": {"login": "timmy"}, + "issues": [{ + "id": 1, "number": 7, "title": "Assigned issue", "repository": "stackchain/api", + "labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T10:00:00Z", + "url": "https://forge.example/stackchain/api/issues/7", + }], + "pull_requests": [], + "notifications": [ + { + "id": 42, "number": 7, "title": "Assigned issue", "repository": "stackchain/api", + "subject_type": "Issue", "unread": True, "updated_at": "2026-08-06T12:00:00Z", + "url": "https://forge.example/stackchain/api/issues/7#issuecomment-9", + }, + { + "id": 43, "number": 8, "title": "Mention only", "repository": "stackchain/web", + "subject_type": "Issue", "unread": True, "updated_at": "2026-08-06T13:00:00Z", + "url": "https://forge.example/stackchain/web/issues/8#issuecomment-2", + }, + ], + } + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +const queue = buildMyWork({json.dumps(payload)}); +process.stdout.write(JSON.stringify({{ + queue, + updates: buildMyWork.filterMyWork(queue, 'update'), + counts: buildMyWork.countMyWork(queue), + summary: buildMyWork.summarizeMyWork(queue), +}})); +""" + + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + output = json.loads(result.stdout) + + assert len(output["queue"]) == 2 + assert [item["key"] for item in output["updates"]] == ["stackchain/web#8", "stackchain/api#7"] + assert output["updates"][0]["kind"] == "update" + assert output["updates"][1]["kind"] == "issue" + assert output["updates"][1]["url"].endswith("#issuecomment-9") + assert output["counts"] == {"all": 2, "issue": 1, "pull": 0, "review": 0, "update": 2} + assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned" + + +def test_unread_update_correlation_distinguishes_issue_and_pull_with_same_number(): + payload = { + "user": {"login": "timmy"}, + "issues": [{"number": 7, "title": "Issue seven", "repository": "stackchain/api", "url": "https://forge.example/issues/7"}], + "pull_requests": [{"number": 7, "title": "Pull seven", "repository": "stackchain/api", "url": "https://forge.example/pulls/7"}], + "notifications": [{ + "id": 42, "number": 7, "title": "Issue seven", "repository": "stackchain/api", + "subject_type": "Issue", "unread": True, "url": "https://forge.example/issues/7#comment-1", + }], + } + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)}))); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + queue = json.loads(result.stdout) + + issue = next(item for item in queue if item["kind"] == "issue") + pull = next(item for item in queue if item["kind"] == "pull") + assert issue["has_update"] is True + assert issue["url"].endswith("#comment-1") + assert pull["has_update"] is False @pytest.mark.anyio @@ -125,6 +198,7 @@ async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels( assert 'data-work-filter="issue"' in html assert 'data-work-filter="pull"' in html assert 'data-work-filter="review"' in html + assert 'data-work-filter="update"' in html assert '.work-filter' in html and 'min-height: 44px' in html assert '.my-work-card' in html and 'min-height: 44px' in html assert '' in html @@ -142,6 +216,8 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session(): assert 'data-work-count="issue"' in html assert 'data-work-count="pull"' in html assert 'data-work-count="review"' in html + assert 'data-work-count="update"' in html + assert "['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)" in html assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html