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
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:
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user