diff --git a/frontend/dashboard.css b/frontend/dashboard.css index ce0daf8..9d360ee 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -539,6 +539,7 @@ textarea { resize: vertical; min-height: 120px; } #issue-sheet.read-only .detail-defer, #issue-sheet.read-only #issue-blockers, #issue-sheet.read-only #retry-issue-comment-actions { display:none; } +#issue-sheet.read-only.filed-reassignable #issue-handoff { display:block; } .filed-claim-actions { position:sticky; bottom:0; z-index:4; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:14px; padding:10px 4px calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #4ade80; } .filed-claim-actions[hidden] { display:none; } .filed-claim-actions button { min-width:0; min-height:44px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index e933f30..8fe1502 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -3383,7 +3383,10 @@ if (!item) return; const readOnly = issueController.readOnly(item); const withdrawable = item.is_filed && !item.is_assigned && !item.is_completed && item.state === 'open'; + const reassignable = item.is_filed && !item.is_completed && item.state === 'open' && + Array.isArray(item.assignees) && item.assignees.length > 0; qs('#issue-sheet').classList.toggle('read-only', readOnly); + qs('#issue-sheet').classList.toggle('filed-reassignable', reassignable); issueDetailPosition.open(workDetailIdentity('issue', item)); qs('#issue-planning').inert = false; qs('#issue-handoff').inert = false; @@ -3417,12 +3420,15 @@ qs('#issue-comment-title').textContent = readOnly ? 'Add follow-up' : 'Add comment'; qs('#issue-comment-status').textContent = ''; qs('#issue-handoff').open = false; + qs('#issue-handoff-summary').textContent = reassignable ? 'Change delegate' : 'Hand off to teammate'; qs('#issue-handoff-recipient').innerHTML = ''; qs('#issue-handoff-recipient').disabled = true; qs('#confirm-issue-handoff').disabled = true; - qs('#confirm-issue-handoff').textContent = workSession.checkpointed(item) ? 'Hand off & next' : 'Confirm handoff'; + qs('#confirm-issue-handoff').textContent = reassignable ? 'Confirm change' : + (workSession.checkpointed(item) ? 'Hand off & next' : 'Confirm handoff'); qs('#load-issue-handoff').disabled = false; - qs('#issue-handoff-status').textContent = 'Load teammates to transfer ownership.'; + qs('#issue-handoff-status').textContent = reassignable ? + 'Load teammates to change the current delegate.' : 'Load teammates to transfer ownership.'; qs('#issue-planning').open = false; qs('#retry-issue-planning').hidden = true; qs('#issue-label-list').textContent = ''; @@ -5939,22 +5945,24 @@ qs('#issue-handoff-status').textContent = 'Loading eligible teammates…'; try { const candidates = await issueController.loadHandoffCandidates(selectedIssue); + const currentAssignees = new Set(selectedIssue.is_filed ? (selectedIssue.assignees || []) : []); + const availableCandidates = candidates.filter(candidate => !currentAssignees.has(candidate.login)); select.textContent = ''; const placeholder = document.createElement('option'); placeholder.value = ''; - placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates'; + placeholder.textContent = availableCandidates.length ? 'Select a teammate' : 'No eligible teammates'; select.appendChild(placeholder); - candidates.forEach(candidate => { + availableCandidates.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; + select.disabled = !availableCandidates.length; qs('#confirm-issue-handoff').disabled = true; - qs('#issue-handoff-status').textContent = candidates.length ? + qs('#issue-handoff-status').textContent = availableCandidates.length ? 'Choose who should own this issue next.' : 'No other eligible assignees were found.'; - if (candidates.length) select.focus(); + if (availableCandidates.length) select.focus(); } catch (error) { qs('#issue-handoff-status').textContent = error.message + ' Retry loading teammates.'; button.disabled = false; @@ -5966,13 +5974,31 @@ }); 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; + if (!selectedIssue || !recipient) return; + const changingDelegate = selectedIssue.is_filed && !selectedIssue.is_completed && + Array.isArray(selectedIssue.assignees) && selectedIssue.assignees.length > 0; + const previousDelegates = changingDelegate ? selectedIssue.assignees.join(', @') : ''; + const confirmation = changingDelegate ? + 'Change delegate from @' + previousDelegates + ' to @' + recipient + ' for ' + selectedIssue.key + '?' : + 'Hand off ' + selectedIssue.key + ' to @' + recipient + '?'; + if (!window.confirm(confirmation)) return; const handingOff = selectedIssue; const continuingSession = workSession.checkpointed(handingOff); const button = qs('#confirm-issue-handoff'); button.disabled = true; qs('#issue-handoff-status').textContent = 'Confirming handoff…'; try { + if (changingDelegate) { + const confirmed = await issueController.reassign(selectedIssue, recipient); + Object.assign(handingOff, { assignees:confirmed.assignees, updated_at:confirmed.updated_at || handingOff.updated_at }); + selectedIssue = handingOff; + if (selectedIssueDetail) selectedIssueDetail.assignees = confirmed.assignees; + qs('#issue-assignees').textContent = 'Assigned to ' + recipient; + qs('#issue-handoff').open = false; + qs('#issue-handoff-status').textContent = 'Delegate changed to @' + recipient + '.'; + refreshMyWorkView({ reconcileSession:false }); + return; + } await issueController.handoff(selectedIssue, recipient, lastContextSnapshot?.user?.login); lastContextSnapshot = buildMyWork.removeIssue( lastContextSnapshot, handingOff.repository, handingOff.number diff --git a/frontend/index.html b/frontend/index.html index 2701660..1dfcca2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -689,7 +689,7 @@
- Hand off to teammate + Hand off to teammate
diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js index 1292cb3..e5c45b9 100644 --- a/frontend/issue-sheet.js +++ b/frontend/issue-sheet.js @@ -23,6 +23,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global let closeRequest = null; let releaseRequest = null; let handoffRequest = null; + let reassignRequest = null; let labelRequest = null; let editRequest = null; let dueDateRequest = null; @@ -70,7 +71,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global }); }, loadHandoffCandidates(item) { - return fetchJson(issuePath(item) + '/handoff-candidates', { + const access = item?.is_filed ? '?access=filed' : ''; + return fetchJson(issuePath(item) + '/handoff-candidates' + access, { headers: { Accept: 'application/json' }, }); }, @@ -262,6 +264,28 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global }).finally(() => { handoffRequest = null; }); return handoffRequest; }, + reassign(item, recipient) { + if (reassignRequest) return reassignRequest; + const expectedAssignees = Array.isArray(item?.assignees) ? item.assignees.slice() : []; + reassignRequest = fetchJson(issuePath(item) + '/reassign', { + method: 'PATCH', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipient, expected_assignees: expectedAssignees }), + }).then(result => { + if ( + result?.number !== item.number || + result?.recipient !== recipient || + !Array.isArray(result.assignees) || + result.assignees.length !== 1 || + result.assignees[0] !== recipient || + JSON.stringify(result.previous_assignees) !== JSON.stringify(expectedAssignees) + ) { + throw new Error('Issue reassignment was not confirmed.'); + } + return result; + }).finally(() => { reassignRequest = null; }); + return reassignRequest; + }, comment(item, body) { if (commentRequest) return commentRequest; this.saveDraft(item, body); diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index c29a619..a5e3fdf 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -1843,6 +1843,63 @@ async def handoff_assigned_issue( } +async def reassign_authored_issue( + repository: str, number: int, recipient: str, expected_assignees: list[str] +) -> dict: + login, issue = await _current_login_and_target( + f"repos/{repository}/issues/{number}" + ) + author = issue.get("user") + author_login = author.get("login") if isinstance(author, dict) else None + assignees_value = issue.get("assignees") + current_assignees = [ + item["login"] for item in assignees_value + if isinstance(item, dict) and isinstance(item.get("login"), str) + ] if isinstance(assignees_value, list) else [] + if ( + issue.get("state") != "open" + or issue.get("pull_request") is not None + or not isinstance(author_login, str) + or author_login.casefold() != login.casefold() + or not current_assignees + or current_assignees != expected_assignees + ): + raise IssueNotAvailableError("Authored issue delegate changed") + + eligible = { + item["login"] for item in await issue_handoff_candidates(repository) + } + if recipient not in eligible or recipient in current_assignees: + raise IssueNotAvailableError("Reassignment recipient is not eligible") + + response = await _get_client().patch( + f"/api/v1/repos/{repository}/issues/{number}", + headers=_auth(), + json={"assignees": [recipient]}, + ) + 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 confirmed_assignees != [recipient] + ): + raise ValueError("Gitea did not confirm issue reassignment") + return { + "repository": repository, + "number": number, + "state": confirmed.get("state", "open"), + "assignees": confirmed_assignees, + "recipient": recipient, + "previous_assignees": expected_assignees, + } + + async def pull_handoff_candidates(repository: str) -> list[dict]: candidates = await issue_handoff_candidates(repository) return [ diff --git a/src/main.py b/src/main.py index b2e39a6..d3dfe64 100644 --- a/src/main.py +++ b/src/main.py @@ -881,6 +881,22 @@ class IssueHandoff(BaseModel): ) +class IssueReassignment(IssueHandoff): + expected_assignees: list[str] = Field(min_length=1, max_length=10) + + @field_validator("expected_assignees") + @classmethod + def validate_expected_assignees(cls, values: list[str]) -> list[str]: + if any( + not isinstance(value, str) + or not re.fullmatch(r"[A-Za-z0-9_.-]+", value) + or len(value) > 255 + for value in values + ) or len(set(values)) != len(values): + raise ValueError("expected assignees must be unique valid logins") + return values + + class IssueBlockerUpdate(BaseModel): repository: str = Field( min_length=3, @@ -4369,12 +4385,20 @@ async def release_assigned_issue( @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) + owner: str, + repo: str, + number: int = PathParam(gt=0), + access: Literal["assigned", "filed"] = Query(default="assigned"), ) -> JSONResponse: repository = f"{owner}/{repo}" async def load_candidates(): - if not await gitea_proxy.is_assigned_issue(repository, number): + authorized = await ( + gitea_proxy.is_authored_issue(repository, number) + if access == "filed" + else gitea_proxy.is_assigned_issue(repository, number) + ) + if not authorized: raise HTTPException(status_code=404, detail="Assigned issue not found") return await gitea_proxy.issue_handoff_candidates(repository) @@ -4444,6 +4468,38 @@ async def handoff_assigned_issue( return JSONResponse(result) +@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/reassign") +async def reassign_authored_issue( + reassignment: IssueReassignment, + owner: str, + repo: str, + number: int = PathParam(gt=0), +) -> JSONResponse: + repository = f"{owner}/{repo}" + try: + result = await asyncio.wait_for( + gitea_proxy.reassign_authored_issue( + repository, + number, + reassignment.recipient, + reassignment.expected_assignees, + ), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, + ) + except gitea_proxy.IssueNotAvailableError: + return JSONResponse( + {"error": "The delegate changed. Reload before reassigning."}, + status_code=409, + ) + except Exception: + return JSONResponse( + {"error": "The reassignment could not be confirmed. The current delegate is unchanged; 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, diff --git a/tests/e2e/fake_gitea.py b/tests/e2e/fake_gitea.py index c346056..ac1ef64 100644 --- a/tests/e2e/fake_gitea.py +++ b/tests/e2e/fake_gitea.py @@ -22,6 +22,7 @@ class FakeGiteaServer(ThreadingHTTPServer): def __init__(self, address: tuple[str, int]): super().__init__(address, FakeGiteaHandler) self.created_issues: list[dict] = [] + self.issue_creation_enabled = False class FakeGiteaHandler(BaseHTTPRequestHandler): @@ -62,6 +63,9 @@ class FakeGiteaHandler(BaseHTTPRequestHandler): if path != "/api/v1/repos/acme/mobile/issues": self._json(404, {"message": "not found"}) return + if not self.server.issue_creation_enabled: + self._json(503, {"message": "release journey is still offline"}) + return self.server.created_issues.append(payload) assignee = payload.get("assignee") self._json( diff --git a/tests/e2e/test_mobile_offline_issue_release.py b/tests/e2e/test_mobile_offline_issue_release.py index ff351a8..b7f4754 100644 --- a/tests/e2e/test_mobile_offline_issue_release.py +++ b/tests/e2e/test_mobile_offline_issue_release.py @@ -219,6 +219,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo browser_errors.clear() # Chromium reports expected network errors while the context is offline. failed_responses.clear() + fake.issue_creation_enabled = True context.set_offline(False) page.evaluate("window.dispatchEvent(new Event('online'))") for _ in range(80): diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index ab6615b..1dfb824 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -1905,6 +1905,70 @@ async def test_gitea_handoff_replaces_operator_and_preserves_coassignees(): ] +@pytest.mark.anyio +async def test_gitea_reassigns_authored_issue_only_from_reviewed_delegate(): + 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 == "/api/v1/repos/stackchain/api/issues/17" and request.method == "GET": + return httpx.Response(200, json={ + "number": 17, "state": "open", "user": {"login": "timmy"}, + "assignees": [{"login": "alex"}], + }) + if 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": "Casey"}, + ]) + assert request.method == "PATCH" + assert json.loads(request.content) == {"assignees": ["casey"]} + return httpx.Response(200, json={ + "number": 17, "state": "open", "assignees": [{"login": "casey"}], + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.reassign_authored_issue( + "stackchain/api", 17, "casey", ["alex"] + ) + finally: + await gitea_proxy.stop_client() + + assert result == { + "repository": "stackchain/api", "number": 17, "state": "open", + "assignees": ["casey"], "recipient": "casey", "previous_assignees": ["alex"], + } + + +@pytest.mark.anyio +async def test_gitea_reassign_authored_issue_rejects_stale_delegate_without_patch(): + requests = [] + + async def handler(request): + requests.append((request.method, request.url.path)) + if request.url.path == "/api/v1/user": + return httpx.Response(200, json={"login": "timmy"}) + return httpx.Response(200, json={ + "number": 17, "state": "open", "user": {"login": "timmy"}, + "assignees": [{"login": "alex-new"}], + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + with pytest.raises(gitea_proxy.IssueNotAvailableError): + await gitea_proxy.reassign_authored_issue( + "stackchain/api", 17, "casey", ["alex"] + ) + finally: + await gitea_proxy.stop_client() + + assert not any(method == "PATCH" for method, _path in requests) + + @pytest.mark.anyio async def test_release_issue_endpoint_invalidates_available_work_and_returns_confirmation(monkeypatch): calls = [] @@ -1976,6 +2040,72 @@ async def test_issue_handoff_endpoints_list_candidates_and_confirm_transfer(monk ] +@pytest.mark.anyio +async def test_filed_reassignment_endpoints_authorize_author_and_preserve_reviewed_delegate(monkeypatch): + calls = [] + + async def authored(repository, number): + calls.append(("authored", repository, number)) + return True + + async def candidates(repository): + calls.append(("candidates", repository)) + return [{"login": "alex", "name": "Alexander"}, {"login": "casey", "name": "Casey"}] + + async def reassign(repository, number, recipient, expected_assignees): + calls.append(("reassign", repository, number, recipient, expected_assignees)) + return { + "repository": repository, "number": number, "state": "open", + "assignees": [recipient], "recipient": recipient, + "previous_assignees": expected_assignees, + } + + monkeypatch.setattr(main.gitea_proxy, "is_authored_issue", authored) + monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates) + monkeypatch.setattr(main.gitea_proxy, "reassign_authored_issue", reassign) + 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?access=filed" + ) + transferred = await client.patch( + "/api/v1/repos/stackchain/api/issues/17/reassign", + json={"recipient": "casey", "expected_assignees": ["alex"]}, + ) + + assert listed.status_code == 200 + assert listed.json() == [ + {"login": "alex", "name": "Alexander"}, + {"login": "casey", "name": "Casey"}, + ] + assert transferred.status_code == 200 + assert transferred.json()["previous_assignees"] == ["alex"] + assert calls == [ + ("authored", "stackchain/api", 17), + ("candidates", "stackchain/api"), + ("reassign", "stackchain/api", 17, "casey", ["alex"]), + ] + + +@pytest.mark.anyio +async def test_filed_reassignment_returns_conflict_when_delegate_changed(monkeypatch): + async def reassign(_repository, _number, _recipient, _expected_assignees): + raise gitea_proxy.IssueNotAvailableError("stale") + + monkeypatch.setattr(main.gitea_proxy, "reassign_authored_issue", reassign) + 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/reassign", + json={"recipient": "casey", "expected_assignees": ["alex"]}, + ) + + assert response.status_code == 409 + assert response.json() == { + "error": "The delegate changed. Reload before reassigning." + } + + @pytest.mark.anyio async def test_mention_candidate_endpoint_bounds_query_and_disables_caching(monkeypatch): calls = [] diff --git a/tests/test_my_work.py b/tests/test_my_work.py index a8ec0d7..b970614 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -1885,6 +1885,23 @@ async def test_mobile_filed_detail_exposes_only_a_withdrawal_action_for_open_del assert ".issue-sheet-actions button" in css and "min-height:44px" in css +@pytest.mark.anyio +async def test_mobile_filed_detail_exposes_change_delegate_flow_for_open_assigned_filing(): + source = await dashboard() + css = (ISSUE_SHEET.parent / "dashboard.css").read_text() + html = (ISSUE_SHEET.parent / "index.html").read_text() + + assert "const reassignable = item.is_filed && !item.is_completed && item.state === 'open' &&" in source + assert "classList.toggle('filed-reassignable', reassignable)" in source + assert "reassignable ? 'Change delegate' : 'Hand off to teammate'" in source + assert "issueController.reassign(selectedIssue, recipient)" in source + assert "Change delegate from @" in source and " to @" in source + assert "Assigned to ' + recipient" in source + assert "#issue-sheet.read-only.filed-reassignable #issue-handoff" in css + assert ".issue-handoff select, .issue-handoff button { min-height:44px; }" in css + assert 'id="issue-handoff-summary"' in html + + def test_issue_sheet_uses_author_access_for_filed_conversation_and_follow_up(): script = f""" const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); @@ -1931,6 +1948,57 @@ Promise.all([ ] +def test_issue_sheet_reassigns_filed_delegate_with_reviewed_owner_as_single_flight(): + script = f""" +const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); +const calls = []; +let finish; +const controller = createIssueSheet({{ + fetchJson: (url, options={{}}) => {{ + calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}}); + if (url.includes('handoff-candidates')) return Promise.resolve([ + {{login:'alex',name:'Alexander'}}, {{login:'casey',name:'Casey'}} + ]); + return new Promise(resolve => {{ finish = resolve; }}); + }}, storage:null, +}}); +const filed = {{repository:'stackchain/api',number:18,is_filed:true,is_assigned:false,assignees:['alex']}}; +(async () => {{ + const candidates = await controller.loadHandoffCandidates(filed); + const first = controller.reassign(filed, 'casey'); + const duplicate = controller.reassign(filed, 'casey'); + finish({{number:18,repository:'stackchain/api',state:'open',assignees:['casey'],recipient:'casey',previous_assignees:['alex']}}); + const results = await Promise.all([first, duplicate]); + process.stdout.write(JSON.stringify({{calls,candidates,same:first===duplicate,result:results[0]}})); +}})(); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + assert json.loads(result.stdout) == { + "calls": [ + { + "url": "api/v1/repos/stackchain/api/issues/18/handoff-candidates?access=filed", + "method": "GET", "body": None, + }, + { + "url": "api/v1/repos/stackchain/api/issues/18/reassign", + "method": "PATCH", + "body": {"recipient": "casey", "expected_assignees": ["alex"]}, + }, + ], + "candidates": [ + {"login": "alex", "name": "Alexander"}, + {"login": "casey", "name": "Casey"}, + ], + "same": True, + "result": { + "number": 18, "repository": "stackchain/api", "state": "open", + "assignees": ["casey"], "recipient": "casey", "previous_assignees": ["alex"], + }, + } + + def test_issue_release_is_single_flight_and_requires_confirmed_unassignment(): script = f""" const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});