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
+56 -4
View File
@@ -140,11 +140,21 @@ class StubGitea:
async def list_repo_topics(self, owner, repo):
return ["python", "mcp"]
async def create_issue(self, owner, repo, *, title, body, labels=None, assignees=None):
return {"number": 1, "title": title, "state": "open"}
async def create_issue(
self, owner, repo, *, title, body, labels=None, assignees=None, milestone=None
):
result = {"number": 1, "title": title, "state": "open"}
if milestone is not None:
result["milestone"] = {"id": 4, "title": str(milestone)}
return result
async def update_issue(self, owner, repo, index, *, title=None, body=None, state=None):
return {"number": index, "title": title or "Issue", "state": state or "open"}
async def update_issue(
self, owner, repo, index, *, title=None, body=None, state=None, milestone=None
):
result = {"number": index, "title": title or "Issue", "state": state or "open"}
if milestone is not None:
result["milestone"] = {"id": 4, "title": str(milestone)}
return result
async def create_issue_comment(self, owner, repo, index, body):
return {"id": 1, "body": body}
@@ -404,6 +414,48 @@ def test_create_label_args_reject_invalid_color() -> None:
CreateLabelArgs(owner="o", repo="r", name="bug", color="red")
@pytest.mark.asyncio
async def test_create_issue_returns_assigned_milestone_title() -> None:
"""create_issue surfaces the assigned milestone title in its response."""
result = await create_issue_tool(
StubGitea(),
{"owner": "acme", "repo": "app", "title": "Issue", "milestone": "Sprint 1"},
)
assert result["milestone"] == "Sprint 1"
@pytest.mark.asyncio
async def test_update_issue_accepts_milestone_only() -> None:
"""update_issue may change only the milestone (no title/body/state needed)."""
result = await update_issue_tool(
StubGitea(),
{"owner": "acme", "repo": "app", "issue_number": 1, "milestone": 4},
)
assert result["milestone"] == "4"
def test_update_issue_args_require_a_changed_field() -> None:
"""An update with no mutable field (incl. milestone) is rejected."""
import pydantic
from aegis_gitea_mcp.tools.arguments import UpdateIssueArgs
with pytest.raises(pydantic.ValidationError):
UpdateIssueArgs(owner="o", repo="r", issue_number=1)
# Supplying only a milestone satisfies the change requirement.
assert UpdateIssueArgs(owner="o", repo="r", issue_number=1, milestone=0).milestone == 0
def test_issue_args_reject_boolean_milestone() -> None:
"""A boolean is rejected as a milestone reference (it subclasses int)."""
import pydantic
from aegis_gitea_mcp.tools.arguments import CreateIssueArgs
with pytest.raises(pydantic.ValidationError):
CreateIssueArgs(owner="o", repo="r", title="x", milestone=True)
# (tool, valid_args) for every write tool, used to exercise error branches.
WRITE_TOOL_ERROR_CASES = [
(create_issue_tool, {"owner": "acme", "repo": "app", "title": "Issue"}),