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