From b7d4bd8137583a65f6f9cfd010376a5c624260dd Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 6 Aug 2026 15:52:26 +0000 Subject: [PATCH] feat: add review requests to My Work (#117) --- frontend/index.html | 11 +++++----- frontend/my-work.js | 24 +++++++++++++++++++-- src/gitea_proxy.py | 16 +++++++++++++- src/main.py | 5 +++++ src/models.py | 1 + tests/test_gitea_work_search.py | 37 ++++++++++++++++++++++++++++++++- tests/test_my_work.py | 36 ++++++++++++++++++++++++++++---- 7 files changed, 116 insertions(+), 14 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 4a0edb7..905a7bd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -99,6 +99,7 @@ textarea { resize: vertical; min-height: 120px; } +
@@ -272,14 +273,12 @@ textarea { resize: vertical; min-height: 120px; } lastMyWork = buildMyWork(data); qs('#my-work').removeAttribute('data-stale'); qs('#my-work-status').textContent = lastMyWork.length ? - lastMyWork.length + ' assigned item' + (lastMyWork.length === 1 ? '' : 's') : - 'No assigned work.'; + summarizeMyWork(lastMyWork) : 'No assigned work or review requests.'; renderMyWork(); } function renderMyWork() { - const visible = selectedWorkFilter === 'all' ? lastMyWork : - lastMyWork.filter(item => item.kind === selectedWorkFilter); + const visible = filterMyWork(lastMyWork, selectedWorkFilter); qs('#my-work-list').innerHTML = visible.length ? visible.map(item => '' + '' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : 'Issue') + '' + @@ -287,13 +286,13 @@ textarea { resize: vertical; min-height: 120px; } '' + escapeHtml(item.reason) + '' + (item.updated_at ? ' · Updated ' + escapeHtml(fmt(item.updated_at)) + '' : '') + '' - ).join('') : '
No ' + (selectedWorkFilter === 'all' ? '' : selectedWorkFilter + ' ') + 'items.
'; + ).join('') : '
No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')) + '.
'; } function markMyWorkStale() { qs('#my-work').setAttribute('data-stale', 'true'); qs('#my-work-status').textContent = lastMyWork.length ? - 'Update failed · showing last assigned work' : 'Assigned work unavailable.'; + 'Update failed · showing last known work' : 'Work inbox unavailable.'; } function paintDeltas(deltas) { diff --git a/frontend/my-work.js b/frontend/my-work.js index 4c4a33c..e6837a7 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -10,11 +10,15 @@ function buildMyWork(data) { priorityLabels.includes(String(label).toLowerCase()) ); const assigned = (item.assignees || []).includes(login); + const isReview = (item.work_reasons || []).includes('review_requested'); return { ...item, key: (item.repository || 'unknown') + '#' + item.number, - reason: priorityLabel ? priorityLabel + ' priority' : (assigned ? 'Assigned to you' : 'Open work'), - _priority: priorityLabel ? 0 : (assigned ? 1 : 2), + is_review: isReview, + is_assigned: assigned, + reason: priorityLabel ? priorityLabel + ' priority' : + (isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work')), + _priority: priorityLabel ? 0 : (isReview ? 1 : (assigned ? 2 : 3)), }; }).sort((left, right) => left._priority - right._priority || @@ -23,6 +27,22 @@ function buildMyWork(data) { ).map(({ _priority, ...item }) => item); } +function filterMyWork(items, selectedFilter) { + if (selectedFilter === 'all') return items; + if (selectedFilter === 'review') return items.filter((item) => item.is_review); + return items.filter((item) => item.kind === selectedFilter); +} + +function summarizeMyWork(items) { + const reviews = items.filter((item) => item.is_review).length; + const assigned = items.filter((item) => item.is_assigned).length; + const reviewLabel = reviews + ' review' + (reviews === 1 ? '' : 's'); + const assignedLabel = assigned + ' assigned'; + return reviewLabel + ' · ' + assignedLabel; +} + if (typeof module !== 'undefined' && module.exports) { + buildMyWork.filterMyWork = filterMyWork; + buildMyWork.summarizeMyWork = summarizeMyWork; module.exports = buildMyWork; } diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 3ba60ea..e9d4dea 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -36,9 +36,23 @@ async def issues() -> list[dict]: async def pull_requests() -> list[dict]: - return await fetch( + assigned = await fetch( "repos/issues/search?state=open&assigned=true&type=pulls&limit=50" ) + review_requested = await fetch( + "repos/issues/search?state=open&review_requested=true&type=pulls&limit=50" + ) + merged: dict[int, dict] = {} + for reason, pulls in ( + ("assigned_to_me", assigned or []), + ("review_requested", review_requested or []), + ): + for pull in pulls: + identity = pull.get("id") + if identity not in merged: + merged[identity] = {**pull, "work_reasons": []} + merged[identity]["work_reasons"].append(reason) + return list(merged.values()) async def activity_events() -> list[dict]: diff --git a/src/main.py b/src/main.py index e54cc1b..e5f579c 100644 --- a/src/main.py +++ b/src/main.py @@ -192,6 +192,11 @@ async def context() -> JSONResponse: for assignee in (p.get("assignees") or []) if isinstance(assignee, dict) ], + work_reasons=[ + reason + for reason in (p.get("work_reasons") or []) + if reason in ("assigned_to_me", "review_requested") + ], repository=( p["repository"].get("full_name", "") if isinstance(p.get("repository"), dict) diff --git a/src/models.py b/src/models.py index bfbd41a..ec2166f 100644 --- a/src/models.py +++ b/src/models.py @@ -37,6 +37,7 @@ class PullRequest(BaseModel): user: str labels: list[str] = [] assignees: list[str] = [] + work_reasons: list[str] = [] repository: str = "" updated_at: str = "" url: str diff --git a/tests/test_gitea_work_search.py b/tests/test_gitea_work_search.py index 72875df..369c035 100644 --- a/tests/test_gitea_work_search.py +++ b/tests/test_gitea_work_search.py @@ -6,7 +6,7 @@ from src import main @pytest.mark.anyio -async def test_work_collections_use_supported_assigned_search_endpoint(monkeypatch): +async def test_work_collections_include_supported_review_request_search(monkeypatch): requested_paths = [] async def fake_fetch(path): @@ -20,9 +20,39 @@ async def test_work_collections_use_supported_assigned_search_endpoint(monkeypat assert requested_paths == [ "repos/issues/search?state=open&assigned=true&type=issues&limit=50", "repos/issues/search?state=open&assigned=true&type=pulls&limit=50", + "repos/issues/search?state=open&review_requested=true&type=pulls&limit=50", ] +@pytest.mark.anyio +async def test_pull_requests_merge_assignment_and_review_responsibilities(monkeypatch): + assigned = { + "id": 11, + "number": 7, + "title": "Review API", + "repository": {"full_name": "stackchain/api"}, + } + review_only = { + "id": 12, + "number": 8, + "title": "Review mobile", + "repository": {"full_name": "stackchain/mobile"}, + } + + async def fake_fetch(path): + if "assigned=true" in path: + return [assigned] + return [assigned.copy(), review_only] + + monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch) + + pulls = await gitea_proxy.pull_requests() + + assert [pull["id"] for pull in pulls] == [11, 12] + assert pulls[0]["work_reasons"] == ["assigned_to_me", "review_requested"] + assert pulls[1]["work_reasons"] == ["review_requested"] + + @pytest.mark.anyio async def test_context_preserves_repository_and_update_time_for_cross_repo_work(monkeypatch): async def user(): @@ -53,6 +83,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work( "user": {"login": "alex"}, "labels": [{"name": "priority-high"}], "assignees": [{"login": "timmy"}], + "work_reasons": ["assigned_to_me", "review_requested"], "repository": {"full_name": "stackchain/api"}, "updated_at": "2026-08-06T11:00:00Z", "html_url": "https://forge.example/stackchain/api/pulls/7", @@ -71,3 +102,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work( assert payload["pull_requests"][0]["updated_at"] == "2026-08-06T11:00:00Z" assert payload["pull_requests"][0]["labels"] == ["priority-high"] assert payload["pull_requests"][0]["assignees"] == ["timmy"] + assert payload["pull_requests"][0]["work_reasons"] == [ + "assigned_to_me", + "review_requested", + ] diff --git a/tests/test_my_work.py b/tests/test_my_work.py index ec2aad6..c618049 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -10,7 +10,7 @@ from src.views import dashboard MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js" -def test_my_work_queue_prioritizes_labels_then_assignment_and_keeps_repo_identity(): +def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity(): payload = { "user": {"login": "timmy"}, "issues": [ @@ -44,6 +44,7 @@ def test_my_work_queue_prioritizes_labels_then_assignment_and_keeps_repo_identit "title": "Review PR", "state": "open", "repository": "stackchain/web", + "work_reasons": ["review_requested"], "updated_at": "2026-08-06T13:00:00Z", "url": "https://forge.example/web/pulls/4", } @@ -62,13 +63,38 @@ process.stdout.write(JSON.stringify(queue)); assert [item["title"] for item in queue] == [ "Priority issue", - "Assigned issue", "Review PR", + "Assigned issue", ] assert queue[0]["key"] == "stackchain/api#7" assert queue[0]["reason"] == "P0 priority" - assert queue[1]["reason"] == "Assigned to you" - assert queue[2]["kind"] == "pull" + assert queue[1]["reason"] == "Needs your review" + assert queue[1]["is_review"] is True + assert queue[2]["reason"] == "Assigned to you" + + +def test_my_work_reviews_filter_and_summary_are_actionable(): + items = [ + {"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True}, + {"title": "Assigned PR", "kind": "pull", "is_review": False, "is_assigned": True}, + {"title": "Review PR", "kind": "pull", "is_review": True, "is_assigned": False}, + ] + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +const items = {json.dumps(items)}; +process.stdout.write(JSON.stringify({{ + reviews: buildMyWork.filterMyWork(items, 'review'), + summary: buildMyWork.summarizeMyWork(items), +}})); +""" + + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + output = json.loads(result.stdout) + + assert [item["title"] for item in output["reviews"]] == ["Review PR"] + assert output["summary"] == "1 review · 2 assigned" @pytest.mark.anyio @@ -79,8 +105,10 @@ async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels( assert 'data-work-filter="all"' in html assert 'data-work-filter="issue"' in html assert 'data-work-filter="pull"' in html + assert 'data-work-filter="review"' 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 assert "buildMyWork(data)" in html assert "markMyWorkStale()" in html + assert "filterMyWork(lastMyWork, selectedWorkFilter)" in html -- 2.43.0