Compare commits
No commits in common. "0c71dea00c476d3db623af51d40844ef78066bea" and "9a24aff5372d482ae84b5a42f5ab6a9796d5a75e" have entirely different histories.
0c71dea00c
...
9a24aff537
|
|
@ -169,14 +169,21 @@
|
||||||
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 releasePlan = await fetchJson(issuePath + '/release-plan', {
|
const milestone = await fetchJson(issuePath + '/milestone', {
|
||||||
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, due_date:context.due_date || null}),
|
body:JSON.stringify({milestone_id:context.milestone_id}),
|
||||||
});
|
});
|
||||||
if (releasePlan?.number !== confirmed.number ||
|
if (milestone?.number !== confirmed.number || milestone?.milestone?.id !== context.milestone_id) {
|
||||||
releasePlan?.milestone?.id !== context.milestone_id ||
|
throw new Error('Issue milestone was not confirmed.');
|
||||||
(context.due_date && releasePlan?.due_date !== context.due_date)) {
|
}
|
||||||
throw new Error('Issue release plan 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.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 'queued';
|
return 'queued';
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1969,58 +1969,6 @@ 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,15 +763,6 @@ 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_.-]+$"
|
||||||
|
|
@ -4383,40 +4374,6 @@ 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,81 +215,6 @@ 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,9 +293,8 @@ 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('/release-plan')) return Promise.resolve({{
|
if (url.endsWith('/milestone')) return Promise.resolve({{number,milestone:{{id:9,title:'August RC'}}}});
|
||||||
number,milestone:{{id:9,title:'August RC'}},due_date:'2026-08-31T23:59:59Z'
|
if (url.endsWith('/due-date')) return Promise.resolve({{number,due_date:'2026-08-31T23:59:59Z'}});
|
||||||
}});
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}};
|
}};
|
||||||
const processorOptions=[];
|
const processorOptions=[];
|
||||||
|
|
@ -347,11 +346,16 @@ 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",
|
{
|
||||||
"method": "PATCH",
|
"url": "api/v1/repos/stackchain/dashboard/issues/815/milestone",
|
||||||
"body": {"milestone_id": 9, "due_date": "2026-08-31T23:59:59Z"},
|
"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["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