From 6a03dfd7acce863574590e8616ad019407f49fc7 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 10:00:46 +0000 Subject: [PATCH 1/2] feat: withdraw delegated issues from Filed (Closes #884) --- frontend/dashboard.css | 1 - frontend/dashboard.js | 23 +++++++++++----- frontend/issue-sheet.js | 3 ++- src/frontend_bundle.py | 2 +- src/gitea_proxy.py | 17 +++++++++++- src/main.py | 18 ++++++++++--- tests/test_issue_api.py | 54 +++++++++++++++++++++++++++++++++++++ tests/test_my_work.py | 60 ++++++++++++++++++++++++++++++++++++++--- 8 files changed, 159 insertions(+), 19 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index d90998e..bca02a6 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -535,7 +535,6 @@ textarea { resize: vertical; min-height: 120px; } #issue-sheet.read-only #issue-planning, #issue-sheet.read-only #issue-handoff, #issue-sheet.read-only #release-issue, -#issue-sheet.read-only #close-issue, #issue-sheet.read-only .detail-defer, #issue-sheet.read-only #issue-blockers, #issue-sheet.read-only #retry-issue-comment-actions { display:none; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 2b91b08..d0730b4 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -3352,6 +3352,7 @@ async function openIssueSheet(item, trigger, offlineDetail = null) { if (!item) return; const readOnly = issueController.readOnly(item); + const withdrawable = item.is_filed && !item.is_assigned && !item.is_completed && item.state === 'open'; qs('#issue-sheet').classList.toggle('read-only', readOnly); issueDetailPosition.open(workDetailIdentity('issue', item)); qs('#issue-planning').inert = false; @@ -3411,7 +3412,8 @@ qs('#issue-edit-form').hidden = true; qs('#issue-edit-status').textContent = ''; qs('#close-issue').disabled = false; - qs('#close-issue').textContent = offlineDetail ? + qs('#close-issue').hidden = readOnly && !withdrawable; + qs('#close-issue').textContent = withdrawable ? 'Withdraw issue' : offlineDetail ? (workSession.active() ? 'Queue close & next' : 'Queue issue closure') : (workSession.active() ? 'Close & next' : 'Close issue'); qs('#release-issue').textContent = workSession.checkpointed(item) ? 'Release & next' : 'Release assignment'; @@ -5914,7 +5916,12 @@ } }); qs('#close-issue').addEventListener('click', async () => { - if (!selectedIssue || !window.confirm('Close ' + selectedIssue.key + '?')) return; + if (!selectedIssue) return; + const withdrawing = selectedIssue.is_filed && !selectedIssue.is_assigned; + const confirmation = withdrawing ? + 'Withdraw ' + selectedIssue.key + '? This closes the delegated request.' : + 'Close ' + selectedIssue.key + '?'; + if (!window.confirm(confirmation)) return; const closing = selectedIssue; const button = qs('#close-issue'); button.disabled = true; @@ -5940,11 +5947,13 @@ } qs('#issue-sheet-status').textContent = 'Closing issue…'; try { - await issueController.close(selectedIssue); + const result = await issueController.close(selectedIssue); closeIssueSheet(); - lastMyWork = lastMyWork.filter(item => - !(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number) - ); + if (withdrawing) Object.assign(closing, { + state:'closed',is_completed:true,updated_at:result.updated_at || closing.updated_at, + }); + else lastMyWork = lastMyWork.filter(item => + !(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number)); refreshMyWorkView({ reconcileSession:false }); const continuingSession = workSession.active(); const transitionResult = workSession.active() ? await runTodayTransition('complete') : null; @@ -5953,7 +5962,7 @@ } else if (transitionResult === 'gated') { qs('#my-work-action-status').textContent = closing.key + ' closed. Choose the next ready Today item.'; } else if (!continuingSession) { - qs('#my-work-action-status').textContent = closing.key + ' closed.'; + qs('#my-work-action-status').textContent = closing.key + (withdrawing ? ' withdrawn.' : ' closed.'); } } catch (error) { qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.'; diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js index 8b6d671..275d872 100644 --- a/frontend/issue-sheet.js +++ b/frontend/issue-sheet.js @@ -214,7 +214,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global }, close(item) { if (closeRequest) return closeRequest; - closeRequest = fetchJson(issuePath(item) + '/close', { + const access = item?.is_filed && !item?.is_assigned ? '?access=filed' : ''; + closeRequest = fetchJson(issuePath(item) + '/close' + access, { method: 'PATCH', headers: { Accept: 'application/json' }, }).then(result => { diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 98f9d29..8544c3c 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -36,7 +36,7 @@ FEATURE_SOURCES = { "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", "static/today-work.js", "static/pick-work.js", "static/batch-find-work.js", "static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js", - "static/issue-attachment.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js", + "static/issue-attachment.js", "static/issue-sheet.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js", ), } CACHE_DECLARATION = re.compile( diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 1f8b80a..4afa07b 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -1253,13 +1253,16 @@ async def close_issue(repository: str, number: int) -> dict: issue = response.json() if not isinstance(issue, dict) or issue.get("state") != "closed": raise ValueError("Gitea did not confirm issue closure") - return { + result = { "number": issue.get("number"), "state": "closed", "closed_at": issue.get("closed_at", "") if isinstance(issue.get("closed_at"), str) else "", } + if isinstance(issue.get("updated_at"), str): + result["updated_at"] = issue["updated_at"] + return result def _normalize_issue_comment(comment: dict) -> dict: @@ -2423,6 +2426,18 @@ async def is_authored_issue(repository: str, number: int) -> bool: ) +async def is_open_authored_issue(repository: str, number: int) -> bool: + login, issue = await _current_login_and_target( + f"repos/{repository}/issues/{number}" + ) + author = issue.get("user") if isinstance(issue.get("user"), dict) else {} + return ( + issue.get("state") == "open" + and not isinstance(issue.get("pull_request"), dict) + and author.get("login") == login + ) + + async def pull_requests() -> WorkItems: assigned, review_requested = await asyncio.gather( work_page("pull"), diff --git a/src/main.py b/src/main.py index 1569171..df89325 100644 --- a/src/main.py +++ b/src/main.py @@ -5296,6 +5296,7 @@ async def close_assigned_issue( owner: str, repo: str, number: int = PathParam(gt=0), + access: Literal["assigned", "filed"] = "assigned", step_up_grant: str | None = Header( default=None, alias="X-Step-Up-Grant", max_length=128 ), @@ -5310,12 +5311,21 @@ async def close_assigned_issue( target = f"{repository}#{number}" try: - assigned = await asyncio.wait_for( - gitea_proxy.is_assigned_issue(repository, number), + authorized = await asyncio.wait_for( + ( + gitea_proxy.is_open_authored_issue(repository, number) + if access == "filed" + else gitea_proxy.is_assigned_issue(repository, number) + ), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) - if not assigned: - raise HTTPException(status_code=404, detail="Assigned issue not found") + if not authorized: + detail = ( + "Open authored issue not found" + if access == "filed" + else "Assigned issue not found" + ) + raise HTTPException(status_code=404, detail=detail) journal = _security_event_store() operation_id = await asyncio.to_thread( journal.reserve, diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index 306ea92..128e14f 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -2245,6 +2245,60 @@ async def test_issue_close_endpoint_mutates_assigned_issue_only_after_confirmati assert calls == [("stackchain/api", 7)] +@pytest.mark.anyio +async def test_issue_close_endpoint_withdraws_only_open_authored_filed_issue(monkeypatch): + checks = [] + calls = [] + + async def authored(repository, number): + checks.append((repository, number)) + return True + + async def close(repository, number): + calls.append((repository, number)) + return { + "number": number, + "state": "closed", + "updated_at": "2026-08-15T10:00:00Z", + } + + monkeypatch.setattr(main.gitea_proxy, "is_open_authored_issue", authored) + monkeypatch.setattr(main.gitea_proxy, "close_issue", close) + 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/7/close?access=filed" + ) + + assert response.status_code == 200 + assert response.json()["state"] == "closed" + assert checks == [("stackchain/api", 7)] + assert calls == [("stackchain/api", 7)] + + +@pytest.mark.anyio +async def test_issue_close_endpoint_does_not_withdraw_non_authored_or_closed_filed_issue(monkeypatch): + close_calls = [] + + async def authored(_repository, _number): + return False + + async def close(repository, number): + close_calls.append((repository, number)) + return {"number": number, "state": "closed"} + + monkeypatch.setattr(main.gitea_proxy, "is_open_authored_issue", authored) + monkeypatch.setattr(main.gitea_proxy, "close_issue", close) + 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/7/close?access=filed" + ) + + assert response.status_code == 404 + assert close_calls == [] + + @pytest.mark.anyio async def test_gitea_close_issue_patches_state_and_confirms_closed_response(): requests = [] diff --git a/tests/test_my_work.py b/tests/test_my_work.py index fc57a76..154eeb0 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -1557,7 +1557,7 @@ async def test_dashboard_opens_delegated_filings_with_follow_up_only_capabilitie assert "#issue-sheet.read-only #issue-planning" in html assert "#issue-sheet.read-only #issue-handoff" in html assert "#issue-sheet.read-only #release-issue" in html - assert "#issue-sheet.read-only #close-issue" in html + assert "qs('#close-issue').hidden = readOnly && !withdrawable" in html assert "#issue-sheet.read-only .detail-defer" in html assert "if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);" in html assert "if (readOnly) qs('#load-older-issue-comments').hidden = true;" not in html @@ -1834,6 +1834,57 @@ Promise.all([ ] +def test_issue_sheet_withdraws_filed_item_through_author_access_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}}); + return new Promise(resolve => {{ finish = resolve; }}); + }}, + storage:null, +}}); +const filed = {{kind:'filed',repository:'stackchain/api',number:18,is_filed:true,is_assigned:false}}; +const first = controller.close(filed); +const duplicate = controller.close(filed); +finish({{number:18,state:'closed',updated_at:'2026-08-15T10:00:00Z'}}); +Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{ + calls, same:first === duplicate, state:results[0].state +}}))); +""" + 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/close?access=filed", + "method": "PATCH", + }], + "same": True, + "state": "closed", + } + + +@pytest.mark.anyio +async def test_mobile_filed_detail_exposes_only_a_withdrawal_action_for_open_delegated_issue(): + source = await dashboard() + css = (ISSUE_SHEET.parent / "dashboard.css").read_text() + + assert "const withdrawable = item.is_filed && !item.is_assigned && !item.is_completed && item.state === 'open';" in source + assert "qs('#close-issue').hidden = readOnly && !withdrawable" in source + assert "withdrawable ? 'Withdraw issue'" in source + close_handler = source.split("qs('#close-issue').addEventListener('click'", 1)[1].split( + "qs('#close-pull-sheet').addEventListener", 1 + )[0] + assert "Withdraw " in close_handler + assert "closes the delegated request" in close_handler + assert "is_completed:true" in close_handler + assert "#issue-sheet.read-only #issue-planning" in css + assert ".issue-sheet-actions button" in css and "min-height:44px" in css + + def test_issue_sheet_uses_author_access_for_filed_conversation_and_follow_up(): script = f""" const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); @@ -3965,7 +4016,7 @@ async def test_closing_issue_advances_active_session_once_and_exposes_close_and_ assert close_handler.index("await issueController.close(selectedIssue)") < close_handler.index( "await runTodayTransition('complete')" ) - assert close_handler.index("lastMyWork = lastMyWork.filter") < close_handler.index( + assert close_handler.index("lastMyWork.filter") < close_handler.index( "await runTodayTransition('complete')" ) @@ -6905,9 +6956,10 @@ async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mut assert '' in html assert "issueController.load(item)" in html assert "issueController.comment(selectedIssue" in html - assert "window.confirm('Close ' + selectedIssue.key + '?')" in html + assert "'Close ' + selectedIssue.key + '?'" in html + assert "window.confirm(confirmation)" in html assert "issueController.close(selectedIssue)" in html - assert "lastMyWork = lastMyWork.filter" in html + assert "lastMyWork.filter" in html assert "if (issueTrigger?.isConnected) issueTrigger.focus()" in html assert "e.key === 'Escape' && selectedIssue" in html -- 2.43.0 From e64541463ce7522b7742a415a0ce6b33791dc208 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 10:04:10 +0000 Subject: [PATCH 2/2] test: cover Filed withdrawal authorization --- tests/test_work_route_resolver.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_work_route_resolver.py b/tests/test_work_route_resolver.py index 5971b40..d6e397c 100644 --- a/tests/test_work_route_resolver.py +++ b/tests/test_work_route_resolver.py @@ -202,6 +202,33 @@ async def test_completed_filed_issue_remains_authorized_for_detail_and_reply(): assert authorized is True +@pytest.mark.anyio +@pytest.mark.parametrize( + ("state", "author", "authorized"), + [("open", "timmy", True), ("closed", "timmy", False), ("open", "alex", False)], +) +async def test_only_open_authored_issues_are_authorized_for_withdrawal( + state, author, authorized +): + async def upstream(request): + if request.url.path.endswith("/user"): + return httpx.Response(200, json={"login": "timmy"}) + return httpx.Response(200, json={ + "state": state, + "user": {"login": author}, + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(upstream)) + try: + result = await gitea_proxy.is_open_authored_issue( + "stackchain/dashboard", 89 + ) + finally: + await gitea_proxy.stop_client() + + assert result is authorized + + @pytest.mark.anyio async def test_resolve_work_route_rejects_a_notification_that_is_already_read(): async def upstream(request): -- 2.43.0