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

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
+8 -2
View File
@@ -74,8 +74,8 @@ Scope requirements:
## Write Tools (Write Mode Required)
- `create_issue` (`owner`, `repo`, `title`, optional `body`, `labels`, `assignees`)
- `update_issue` (`owner`, `repo`, `issue_number`, one or more of `title`, `body`, `state`)
- `create_issue` (`owner`, `repo`, `title`, optional `body`, `labels`, `assignees`, `milestone`)
- `update_issue` (`owner`, `repo`, `issue_number`, one or more of `title`, `body`, `state`, `milestone`)
- `create_issue_comment` (`owner`, `repo`, `issue_number`, `body`)
- `create_pr_comment` (`owner`, `repo`, `pull_number`, `body`)
- `add_labels` (`owner`, `repo`, `issue_number`, `labels` by name)
@@ -96,6 +96,12 @@ management.
Note: `create_issue`, `add_labels`, and `remove_labels` accept label **names**; the
server resolves them to Gitea label ids and returns a clear error for unknown labels.
Note: the `milestone` argument on `create_issue`/`update_issue` accepts either a numeric
milestone **id** or a milestone **title** (resolved case-insensitively against open and
closed milestones; unknown titles return a clear error). On `update_issue`, `milestone: 0`
clears the issue's milestone. Gitea Projects (Kanban boards) are intentionally unsupported:
the Gitea REST API exposes no project endpoints.
## Validation and Limits
- All tool argument schemas reject unknown fields.
+15 -3
View File
@@ -13,14 +13,26 @@ Write mode introduces mutation risk (issue/PR changes, metadata updates). Risks
## Supported Write Tools
- `create_issue`
- `update_issue`
- `create_issue` (optional `milestone` id or title)
- `update_issue` (optional `milestone`; `0` clears it)
- `create_issue_comment`
- `create_pr_comment`
- `edit_issue_comment`
- `add_labels`
- `remove_labels`
- `assign_issue`
- `create_label`
- `update_label`
- `create_pull_request`
- `create_release`
- `edit_release`
- `create_branch`
- `create_milestone`
Not supported (explicitly forbidden): merge actions, branch deletion, force push.
Not supported (explicitly forbidden): merge actions, branch/label/release deletion,
force push, repo/admin management, and repository content writes (file create/edit,
commits). Gitea Projects (Kanban boards) are unsupported because the Gitea REST API
exposes no project endpoints.
## Enablement Steps
+49 -3
View File
@@ -621,6 +621,41 @@ class GiteaClient:
)
return ids
async def _resolve_milestone_id(
self, owner: str, repo: str, milestone: int | str, *, correlation_id: str
) -> int:
"""Resolve a milestone id or title to a numeric milestone id.
Gitea's issue API requires a numeric milestone id. An integer is used
as-is (``0`` clears the milestone); a string is resolved
case-insensitively against the repository's milestones (open or closed)
and raises a clear error when no title matches.
"""
if isinstance(milestone, int):
return milestone
title = milestone.strip()
existing = await self._request(
"GET",
f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/milestones",
params={"state": "all", "limit": 100},
correlation_id=correlation_id,
)
by_title: dict[str, int] = {}
if isinstance(existing, list):
for item in existing:
if isinstance(item, dict):
m_title = str(item.get("title", ""))
m_id = item.get("id")
if m_title and isinstance(m_id, int):
by_title[m_title.lower()] = m_id
match = by_title.get(title.lower())
if match is None:
raise GiteaError(
f"Unknown milestone for {owner}/{repo}: {title}. "
"Create it first with create_milestone."
)
return match
async def create_issue(
self,
owner: str,
@@ -630,6 +665,7 @@ class GiteaClient:
body: str,
labels: list[str] | None = None,
assignees: list[str] | None = None,
milestone: int | str | None = None,
) -> dict[str, Any]:
"""Create repository issue."""
correlation_id = str(
@@ -642,6 +678,10 @@ class GiteaClient:
)
if assignees:
payload["assignees"] = assignees
if milestone is not None:
payload["milestone"] = await self._resolve_milestone_id(
owner, repo, milestone, correlation_id=correlation_id
)
result = await self._request(
"POST",
f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/issues",
@@ -659,8 +699,12 @@ class GiteaClient:
title: str | None = None,
body: str | None = None,
state: str | None = None,
milestone: int | str | None = None,
) -> dict[str, Any]:
"""Update issue fields."""
correlation_id = str(
self.audit.log_tool_invocation(tool_name="update_issue", result_status="pending")
)
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
@@ -668,13 +712,15 @@ class GiteaClient:
payload["body"] = body
if state is not None:
payload["state"] = state
if milestone is not None:
payload["milestone"] = await self._resolve_milestone_id(
owner, repo, milestone, correlation_id=correlation_id
)
result = await self._request(
"PATCH",
f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/issues/{index}",
json_body=payload,
correlation_id=str(
self.audit.log_tool_invocation(tool_name="update_issue", result_status="pending")
),
correlation_id=correlation_id,
)
return result if isinstance(result, dict) else {}
+9 -1
View File
@@ -464,6 +464,10 @@ AVAILABLE_TOOLS: list[MCPTool] = [
"body": {"type": "string", "default": ""},
"labels": {"type": "array", "items": {"type": "string"}, "default": []},
"assignees": {"type": "array", "items": {"type": "string"}, "default": []},
"milestone": {
"type": ["integer", "string"],
"description": "Milestone id or title to assign the issue to",
},
},
"required": ["owner", "repo", "title"],
"additionalProperties": False,
@@ -472,7 +476,7 @@ AVAILABLE_TOOLS: list[MCPTool] = [
),
_tool(
"update_issue",
"Update issue title/body/state (write-mode only).",
"Update issue title/body/state/milestone (write-mode only).",
{
"type": "object",
"properties": {
@@ -482,6 +486,10 @@ AVAILABLE_TOOLS: list[MCPTool] = [
"title": {"type": "string"},
"body": {"type": "string"},
"state": {"type": "string", "enum": ["open", "closed"]},
"milestone": {
"type": ["integer", "string"],
"description": "Milestone id or title to assign; 0 clears the milestone",
},
},
"required": ["owner", "repo", "issue_number"],
"additionalProperties": False,
+48 -3
View File
@@ -4,7 +4,14 @@ from __future__ import annotations
from typing import Annotated, Literal
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, model_validator
from pydantic import (
AfterValidator,
BaseModel,
BeforeValidator,
ConfigDict,
Field,
model_validator,
)
_REPO_PART_PATTERN = r"^[A-Za-z0-9._-]{1,100}$"
@@ -45,6 +52,33 @@ def _validate_git_ref(value: str) -> str:
GitRef = Annotated[str, AfterValidator(_validate_git_ref)]
def _validate_milestone(value: object) -> int | str:
"""Validate a milestone reference supplied as a numeric id or a title.
An integer is treated as a milestone id (``0`` clears the milestone on
update); a string is treated as a milestone title to resolve. Runs as a
``BeforeValidator`` so ``bool`` (a subclass of ``int`` that Pydantic would
otherwise coerce to ``1``/``0``) is rejected on the raw input.
"""
if isinstance(value, bool):
raise ValueError("milestone must be a milestone id or title")
if isinstance(value, int):
if value < 0:
raise ValueError("milestone id must be >= 0")
return value
if isinstance(value, str):
title = value.strip()
if not title:
raise ValueError("milestone title must not be empty")
if len(title) > 256:
raise ValueError("milestone title must not exceed 256 characters")
return title
raise ValueError("milestone must be a milestone id or title")
MilestoneRef = Annotated[int | str, BeforeValidator(_validate_milestone)]
class StrictBaseModel(BaseModel):
"""Strict model base that rejects unexpected fields."""
@@ -174,6 +208,9 @@ class CreateIssueArgs(RepositoryArgs):
body: str = Field(default="", max_length=20_000)
labels: list[str] = Field(default_factory=list, max_length=20)
assignees: list[str] = Field(default_factory=list, max_length=20)
milestone: MilestoneRef | None = Field(
default=None, description="Milestone id or title to assign the issue to"
)
class UpdateIssueArgs(RepositoryArgs):
@@ -183,12 +220,20 @@ class UpdateIssueArgs(RepositoryArgs):
title: str | None = Field(default=None, min_length=1, max_length=256)
body: str | None = Field(default=None, max_length=20_000)
state: Literal["open", "closed"] | None = Field(default=None)
milestone: MilestoneRef | None = Field(
default=None, description="Milestone id or title to assign; 0 clears the milestone"
)
@model_validator(mode="after")
def require_change(self) -> UpdateIssueArgs:
"""Require at least one mutable field in update payload."""
if self.title is None and self.body is None and self.state is None:
raise ValueError("At least one of title, body, or state must be provided")
if (
self.title is None
and self.body is None
and self.state is None
and self.milestone is None
):
raise ValueError("At least one of title, body, state, or milestone must be provided")
return self
+12
View File
@@ -30,6 +30,14 @@ from aegis_gitea_mcp.tools.arguments import (
)
def _milestone_title(issue: dict[str, Any]) -> str:
"""Extract the milestone title from an issue payload, or '' if unset."""
milestone = issue.get("milestone")
if isinstance(milestone, dict):
return limit_text(str(milestone.get("title", "")))
return ""
async def create_label_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Create a repository label in write mode."""
parsed = CreateLabelArgs.model_validate(arguments)
@@ -119,11 +127,13 @@ async def create_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> di
body=parsed.body,
labels=parsed.labels,
assignees=parsed.assignees,
milestone=parsed.milestone,
)
return {
"number": issue.get("number", 0),
"title": limit_text(str(issue.get("title", ""))),
"state": issue.get("state", ""),
"milestone": _milestone_title(issue),
"url": issue.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
@@ -145,11 +155,13 @@ async def update_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> di
title=parsed.title,
body=parsed.body,
state=parsed.state,
milestone=parsed.milestone,
)
return {
"number": issue.get("number", parsed.issue_number),
"title": limit_text(str(issue.get("title", ""))),
"state": issue.get("state", ""),
"milestone": _milestone_title(issue),
"url": issue.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
+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."""
+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"}),