feat: apply search release plans atomically (Closes #817)
This commit is contained in:
parent
9a24aff537
commit
53539e0cdf
|
|
@ -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';
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
src/main.py
43
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}"
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user