From bc082f2bd9b400009936ed7612d10b7fdb7f3575 Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 14 Aug 2026 01:44:51 +0000 Subject: [PATCH] feat: open requested reviews from mobile search (Closes #791) --- frontend/dashboard.js | 13 +++++++++---- src/gitea_proxy.py | 14 ++++++++++++++ tests/test_command_palette.py | 19 ++++++++++++++++++- tests/test_global_search.py | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index b44c651..6a2ef51 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -4364,9 +4364,9 @@ claimButton.hidden = !(detail.claimable || detail.assigned_to_me); claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'; claimButton.disabled = state.status === 'claiming'; - startButton.hidden = !(detail.kind === 'issue' && (detail.reopenable || - (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)))); - startButton.textContent = detail.reopenable ? 'Reopen & resume' : + startButton.hidden = !(detail.reviewable || (detail.kind === 'issue' && + (detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me))))); + startButton.textContent = detail.reviewable ? 'Review now' : detail.reopenable ? 'Reopen & resume' : (detail.assigned_to_me ? 'Start in Today' : 'Assign & start'); startButton.disabled = state.status === 'claiming' || state.status === 'reopening'; const shareStatus = { @@ -4380,7 +4380,7 @@ else if (detail.claimable) status.textContent = 'This issue is open and unassigned.'; else if (detail.assigned_to_me) status.textContent = 'This item is already in My Work.'; else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.'; - else status.textContent = 'Read-only preview.'; + else status.textContent = 'Ready.'; } const searchPreview = createSearchPreview({ fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }), @@ -4657,6 +4657,11 @@ }); qs('#start-search-result').addEventListener('click', async () => { const detail = searchPreviewDetail; + if (detail?.reviewable) { + taskOverlayHistory.leave(); + detail.kind = 'review'; + return openRoutedWork(detail, null, { replace:true }); + } if (!detail || detail.kind !== 'issue') return; try { if (detail.reopenable) await searchReopenAndStart.run(detail); diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 67e1787..fd96dff 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -539,6 +539,19 @@ async def work_preview(repository: str, kind: str, number: int) -> dict: login = user.get("login") if isinstance(user, dict) else "" state = issue.get("state") if isinstance(issue.get("state"), str) else "" actual_kind = "pull" if isinstance(issue.get("pull_request"), dict) else "issue" + reviewable = False + if actual_kind == "pull" and state == "open" and login: + try: + pull = await fetch(f"repos/{repository}/pulls/{number}") + except Exception: + pull = None + requested_reviewers = pull.get("requested_reviewers") if isinstance(pull, dict) else [] + if not isinstance(requested_reviewers, list): + requested_reviewers = [] + reviewable = any( + isinstance(reviewer, dict) and reviewer.get("login") == login + for reviewer in requested_reviewers + ) return { "kind": actual_kind, "repository": repository, @@ -556,6 +569,7 @@ async def work_preview(repository: str, kind: str, number: int) -> dict: "claimable": actual_kind == "issue" and state == "open" and not assignee_names, "reopenable": actual_kind == "issue" and state == "closed", "assigned_to_me": bool(login and login in assignee_names), + "reviewable": reviewable, } diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py index a884cc4..3570899 100644 --- a/tests/test_command_palette.py +++ b/tests/test_command_palette.py @@ -398,6 +398,23 @@ def test_assigned_pull_preview_hands_off_to_existing_my_work_sheet_without_claim assert "await openPreviewWorkInMyWork(claimed)" in click_handler +def test_requested_pull_preview_opens_native_review_without_assignment_mutation(): + html = dashboard_bundle_text() + css = (FRONTEND / "dashboard.css").read_text() + + assert "startButton.hidden = !(detail.reviewable" in html + assert "detail.reviewable ? 'Review now'" in html + handler = html.split( + "qs('#start-search-result').addEventListener('click'", 1 + )[1].split("qs('#close-whiteboard')", 1)[0] + assert "detail.kind = 'review'" in handler + assert "openRoutedWork" in handler + assert "{ replace:true }" in handler + assert "searchPreview.claim" not in handler + assert "fetchReviewJson" not in handler + assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css + + def test_search_preview_offers_assign_and_start_for_eligible_issues(): html = dashboard_bundle_text() css = (FRONTEND / "dashboard.css").read_text() @@ -413,7 +430,7 @@ def test_search_preview_offers_assign_and_start_for_eligible_issues(): def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard(): html = dashboard_bundle_text() - assert "startButton.textContent = detail.reopenable ? 'Reopen & resume'" in html + assert "detail.reopenable ? 'Reopen & resume'" in html assert "mutate: (detail, action) => fetchReviewJson(" in html handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split( "qs('#close-whiteboard')", 1 diff --git a/tests/test_global_search.py b/tests/test_global_search.py index 98b93f9..7428232 100644 --- a/tests/test_global_search.py +++ b/tests/test_global_search.py @@ -267,6 +267,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i "claimable": True, "reopenable": False, "assigned_to_me": False, + "reviewable": False, } @@ -294,6 +295,38 @@ async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim(): assert preview["claimable"] is False +@pytest.mark.anyio +async def test_work_preview_marks_authoritative_requested_pull_review_actionable(): + requested_paths = [] + + async def handler(request): + requested_paths.append(request.url.path) + if request.url.path.endswith("/user"): + return httpx.Response(200, json={"login": "timmy"}) + if request.url.path.endswith("/pulls/9"): + return httpx.Response(200, json={ + "state": "open", + "requested_reviewers": [{"login": "timmy"}, None], + }) + return httpx.Response(200, json={ + "number": 9, + "title": "Improve search", + "state": "open", + "html_url": "https://forge.example/stackchain/web/pulls/9", + "pull_request": {"merged": False}, + "assignees": [], + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + preview = await gitea_proxy.work_preview("stackchain/web", "issue", 9) + finally: + await gitea_proxy.stop_client() + + assert preview["reviewable"] is True + assert "/api/v1/repos/stackchain/web/pulls/9" in requested_paths + + @pytest.mark.anyio async def test_work_preview_offers_reopen_only_for_closed_issues(): async def handler(request):