feat: add unread updates to My Work (#133)
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy committed 2026-08-06 19:59:39 +00:00
1 parent d75a55f3f7
commit f34fc8b5f5
7 files changed
+336 -13

No files matched your search

+88
View File
@@ -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()
+43 -1
View File
@@ -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)
+77 -1
View File
@@ -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 '<script src="static/my-work.js"></script>' 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