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
+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