feat: apply search release plans atomically (Closes #817)
All checks were successful
CI / lint (pull_request) Successful in 1m43s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-14 09:57:15 +00:00
parent 9a24aff537
commit 53539e0cdf
5 changed files with 184 additions and 25 deletions

View File

@ -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';
},

View File

@ -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,

View File

@ -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}"

View File

@ -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 = []

View File

@ -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",