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) => {
|
queue:async (confirmed, context) => {
|
||||||
const issuePath = 'api/v1/repos/' + confirmed.repository.split('/').map(encodeURIComponent).join('/') +
|
const issuePath = 'api/v1/repos/' + confirmed.repository.split('/').map(encodeURIComponent).join('/') +
|
||||||
'/issues/' + encodeURIComponent(confirmed.number);
|
'/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'},
|
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) {
|
if (releasePlan?.number !== confirmed.number ||
|
||||||
throw new Error('Issue milestone was not confirmed.');
|
releasePlan?.milestone?.id !== context.milestone_id ||
|
||||||
}
|
(context.due_date && releasePlan?.due_date !== context.due_date)) {
|
||||||
if (context.due_date) {
|
throw new Error('Issue release plan was not confirmed.');
|
||||||
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.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return 'queued';
|
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(
|
async def issue_conversation_page(
|
||||||
repository: str,
|
repository: str,
|
||||||
number: int,
|
number: int,
|
||||||
|
|
|
||||||
43
src/main.py
43
src/main.py
|
|
@ -763,6 +763,15 @@ class IssueMilestoneUpdate(BaseModel):
|
||||||
milestone_id: PositiveInt | None = None
|
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):
|
class IssueHandoff(BaseModel):
|
||||||
recipient: str = Field(
|
recipient: str = Field(
|
||||||
min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_.-]+$"
|
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)
|
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")
|
@app.get("/api/v1/repos/{owner}/{repo}/milestones")
|
||||||
async def repository_milestones(owner: str, repo: str):
|
async def repository_milestones(owner: str, repo: str):
|
||||||
repository = f"{owner}/{repo}"
|
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
|
@pytest.mark.anyio
|
||||||
async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch):
|
async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
|
||||||
|
|
@ -293,8 +293,9 @@ const fetchJson=(url,options={{}})=>{{
|
||||||
calls.push({{url,method:options.method || 'GET',body:options.body ? JSON.parse(options.body) : null}});
|
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'}}]);
|
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]);
|
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('/release-plan')) return Promise.resolve({{
|
||||||
if (url.endsWith('/due-date')) return Promise.resolve({{number,due_date:'2026-08-31T23:59:59Z'}});
|
number,milestone:{{id:9,title:'August RC'}},due_date:'2026-08-31T23:59:59Z'
|
||||||
|
}});
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}};
|
}};
|
||||||
const processorOptions=[];
|
const processorOptions=[];
|
||||||
|
|
@ -346,16 +347,11 @@ listeners['plan-selected-search-results:click']().then(async()=>{{
|
||||||
"due_date": "2026-08-31T23:59:59Z",
|
"due_date": "2026-08-31T23:59:59Z",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
assert result["calls"][2:] == [
|
assert result["calls"][2:] == [{
|
||||||
{
|
"url": "api/v1/repos/stackchain/dashboard/issues/815/release-plan",
|
||||||
"url": "api/v1/repos/stackchain/dashboard/issues/815/milestone",
|
"method": "PATCH",
|
||||||
"method": "PATCH", "body": {"milestone_id": 9},
|
"body": {"milestone_id": 9, "due_date": "2026-08-31T23:59:59Z"},
|
||||||
},
|
}]
|
||||||
{
|
|
||||||
"url": "api/v1/repos/stackchain/dashboard/issues/815/due-date",
|
|
||||||
"method": "PATCH", "body": {"due_date": "2026-08-31T23:59:59Z"},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
assert result["queued"] == "queued"
|
assert result["queued"] == "queued"
|
||||||
assert result["journals"] == [
|
assert result["journals"] == [
|
||||||
"search-today-batch", "search-later-batch", "search-release-batch",
|
"search-today-batch", "search-later-batch", "search-release-batch",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user