diff --git a/frontend/search-batch-plan.js b/frontend/search-batch-plan.js index 839d15d..360449c 100644 --- a/frontend/search-batch-plan.js +++ b/frontend/search-batch-plan.js @@ -169,21 +169,14 @@ queue:async (confirmed, context) => { const issuePath = 'api/v1/repos/' + confirmed.repository.split('/').map(encodeURIComponent).join('/') + '/issues/' + encodeURIComponent(confirmed.number); - const milestone = await fetchJson(issuePath + '/milestone', { + const releasePlan = await fetchJson(issuePath + '/release-plan', { method:'PATCH', headers:{Accept:'application/json','Content-Type':'application/json'}, - body:JSON.stringify({milestone_id:context.milestone_id}), + body:JSON.stringify({milestone_id:context.milestone_id, due_date:context.due_date || null}), }); - if (milestone?.number !== confirmed.number || milestone?.milestone?.id !== context.milestone_id) { - throw new Error('Issue milestone was not confirmed.'); - } - if (context.due_date) { - const deadline = await fetchJson(issuePath + '/due-date', { - method:'PATCH', headers:{Accept:'application/json','Content-Type':'application/json'}, - body:JSON.stringify({due_date:context.due_date}), - }); - if (deadline?.number !== confirmed.number || deadline?.due_date !== context.due_date) { - throw new Error('Issue due date was not confirmed.'); - } + if (releasePlan?.number !== confirmed.number || + releasePlan?.milestone?.id !== context.milestone_id || + (context.due_date && releasePlan?.due_date !== context.due_date)) { + throw new Error('Issue release plan was not confirmed.'); } return 'queued'; }, diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index b79d807..6ac0d4e 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -1969,6 +1969,58 @@ async def update_assigned_issue_milestone( } +async def update_assigned_issue_release_plan( + repository: str, number: int, milestone_id: int, due_date: str | None +) -> dict: + path = f"repos/{repository}/issues/{number}" + login, issue = await _current_login_and_target(path) + if ( + issue.get("state") != "open" + or isinstance(issue.get("pull_request"), dict) + or not _login_in_users(login, issue.get("assignees")) + ): + raise IssueNotAvailableError("assigned issue not found") + + selected = next( + (item for item in await repo_milestones(repository) if item["id"] == milestone_id), + None, + ) + if selected is None: + raise ValueError("Unknown open repository milestone") + + payload: dict = {"milestone": milestone_id} + if due_date is not None: + payload["due_date"] = due_date + response = await _get_client().patch( + f"/api/v1/{path}", headers=_auth(), json=payload + ) + response.raise_for_status() + confirmed = response.json() + milestone_value = confirmed.get("milestone") if isinstance(confirmed, dict) else None + normalized = ( + {"id": milestone_value["id"], "title": milestone_value["title"]} + if isinstance(milestone_value, dict) + and isinstance(milestone_value.get("id"), int) + and isinstance(milestone_value.get("title"), str) + else None + ) + expected_due_date = due_date if due_date is not None else issue.get("due_date") + if ( + not isinstance(confirmed, dict) + or confirmed.get("number") != number + or normalized != selected + or confirmed.get("due_date") != expected_due_date + ): + raise ValueError("Gitea did not confirm the issue release plan") + return { + "repository": repository, + "number": number, + "state": confirmed.get("state", "open"), + "milestone": normalized, + "due_date": confirmed.get("due_date"), + } + + async def issue_conversation_page( repository: str, number: int, diff --git a/src/main.py b/src/main.py index 4c218fa..1a2781a 100644 --- a/src/main.py +++ b/src/main.py @@ -763,6 +763,15 @@ class IssueMilestoneUpdate(BaseModel): milestone_id: PositiveInt | None = None +class IssueReleasePlanUpdate(BaseModel): + milestone_id: PositiveInt + due_date: str | None = Field( + default=None, + pattern=r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", + max_length=20, + ) + + class IssueHandoff(BaseModel): recipient: str = Field( min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_.-]+$" @@ -4374,6 +4383,40 @@ async def update_assigned_issue_milestone( return JSONResponse(result) +@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/release-plan") +async def update_assigned_issue_release_plan( + update: IssueReleasePlanUpdate, + owner: str, + repo: str, + number: int = PathParam(gt=0), +): + repository = f"{owner}/{repo}" + try: + result = await asyncio.wait_for( + gitea_proxy.update_assigned_issue_release_plan( + repository, number, update.milestone_id, update.due_date + ), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, + ) + except gitea_proxy.IssueNotAvailableError: + raise HTTPException(status_code=404, detail="Assigned issue not found") + except ValueError as exc: + if str(exc) == "Unknown open repository milestone": + raise HTTPException(status_code=422, detail=str(exc)) + return JSONResponse( + {"error": "The release plan could not be confirmed. Your selection is safe; please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + except Exception: + return JSONResponse( + {"error": "The release plan could not be updated. Your selection is safe; please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse(result) + + @app.get("/api/v1/repos/{owner}/{repo}/milestones") async def repository_milestones(owner: str, repo: str): repository = f"{owner}/{repo}" diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index 5472a5d..b411ba3 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -215,6 +215,81 @@ async def test_gitea_milestone_update_revalidates_assignment_and_open_repository } +@pytest.mark.anyio +@pytest.mark.parametrize("due_date", ["2026-08-31T23:59:59Z", None]) +async def test_release_plan_applies_milestone_and_optional_deadline_in_one_patch(due_date): + requests = [] + existing_due_date = "2026-08-20T23:59:59Z" + + async def handler(request): + requests.append(request) + if request.url.path == "/api/v1/user": + return httpx.Response(200, json={"login": "timmy"}) + if request.method == "GET" and request.url.path.endswith("/issues/17"): + return httpx.Response(200, json={ + "number": 17, "state": "open", "pull_request": None, + "assignees": [{"login": "timmy"}], "due_date": existing_due_date, + }) + if request.method == "GET" and request.url.path.endswith("/milestones"): + return httpx.Response(200, json=[ + {"id": 9, "title": "August RC", "state": "open"}, + ]) + return httpx.Response(200, json={ + "number": 17, "state": "open", + "milestone": {"id": 9, "title": "August RC"}, + "due_date": due_date or existing_due_date, + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.update_assigned_issue_release_plan( + "stackchain/api", 17, 9, due_date + ) + finally: + await gitea_proxy.stop_client() + + patch_requests = [request for request in requests if request.method == "PATCH"] + assert len(patch_requests) == 1 + expected_payload = {"milestone": 9} + if due_date: + expected_payload["due_date"] = due_date + assert json.loads(patch_requests[0].content) == expected_payload + assert result == { + "repository": "stackchain/api", "number": 17, "state": "open", + "milestone": {"id": 9, "title": "August RC"}, + "due_date": due_date or existing_due_date, + } + + +@pytest.mark.anyio +async def test_release_plan_endpoint_is_bounded_and_returns_confirmed_plan(monkeypatch): + calls = [] + + async def update(repository, number, milestone_id, due_date): + calls.append((repository, number, milestone_id, due_date)) + return { + "repository": repository, "number": number, "state": "open", + "milestone": {"id": milestone_id, "title": "August RC"}, + "due_date": due_date, + } + + monkeypatch.setattr( + main.gitea_proxy, "update_assigned_issue_release_plan", update, raising=False + ) + 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-plan", + json={"milestone_id": 9, "due_date": "2026-08-31T23:59:59Z"}, + ) + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.json()["milestone"] == {"id": 9, "title": "August RC"} + assert response.json()["due_date"] == "2026-08-31T23:59:59Z" + assert calls == [("stackchain/api", 17, 9, "2026-08-31T23:59:59Z")] + + @pytest.mark.anyio async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch): calls = [] diff --git a/tests/test_search_batch_plan.py b/tests/test_search_batch_plan.py index 848fd8e..1e0664b 100644 --- a/tests/test_search_batch_plan.py +++ b/tests/test_search_batch_plan.py @@ -293,8 +293,9 @@ const fetchJson=(url,options={{}})=>{{ calls.push({{url,method:options.method || 'GET',body:options.body ? JSON.parse(options.body) : null}}); if (url.endsWith('/milestones')) return Promise.resolve([{{id:9,title:'August RC'}},{{id:7,title:'Old',state:'closed'}}]); const number=Number(url.split('/issues/')[1].split('/')[0]); - if (url.endsWith('/milestone')) return Promise.resolve({{number,milestone:{{id:9,title:'August RC'}}}}); - if (url.endsWith('/due-date')) return Promise.resolve({{number,due_date:'2026-08-31T23:59:59Z'}}); + if (url.endsWith('/release-plan')) return Promise.resolve({{ + number,milestone:{{id:9,title:'August RC'}},due_date:'2026-08-31T23:59:59Z' + }}); return Promise.resolve(); }}; const processorOptions=[]; @@ -346,16 +347,11 @@ listeners['plan-selected-search-results:click']().then(async()=>{{ "due_date": "2026-08-31T23:59:59Z", }, } - assert result["calls"][2:] == [ - { - "url": "api/v1/repos/stackchain/dashboard/issues/815/milestone", - "method": "PATCH", "body": {"milestone_id": 9}, - }, - { - "url": "api/v1/repos/stackchain/dashboard/issues/815/due-date", - "method": "PATCH", "body": {"due_date": "2026-08-31T23:59:59Z"}, - }, - ] + assert result["calls"][2:] == [{ + "url": "api/v1/repos/stackchain/dashboard/issues/815/release-plan", + "method": "PATCH", + "body": {"milestone_id": 9, "due_date": "2026-08-31T23:59:59Z"}, + }] assert result["queued"] == "queued" assert result["journals"] == [ "search-today-batch", "search-later-batch", "search-release-batch",