From 04bb6839ae1d5bdba3d55e8bc0d5bd5ec5b0c441 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 08:37:48 +0000 Subject: [PATCH] feat: hand off assigned issues from mobile (#281) --- frontend/index.html | 74 ++++++++++++++++ frontend/issue-sheet.js | 26 ++++++ frontend/service-worker.js | 2 +- src/gitea_proxy.py | 84 +++++++++++++++++++ src/main.py | 61 ++++++++++++++ tests/test_issue_api.py | 158 +++++++++++++++++++++++++++++++++++ tests/test_my_work.py | 65 ++++++++++++++ tests/test_service_worker.py | 4 +- 8 files changed, 471 insertions(+), 3 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index d27dc61..c7863e7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -189,6 +189,10 @@ textarea { resize: vertical; min-height: 120px; } .issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } .issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; } .issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; } +.issue-handoff { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; } +.issue-handoff > div { display:grid; gap:8px; margin-top:10px; } +.issue-handoff select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); } +.issue-handoff select, .issue-handoff button { min-height:44px; } .issue-retry { min-height:44px; width:100%; margin-top:10px; } .new-issue { min-height:44px; } .find-work-action { min-height:44px; } @@ -519,6 +523,16 @@ textarea { resize: vertical; min-height: 120px; }
Expand planning controls to load milestones.
+
+ Hand off to teammate +
+ + + + +
Load teammates to transfer ownership.
+
+
@@ -1803,6 +1817,12 @@ textarea { resize: vertical; min-height: 120px; } qs('#issue-conversation-status').textContent = 'Loading newest messages…'; qs('#issue-comment').value = issueController.loadDraft(item); qs('#issue-comment-status').textContent = ''; + qs('#issue-handoff').open = false; + qs('#issue-handoff-recipient').innerHTML = ''; + qs('#issue-handoff-recipient').disabled = true; + qs('#confirm-issue-handoff').disabled = true; + qs('#load-issue-handoff').disabled = false; + qs('#issue-handoff-status').textContent = 'Load teammates to transfer ownership.'; qs('#issue-planning').open = false; qs('#retry-issue-planning').hidden = true; qs('#issue-label-list').textContent = ''; @@ -3130,6 +3150,60 @@ textarea { resize: vertical; min-height: 120px; } button.focus(); } }); + qs('#load-issue-handoff').addEventListener('click', async () => { + if (!selectedIssue) return; + const button = qs('#load-issue-handoff'); + const select = qs('#issue-handoff-recipient'); + button.disabled = true; + qs('#issue-handoff-status').textContent = 'Loading eligible teammates…'; + try { + const candidates = await issueController.loadHandoffCandidates(selectedIssue); + select.textContent = ''; + const placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates'; + select.appendChild(placeholder); + candidates.forEach(candidate => { + const option = document.createElement('option'); + option.value = candidate.login; + option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')'); + select.appendChild(option); + }); + select.disabled = !candidates.length; + qs('#confirm-issue-handoff').disabled = true; + qs('#issue-handoff-status').textContent = candidates.length ? + 'Choose who should own this issue next.' : 'No other eligible assignees were found.'; + if (candidates.length) select.focus(); + } catch (error) { + qs('#issue-handoff-status').textContent = error.message + ' Retry loading teammates.'; + button.disabled = false; + button.focus(); + } + }); + qs('#issue-handoff-recipient').addEventListener('change', event => { + qs('#confirm-issue-handoff').disabled = !event.target.value; + }); + qs('#confirm-issue-handoff').addEventListener('click', async () => { + const recipient = qs('#issue-handoff-recipient').value; + if (!selectedIssue || !recipient || !window.confirm('Hand off ' + selectedIssue.key + ' to @' + recipient + '?')) return; + const handingOff = selectedIssue; + const button = qs('#confirm-issue-handoff'); + button.disabled = true; + qs('#issue-handoff-status').textContent = 'Confirming handoff…'; + try { + await issueController.handoff(selectedIssue, recipient, lastContextSnapshot?.user?.login); + lastContextSnapshot = buildMyWork.removeIssue( + lastContextSnapshot, handingOff.repository, handingOff.number + ); + closeIssueSheet(); + paintMyWork(lastContextSnapshot); + qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '.'; + } catch (error) { + qs('#issue-handoff-status').textContent = error.message + ' The issue remains in My Work; retry.'; + button.disabled = false; + button.focus(); + } + }); qs('#close-issue').addEventListener('click', async () => { if (!selectedIssue || !window.confirm('Close ' + selectedIssue.key + '?')) return; const closing = selectedIssue; diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js index cf1285c..b742e05 100644 --- a/frontend/issue-sheet.js +++ b/frontend/issue-sheet.js @@ -22,6 +22,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global let commentRequest = null; let closeRequest = null; let releaseRequest = null; + let handoffRequest = null; let labelRequest = null; let editRequest = null; let dueDateRequest = null; @@ -61,6 +62,11 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global headers: { Accept: 'application/json' }, }); }, + loadHandoffCandidates(item) { + return fetchJson(issuePath(item) + '/handoff-candidates', { + headers: { Accept: 'application/json' }, + }); + }, loadDraft(item) { try { return storage?.getItem(draftKey(item)) || ''; } catch (_error) { return ''; } @@ -209,6 +215,26 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global }).finally(() => { releaseRequest = null; }); return releaseRequest; }, + handoff(item, recipient, currentLogin) { + if (handoffRequest) return handoffRequest; + handoffRequest = fetchJson(issuePath(item) + '/handoff', { + method: 'PATCH', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipient }), + }).then(result => { + if ( + result?.number !== item.number || + result?.recipient !== recipient || + !Array.isArray(result.assignees) || + !result.assignees.includes(recipient) || + result.assignees.includes(currentLogin) + ) { + throw new Error('Issue handoff was not confirmed.'); + } + return result; + }).finally(() => { handoffRequest = null; }); + return handoffRequest; + }, comment(item, body) { if (commentRequest) return commentRequest; this.saveDraft(item, body); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 28ef82a..349ff8a 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v20'; +const CACHE = 'stackchain-dashboard-shell-v21'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index ff0a1ab..fbd3592 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -1038,6 +1038,90 @@ async def release_assigned_issue(repository: str, number: int) -> dict: } +async def issue_handoff_candidates(repository: str) -> list[dict]: + user, response = await asyncio.gather( + current_user(), + _get_client().get( + f"/api/v1/repos/{repository}/assignees", headers=_auth() + ), + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, list): + raise ValueError("Gitea assignees response was not a list") + current_login = user.get("login") if isinstance(user, dict) else None + candidates = [] + for item in payload: + if not isinstance(item, dict): + continue + login = item.get("login") + if not isinstance(login, str) or not login or login == current_login: + continue + full_name = item.get("full_name") + candidates.append({ + "login": login, + "name": full_name if isinstance(full_name, str) and full_name else login, + }) + return candidates + + +async def handoff_assigned_issue( + repository: str, number: int, recipient: str +) -> dict: + login, issue = await _current_login_and_target( + f"repos/{repository}/issues/{number}" + ) + if ( + issue.get("state") != "open" + or issue.get("pull_request") is not None + or not _login_in_users(login, issue.get("assignees")) + ): + raise IssueNotAvailableError("Issue is not assigned to the current user") + + eligible = { + item["login"] for item in await issue_handoff_candidates(repository) + } + if recipient not in eligible: + raise IssueNotAvailableError("Handoff recipient is not eligible") + assignees_value = issue.get("assignees") + assignees = assignees_value if isinstance(assignees_value, list) else [] + desired = [ + item["login"] for item in assignees + if isinstance(item, dict) + and isinstance(item.get("login"), str) + and item["login"] != login + ] + if recipient not in desired: + desired.append(recipient) + response = await _get_client().patch( + f"/api/v1/repos/{repository}/issues/{number}", + headers=_auth(), + json={"assignees": desired}, + ) + response.raise_for_status() + confirmed = response.json() + confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None + confirmed_assignees = [ + item["login"] for item in confirmed_value + if isinstance(item, dict) and isinstance(item.get("login"), str) + ] if isinstance(confirmed_value, list) else [] + if ( + not isinstance(confirmed, dict) + or confirmed.get("number") != number + or login in confirmed_assignees + or recipient not in confirmed_assignees + or set(confirmed_assignees) != set(desired) + ): + raise ValueError("Gitea did not confirm issue handoff") + return { + "repository": repository, + "number": number, + "state": confirmed.get("state", "open"), + "assignees": confirmed_assignees, + "recipient": recipient, + } + + async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict: response = await _get_client().patch( f"/api/v1/repos/{repository}/issues/{number}", diff --git a/src/main.py b/src/main.py index 16acabd..500de7c 100644 --- a/src/main.py +++ b/src/main.py @@ -239,6 +239,12 @@ class IssueMilestoneUpdate(BaseModel): milestone_id: PositiveInt | None = None +class IssueHandoff(BaseModel): + recipient: str = Field( + min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_.-]+$" + ) + + class PullReviewComment(BaseModel): path: str = Field(min_length=1, max_length=1_000) body: str = Field(min_length=1, max_length=10_000) @@ -1478,6 +1484,61 @@ async def release_assigned_issue( return JSONResponse(result) +@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff-candidates") +async def issue_handoff_candidates( + owner: str, repo: str, number: int = PathParam(gt=0) +) -> JSONResponse: + repository = f"{owner}/{repo}" + + async def load_candidates(): + if not await gitea_proxy.is_assigned_issue(repository, number): + raise HTTPException(status_code=404, detail="Assigned issue not found") + return await gitea_proxy.issue_handoff_candidates(repository) + + try: + result = await asyncio.wait_for( + load_candidates(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS + ) + except HTTPException: + raise + except Exception: + return JSONResponse( + {"error": "Teammates could not be loaded. Please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse(result) + + +@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff") +async def handoff_assigned_issue( + handoff: IssueHandoff, + owner: str, + repo: str, + number: int = PathParam(gt=0), +) -> JSONResponse: + repository = f"{owner}/{repo}" + try: + result = await asyncio.wait_for( + gitea_proxy.handoff_assigned_issue( + repository, number, handoff.recipient + ), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, + ) + except gitea_proxy.IssueNotAvailableError: + return JSONResponse( + {"error": "The issue or recipient changed. Reload before handing off."}, + status_code=409, + ) + except Exception: + return JSONResponse( + {"error": "The handoff could not be confirmed. It remains in My Work; please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse(result) + + @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail") async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)): repository = f"{owner}/{repo}" diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index 484ea51..4d97f85 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -955,6 +955,76 @@ async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_ assert result["available"] is False +@pytest.mark.anyio +async def test_gitea_handoff_candidates_exclude_current_and_malformed_users(): + async def handler(request): + if request.url.path == "/api/v1/user": + return httpx.Response(200, json={"login": "timmy"}) + assert request.url.path == "/api/v1/repos/stackchain/api/assignees" + return httpx.Response(200, json=[ + {"login": "timmy", "full_name": "Timmy"}, + {"login": "alex", "full_name": "Alexander"}, + {"login": "casey", "full_name": ""}, + {"login": ""}, + {"full_name": "Missing login"}, + "malformed", + ]) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.issue_handoff_candidates("stackchain/api") + finally: + await gitea_proxy.stop_client() + + assert result == [ + {"login": "alex", "name": "Alexander"}, + {"login": "casey", "name": "casey"}, + ] + + +@pytest.mark.anyio +async def test_gitea_handoff_replaces_operator_and_preserves_coassignees(): + requests = [] + + async def handler(request): + requests.append(request) + if request.url.path == "/api/v1/user": + return httpx.Response(200, json={"login": "timmy"}) + if request.url.path.endswith("/assignees"): + return httpx.Response(200, json=[ + {"login": "alex", "full_name": "Alexander"}, + {"login": "casey", "full_name": "Casey"}, + ]) + if request.method == "GET": + return httpx.Response(200, json={ + "number": 17, "state": "open", "pull_request": None, + "assignees": [{"login": "timmy"}, {"login": "casey"}], + }) + assert request.content == b'{"assignees":["casey","alex"]}' + return httpx.Response(200, json={ + "number": 17, "state": "open", + "assignees": [{"login": "casey"}, {"login": "alex"}], + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.handoff_assigned_issue("stackchain/api", 17, "alex") + finally: + await gitea_proxy.stop_client() + + assert result == { + "repository": "stackchain/api", "number": 17, "state": "open", + "assignees": ["casey", "alex"], "recipient": "alex", + } + assert [(request.method, request.url.path) for request in requests] == [ + ("GET", "/api/v1/user"), + ("GET", "/api/v1/repos/stackchain/api/issues/17"), + ("GET", "/api/v1/user"), + ("GET", "/api/v1/repos/stackchain/api/assignees"), + ("PATCH", "/api/v1/repos/stackchain/api/issues/17"), + ] + + @pytest.mark.anyio async def test_release_issue_endpoint_invalidates_available_work_and_returns_confirmation(monkeypatch): calls = [] @@ -981,6 +1051,94 @@ async def test_release_issue_endpoint_invalidates_available_work_and_returns_con assert main._available_issue_snapshot_created_at is None +@pytest.mark.anyio +async def test_issue_handoff_endpoints_list_candidates_and_confirm_transfer(monkeypatch): + calls = [] + + async def assigned(repository, number): + calls.append(("assigned", repository, number)) + return True + + async def candidates(repository): + calls.append(("candidates", repository)) + return [{"login": "alex", "name": "Alexander"}] + + async def handoff(repository, number, recipient): + calls.append(("handoff", repository, number, recipient)) + return { + "repository": repository, "number": number, "state": "open", + "assignees": [recipient], "recipient": recipient, + } + + monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned) + monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates) + monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_issue", handoff) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + listed = await client.get( + "/api/v1/repos/stackchain/api/issues/17/handoff-candidates" + ) + transferred = await client.patch( + "/api/v1/repos/stackchain/api/issues/17/handoff", + json={"recipient": "alex"}, + ) + + assert listed.status_code == 200 + assert listed.json() == [{"login": "alex", "name": "Alexander"}] + assert transferred.status_code == 200 + assert transferred.json()["recipient"] == "alex" + assert listed.headers["cache-control"] == "no-store" + assert transferred.headers["cache-control"] == "no-store" + assert calls == [ + ("assigned", "stackchain/api", 17), + ("candidates", "stackchain/api"), + ("handoff", "stackchain/api", 17, "alex"), + ] + + +@pytest.mark.anyio +async def test_issue_handoff_returns_conflict_when_assignment_or_recipient_changed(monkeypatch): + async def handoff(_repository, _number, _recipient): + raise gitea_proxy.IssueNotAvailableError("stale") + + monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_issue", handoff) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.patch( + "/api/v1/repos/stackchain/api/issues/17/handoff", + json={"recipient": "alex"}, + ) + + assert response.status_code == 409 + assert response.json() == { + "error": "The issue or recipient changed. Reload before handing off." + } + + +@pytest.mark.anyio +async def test_issue_handoff_candidates_reject_an_issue_not_assigned_to_operator(monkeypatch): + called = False + + async def assigned(_repository, _number): + return False + + async def candidates(_repository): + nonlocal called + called = True + return [] + + monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned) + monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/api/v1/repos/stackchain/api/issues/17/handoff-candidates" + ) + + assert response.status_code == 404 + assert called is False + + @pytest.mark.anyio async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch): async def assigned(repository, number): diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 16e0887..e1e69ae 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -470,6 +470,58 @@ controller.release({{repository:'stackchain/api', number:17}}, 'timmy') assert result.stdout == "Issue release was not confirmed." +def test_issue_handoff_is_single_flight_and_requires_confirmed_transfer(): + script = f""" +const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); +let calls = []; +let finish; +const controller = createIssueSheet({{ + fetchJson: (url, options) => {{ + calls.push({{url, options}}); + return new Promise(resolve => {{ finish = resolve; }}); + }}, + storage: null, +}}); +const item = {{repository:'stackchain/api', number:17}}; +const first = controller.handoff(item, 'alex', 'timmy'); +const duplicate = controller.handoff(item, 'alex', 'timmy'); +finish({{number:17, repository:'stackchain/api', recipient:'alex', assignees:['alex']}}); +Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{ + calls, same: first === duplicate, results +}}))); +""" + output = json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + assert output["same"] is True + assert len(output["calls"]) == 1 + assert output["calls"][0]["url"].endswith("/issues/17/handoff") + assert output["calls"][0]["options"]["method"] == "PATCH" + assert json.loads(output["calls"][0]["options"]["body"]) == {"recipient": "alex"} + assert output["results"][0]["assignees"] == ["alex"] + + +def test_issue_handoff_candidates_use_the_assigned_issue_route(): + script = f""" +const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); +let call; +const controller = createIssueSheet({{ + fetchJson: async (url, options) => {{ call = {{url, options}}; return [{{login:'alex', name:'Alexander'}}]; }}, + storage: null, +}}); +controller.loadHandoffCandidates({{repository:'stackchain/api', number:17}}) + .then(result => process.stdout.write(JSON.stringify({{call, result}}))); +""" + output = json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + assert output["call"]["url"].endswith("/issues/17/handoff-candidates") + assert output["call"]["options"]["headers"]["Accept"] == "application/json" + assert output["result"] == [{"login": "alex", "name": "Alexander"}] + + def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed(): script = f""" const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); @@ -576,6 +628,19 @@ async def test_mobile_issue_sheet_exposes_touch_sized_due_date_editor_and_card_b assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html +@pytest.mark.anyio +async def test_mobile_issue_sheet_exposes_touch_safe_teammate_handoff(): + html = await dashboard() + + assert 'id="issue-handoff-recipient"' in html + assert 'id="load-issue-handoff"' in html + assert 'id="confirm-issue-handoff"' in html + assert 'id="issue-handoff-status" class="small" aria-live="assertive"' in html + assert '.issue-handoff select, .issue-handoff button { min-height:44px;' in html + assert "issueController.loadHandoffCandidates(selectedIssue)" in html + assert "issueController.handoff(selectedIssue, recipient" in html + + @pytest.mark.anyio async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls(): html = await dashboard() diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 8633001..b26037e 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -91,10 +91,10 @@ async function dispatchNotificationClick(route) {{ return json.loads(completed.stdout) -def test_today_queue_ships_in_a_new_shell_cache(): +def test_issue_handoff_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v20" in source + assert "stackchain-dashboard-shell-v21" in source assert "BASE + 'static/today-work.js'" in source -- 2.43.0