feat: apply search release plans atomically (Closes #817)
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 committed 2026-08-14 09:57:15 +00:00
1 parent 9a24aff537
commit 53539e0cdf
5 files changed
+184 -25

No files matched your search

+52
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,
+43
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}"