feat: assign issues to milestones on create/update (#22)
lint / lint (pull_request) Successful in 35s
test / test (pull_request) Successful in 35s
docker / docker-test (pull_request) Successful in 8s
test / test (push) Successful in 23s
lint / lint (push) Successful in 23s
docker / test (pull_request) Successful in 29s
docker / lint (pull_request) Successful in 35s
docker / docker-publish (pull_request) Has been skipped

Add a `milestone` argument to `create_issue` and `update_issue` accepting
either a numeric milestone id or a title (resolved case-insensitively against
open and closed milestones, with a clear error for unknown titles). On
`update_issue`, `milestone: 0` clears the milestone. A BeforeValidator rejects
booleans so they are not silently coerced to an id.

Gitea Projects (Kanban boards) were investigated for #22 and are intentionally
left unsupported: Gitea 1.26.2 exposes no project endpoints in its REST API.
Documented this in api-reference.md and refreshed the (stale) write-mode tool
list to cover all 16 write tools.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 17:36:01 +02:00
parent 10a307ac02
commit e08ba42697
8 changed files with 267 additions and 16 deletions
+70
View File
@@ -272,6 +272,76 @@ async def test_resolve_label_ids_rejects_unknown_label() -> None:
await client._resolve_label_ids("o", "r", ["ghost"], correlation_id="c")
@pytest.mark.asyncio
async def test_resolve_milestone_id_passes_through_integer() -> None:
"""An integer milestone reference is used as a Gitea milestone id as-is."""
client = GiteaClient(token="user-token")
client._request = AsyncMock() # type: ignore[method-assign]
assert await client._resolve_milestone_id("o", "r", 7, correlation_id="c") == 7
# Integer ids must not trigger a milestone lookup.
client._request.assert_not_called()
@pytest.mark.asyncio
async def test_resolve_milestone_id_maps_title_case_insensitively() -> None:
"""A milestone title is resolved to its id regardless of case."""
client = GiteaClient(token="user-token")
async def fake_request(method: str, endpoint: str, **kwargs):
return [{"id": 11, "title": "Sprint 1"}, {"id": 12, "title": "Backlog"}]
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
resolved = await client._resolve_milestone_id("o", "r", "sprint 1", correlation_id="c")
assert resolved == 11
@pytest.mark.asyncio
async def test_resolve_milestone_id_rejects_unknown_title() -> None:
"""An unknown milestone title raises a clear error."""
client = GiteaClient(token="user-token")
async def fake_request(method: str, endpoint: str, **kwargs):
return [{"id": 11, "title": "Sprint 1"}]
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
with pytest.raises(GiteaError, match="Unknown milestone"):
await client._resolve_milestone_id("o", "r", "Sprint 2", correlation_id="c")
@pytest.mark.asyncio
async def test_create_issue_resolves_milestone_title() -> None:
"""create_issue resolves a milestone title to an id in the POST payload."""
client = GiteaClient(token="user-token")
captured: dict = {}
async def fake_request(method: str, endpoint: str, **kwargs):
if endpoint.endswith("/milestones") and method == "GET":
return [{"id": 11, "title": "Sprint 1"}]
if endpoint.endswith("/issues") and method == "POST":
captured["payload"] = kwargs.get("json_body")
return {"number": 1, "title": "Issue", "state": "open"}
return {}
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
await client.create_issue("o", "r", title="Issue", body="", milestone="Sprint 1")
assert captured["payload"]["milestone"] == 11
@pytest.mark.asyncio
async def test_update_issue_clears_milestone_with_zero() -> None:
"""update_issue forwards milestone id 0 verbatim to clear the milestone."""
client = GiteaClient(token="user-token")
captured: dict = {}
async def fake_request(method: str, endpoint: str, **kwargs):
captured["payload"] = kwargs.get("json_body")
return {"number": 1, "title": "Issue", "state": "open"}
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
await client.update_issue("o", "r", 1, milestone=0)
assert captured["payload"]["milestone"] == 0
@pytest.mark.asyncio
async def test_add_labels_resolves_names_to_ids() -> None:
"""add_labels translates names to ids before POSTing to Gitea."""