diff --git a/README.md b/README.md index 40c6c0a..ff518f2 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ python3 -m pip install -r requirements.txt Point the dashboard at the Gitea server root (without `/api/v1`) and provide a token that can read dashboard data, update the authenticated user's notification -threads, create and self-assign issues, discover and claim open unassigned issues, +threads, create and self-assign issues, discover, claim, and release issue assignments, create issue comments, close assigned issues, inspect/comment on assigned pull requests, merge assigned pull requests, and submit pull-request reviews. Pull-request replies and mobile My Work issue and PR comments use Gitea's diff --git a/frontend/index.html b/frontend/index.html index 2e45fb1..e165a9d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -353,6 +353,7 @@ textarea { resize: vertical; min-height: 120px; }
+ Open in Gitea
@@ -1718,6 +1719,27 @@ textarea { resize: vertical; min-height: 120px; } button.disabled = false; } }); + qs('#release-issue').addEventListener('click', async () => { + if (!selectedIssue || !window.confirm('Release ' + selectedIssue.key + ' from your My Work?')) return; + const releasing = selectedIssue; + const button = qs('#release-issue'); + button.disabled = true; + qs('#issue-sheet-status').textContent = 'Releasing assignment…'; + try { + const confirmed = await issueController.release(selectedIssue, lastContextSnapshot?.user?.login); + lastContextSnapshot = buildMyWork.removeIssue( + lastContextSnapshot, releasing.repository, releasing.number + ); + paintMyWork(lastContextSnapshot); + closeIssueSheet(); + qs('#my-work-action-status').textContent = releasing.key + ' released.' + + (confirmed.available ? ' It is available in Find Work.' : ' Other assignees remain.'); + } catch (error) { + qs('#issue-sheet-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 93bd21f..9c6919e 100644 --- a/frontend/issue-sheet.js +++ b/frontend/issue-sheet.js @@ -1,6 +1,7 @@ function createIssueSheet({ fetchJson, storage }) { let commentRequest = null; let closeRequest = null; + let releaseRequest = null; let labelRequest = null; const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') + '/issues/' + encodeURIComponent(item.number); @@ -61,6 +62,23 @@ function createIssueSheet({ fetchJson, storage }) { }).finally(() => { closeRequest = null; }); return closeRequest; }, + release(item, currentLogin) { + if (releaseRequest) return releaseRequest; + releaseRequest = fetchJson(issuePath(item) + '/release', { + method: 'PATCH', + headers: { Accept: 'application/json' }, + }).then(result => { + if ( + result?.number !== item.number || + !Array.isArray(result.assignees) || + result.assignees.includes(currentLogin) + ) { + throw new Error('Issue release was not confirmed.'); + } + return result; + }).finally(() => { releaseRequest = null; }); + return releaseRequest; + }, comment(item, body) { if (commentRequest) return commentRequest; this.saveDraft(item, body); diff --git a/frontend/my-work.js b/frontend/my-work.js index 5b67fd4..e385030 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -335,6 +335,15 @@ function replaceIssueLabels(data, repository, number, labels) { }; } +function removeIssue(data, repository, number) { + return { + ...data, + issues: (data.issues || []).filter(item => + item.repository !== repository || item.number !== number + ), + }; +} + function summarizeMyWork(items) { const updates = items.filter((item) => item.has_update).length; const reviews = items.filter((item) => item.is_review).length; @@ -358,6 +367,7 @@ function countMyWork(items) { if (typeof module !== 'undefined' && module.exports) { buildMyWork.filterMyWork = filterMyWork; buildMyWork.replaceIssueLabels = replaceIssueLabels; + buildMyWork.removeIssue = removeIssue; buildMyWork.summarizeMyWork = summarizeMyWork; buildMyWork.countMyWork = countMyWork; buildMyWork.acknowledgeNotification = acknowledgeNotification; diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 414e00e..513c316 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -654,6 +654,53 @@ async def claim_available_issue(repository: str, number: int) -> dict: } +async def release_assigned_issue(repository: str, number: int) -> dict: + login, issue = await _current_login_and_target( + f"repos/{repository}/issues/{number}" + ) + assignees_value = issue.get("assignees") + if ( + issue.get("state") != "open" + or issue.get("pull_request") is not None + or not _login_in_users(login, assignees_value) + ): + raise IssueNotAvailableError("Issue is not assigned to the current user") + + assignees = assignees_value if isinstance(assignees_value, list) else [] + remaining = [ + assignee["login"] for assignee in assignees + if isinstance(assignee, dict) + and isinstance(assignee.get("login"), str) + and assignee["login"] != login + ] + response = await _get_client().patch( + f"/api/v1/repos/{repository}/issues/{number}", + headers=_auth(), + json={"assignees": remaining}, + ) + response.raise_for_status() + confirmed = response.json() + confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None + confirmed_assignees = [ + assignee["login"] for assignee in confirmed_value + if isinstance(assignee, dict) and isinstance(assignee.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 set(confirmed_assignees) != set(remaining) + ): + raise ValueError("Gitea did not confirm issue release") + return { + "number": number, + "repository": repository, + "state": confirmed.get("state", "open"), + "assignees": confirmed_assignees, + "available": not confirmed_assignees, + } + + 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 ec615f5..8768242 100644 --- a/src/main.py +++ b/src/main.py @@ -959,6 +959,31 @@ async def claim_available_issue( return JSONResponse(result) +@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/release") +async def release_assigned_issue( + owner: str, repo: str, number: int = PathParam(gt=0) +) -> JSONResponse: + global _available_issue_snapshot_value, _available_issue_snapshot_created_at + repository = f"{owner}/{repo}" + try: + result = await asyncio.wait_for( + gitea_proxy.release_assigned_issue(repository, number), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, + ) + except gitea_proxy.IssueNotAvailableError: + raise HTTPException(status_code=404, detail="Assigned issue not found") + except Exception: + return JSONResponse( + {"error": "The assignment could not be released. It remains in My Work; please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + if result.get("available"): + _available_issue_snapshot_value = None + _available_issue_snapshot_created_at = None + 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 60ba6e0..dd8a3e0 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -460,6 +460,68 @@ async def test_claim_available_issue_endpoint_reports_assignment_race_as_conflic } +@pytest.mark.anyio +async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_peers(): + requests = [] + + async def handler(request): + requests.append(request) + if request.method == "GET" and request.url.path.endswith("/issues/17"): + return httpx.Response(200, json={ + "id": 81, "number": 17, "title": "Shared work", "state": "open", + "pull_request": None, + "assignees": [{"login": "timmy"}, {"login": "alex"}], + "labels": [], + }) + if request.method == "GET" and request.url.path == "/api/v1/user": + return httpx.Response(200, json={"login": "timmy"}) + return httpx.Response(200, json={ + "id": 81, "number": 17, "title": "Shared work", "state": "open", + "assignees": [{"login": "alex"}], "labels": [], + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.release_assigned_issue("stackchain/api", 17) + finally: + await gitea_proxy.stop_client() + + assert [(request.method, request.url.path) for request in requests] == [ + ("GET", "/api/v1/user"), + ("GET", "/api/v1/repos/stackchain/api/issues/17"), + ("PATCH", "/api/v1/repos/stackchain/api/issues/17"), + ] + assert requests[2].content == b'{"assignees":["alex"]}' + assert result["assignees"] == ["alex"] + assert result["available"] is False + + +@pytest.mark.anyio +async def test_release_issue_endpoint_invalidates_available_work_and_returns_confirmation(monkeypatch): + calls = [] + + async def release(repository, number): + calls.append((repository, number)) + return { + "number": number, "repository": repository, "state": "open", + "assignees": [], "available": True, + } + + monkeypatch.setattr(main.gitea_proxy, "release_assigned_issue", release, raising=False) + monkeypatch.setattr(main, "_available_issue_snapshot_value", [{"number": 99}]) + monkeypatch.setattr(main, "_available_issue_snapshot_created_at", 123.0) + 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/release") + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.json()["available"] is True + assert calls == [("stackchain/api", 17)] + assert main._available_issue_snapshot_value is None + assert main._available_issue_snapshot_created_at is None + + @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 d8bb841..f0bf192 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -110,6 +110,51 @@ process.stdout.write(JSON.stringify({{ } +def test_issue_release_is_single_flight_and_requires_confirmed_unassignment(): + script = f""" +const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); +let calls = 0; +let finish; +const controller = createIssueSheet({{ + fetchJson: () => {{ + calls += 1; + return new Promise(resolve => {{ finish = resolve; }}); + }}, + storage: null, +}}); +const item = {{repository:'stackchain/api', number:17}}; +const first = controller.release(item, 'timmy'); +const duplicate = controller.release(item, 'timmy'); +finish({{number:17, repository:'stackchain/api', assignees:[], available:true}}); +Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{ + calls, same: first === duplicate, results +}}))); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + output = json.loads(result.stdout) + assert output["calls"] == 1 + assert output["same"] is True + assert output["results"][0]["available"] is True + + +def test_issue_release_rejects_response_that_still_assigns_current_user(): + script = f""" +const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); +const controller = createIssueSheet({{ + fetchJson: async () => ({{number:17, assignees:['timmy']}}), storage: null, +}}); +controller.release({{repository:'stackchain/api', number:17}}, 'timmy') + .then(() => process.stdout.write('unexpected')) + .catch(error => process.stdout.write(error.message)); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + assert result.stdout == "Issue release was not confirmed." + + def test_my_work_reviews_filter_and_summary_are_actionable(): items = [ {"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True}, @@ -1221,6 +1266,40 @@ async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mut assert "e.key === 'Escape' && selectedIssue" in html +@pytest.mark.anyio +async def test_mobile_issue_sheet_releases_assignment_with_confirmed_local_removal(): + html = await dashboard() + + assert 'id="release-issue"' in html + assert 'Release assignment' in html + assert "window.confirm('Release ' + selectedIssue.key + ' from your My Work?')" in html + assert "issueController.release(selectedIssue, lastContextSnapshot?.user?.login)" in html + assert "buildMyWork.removeIssue(" in html + + +def test_remove_issue_updates_snapshot_without_mutating_other_work(): + payload = { + "issues": [ + {"repository": "stackchain/api", "number": 17}, + {"repository": "stackchain/web", "number": 18}, + ], + "pull_requests": [{"repository": "stackchain/api", "number": 17}], + } + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +const original = {json.dumps(payload)}; +const updated = buildMyWork.removeIssue(original, 'stackchain/api', 17); +process.stdout.write(JSON.stringify({{updated, original}})); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + output = json.loads(result.stdout) + assert output["updated"]["issues"] == [{"repository": "stackchain/web", "number": 18}] + assert output["updated"]["pull_requests"] == payload["pull_requests"] + assert output["original"]["issues"] == payload["issues"] + + @pytest.mark.anyio async def test_mobile_issue_sheet_edits_labels_and_repaints_confirmed_priority(): html = await dashboard()