feat: add PR/release/branch/milestone/comment write tools

Adds six opt-in write tools (write-mode + policy + per-user permission still
enforced; no destructive or admin actions):

- create_pull_request (POST /pulls)
- create_release / edit_release (POST/PATCH /releases)
- create_branch (POST /branches; create only, no deletion)
- create_milestone (POST /milestones)
- edit_issue_comment (PATCH /issues/comments/{id})

Each: arg schema (extra=forbid, GitRef on branch/ref-like fields), Gitea client
method with url-encoded path segments, handler that surfaces auth errors, MCP
registration (write_operation=True), server wiring, docs, and success tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 20:38:25 +02:00
parent c282ffe359
commit 7837ff43ad
7 changed files with 548 additions and 0 deletions
+9
View File
@@ -70,6 +70,15 @@ Scope requirements:
- `assign_issue` (`owner`, `repo`, `issue_number`, `assignees`)
- `create_label` (`owner`, `repo`, `name`, `color` hex e.g. `#00aabb`, optional `description`, `exclusive`)
- `update_label` (`owner`, `repo`, `name`, one or more of `new_name`, `color`, `description`)
- `create_pull_request` (`owner`, `repo`, `title`, `head`, `base`, optional `body`)
- `create_release` (`owner`, `repo`, `tag_name`, optional `name`, `body`, `draft`, `prerelease`, `target`)
- `edit_release` (`owner`, `repo`, `release_id`, one or more of `name`, `body`, `draft`, `prerelease`)
- `create_branch` (`owner`, `repo`, `new_branch_name`, optional `old_branch_name`)
- `create_milestone` (`owner`, `repo`, `title`, optional `description`, `due_on`)
- `edit_issue_comment` (`owner`, `repo`, `comment_id`, `body`)
Not supported by design: merge, branch/label/release deletion, force push, repo/admin
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.
+149
View File
@@ -840,3 +840,152 @@ class GiteaClient:
),
)
return result if isinstance(result, dict) else {}
async def create_pull_request(
self,
owner: str,
repo: str,
*,
title: str,
head: str,
base: str,
body: str = "",
) -> dict[str, Any]:
"""Open a pull request from head into base."""
result = await self._request(
"POST",
f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/pulls",
json_body={"title": title, "head": head, "base": base, "body": body},
correlation_id=str(
self.audit.log_tool_invocation(
tool_name="create_pull_request", result_status="pending"
)
),
)
return result if isinstance(result, dict) else {}
async def create_release(
self,
owner: str,
repo: str,
*,
tag_name: str,
name: str = "",
body: str = "",
draft: bool = False,
prerelease: bool = False,
target: str | None = None,
) -> dict[str, Any]:
"""Create a release for an existing or new tag."""
payload: dict[str, Any] = {
"tag_name": tag_name,
"name": name or tag_name,
"body": body,
"draft": draft,
"prerelease": prerelease,
}
if target:
payload["target_commitish"] = target
result = await self._request(
"POST",
f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/releases",
json_body=payload,
correlation_id=str(
self.audit.log_tool_invocation(tool_name="create_release", result_status="pending")
),
)
return result if isinstance(result, dict) else {}
async def edit_release(
self,
owner: str,
repo: str,
release_id: int,
*,
name: str | None = None,
body: str | None = None,
draft: bool | None = None,
prerelease: bool | None = None,
) -> dict[str, Any]:
"""Edit fields of an existing release."""
payload: dict[str, Any] = {}
if name is not None:
payload["name"] = name
if body is not None:
payload["body"] = body
if draft is not None:
payload["draft"] = draft
if prerelease is not None:
payload["prerelease"] = prerelease
result = await self._request(
"PATCH",
f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/releases/{release_id}",
json_body=payload,
correlation_id=str(
self.audit.log_tool_invocation(tool_name="edit_release", result_status="pending")
),
)
return result if isinstance(result, dict) else {}
async def create_branch(
self,
owner: str,
repo: str,
*,
new_branch_name: str,
old_branch_name: str | None = None,
) -> dict[str, Any]:
"""Create a branch, optionally from a specific existing branch."""
payload: dict[str, Any] = {"new_branch_name": new_branch_name}
if old_branch_name:
payload["old_branch_name"] = old_branch_name
result = await self._request(
"POST",
f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/branches",
json_body=payload,
correlation_id=str(
self.audit.log_tool_invocation(tool_name="create_branch", result_status="pending")
),
)
return result if isinstance(result, dict) else {}
async def create_milestone(
self,
owner: str,
repo: str,
*,
title: str,
description: str = "",
due_on: str | None = None,
) -> dict[str, Any]:
"""Create a repository milestone."""
payload: dict[str, Any] = {"title": title, "description": description}
if due_on:
payload["due_on"] = due_on
result = await self._request(
"POST",
f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/milestones",
json_body=payload,
correlation_id=str(
self.audit.log_tool_invocation(
tool_name="create_milestone", result_status="pending"
)
),
)
return result if isinstance(result, dict) else {}
async def edit_issue_comment(
self, owner: str, repo: str, comment_id: int, body: str
) -> dict[str, Any]:
"""Edit an existing issue or PR comment."""
result = await self._request(
"PATCH",
f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/issues/comments/{comment_id}",
json_body={"body": body},
correlation_id=str(
self.audit.log_tool_invocation(
tool_name="edit_issue_comment", result_status="pending"
)
),
)
return result if isinstance(result, dict) else {}
+106
View File
@@ -426,6 +426,112 @@ AVAILABLE_TOOLS: list[MCPTool] = [
},
write_operation=True,
),
_tool(
"create_pull_request",
"Open a pull request from head into base (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"title": {"type": "string"},
"head": {"type": "string", "description": "Source branch"},
"base": {"type": "string", "description": "Target branch"},
"body": {"type": "string", "default": ""},
},
"required": ["owner", "repo", "title", "head", "base"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"create_release",
"Create a release for a tag (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"tag_name": {"type": "string"},
"name": {"type": "string", "default": ""},
"body": {"type": "string", "default": ""},
"draft": {"type": "boolean", "default": False},
"prerelease": {"type": "boolean", "default": False},
"target": {"type": "string", "description": "Target commitish/branch"},
},
"required": ["owner", "repo", "tag_name"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"edit_release",
"Edit an existing release (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"release_id": {"type": "integer", "minimum": 1},
"name": {"type": "string"},
"body": {"type": "string"},
"draft": {"type": "boolean"},
"prerelease": {"type": "boolean"},
},
"required": ["owner", "repo", "release_id"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"create_branch",
"Create a branch, optionally from an existing branch (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"new_branch_name": {"type": "string"},
"old_branch_name": {"type": "string", "description": "Source branch (optional)"},
},
"required": ["owner", "repo", "new_branch_name"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"create_milestone",
"Create a repository milestone (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"title": {"type": "string"},
"description": {"type": "string", "default": ""},
"due_on": {"type": "string", "description": "ISO8601 due date (optional)"},
},
"required": ["owner", "repo", "title"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"edit_issue_comment",
"Edit an existing issue or PR comment (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"comment_id": {"type": "integer", "minimum": 1},
"body": {"type": "string"},
},
"required": ["owner", "repo", "comment_id", "body"],
"additionalProperties": False,
},
write_operation=True,
),
]
+12
View File
@@ -81,10 +81,16 @@ from aegis_gitea_mcp.tools.repository import (
from aegis_gitea_mcp.tools.write_tools import (
add_labels_tool,
assign_issue_tool,
create_branch_tool,
create_issue_comment_tool,
create_issue_tool,
create_label_tool,
create_milestone_tool,
create_pr_comment_tool,
create_pull_request_tool,
create_release_tool,
edit_issue_comment_tool,
edit_release_tool,
remove_labels_tool,
update_issue_tool,
update_label_tool,
@@ -348,6 +354,12 @@ TOOL_HANDLERS: dict[str, ToolHandler] = {
"create_label": create_label_tool,
"update_label": update_label_tool,
"remove_labels": remove_labels_tool,
"create_pull_request": create_pull_request_tool,
"create_release": create_release_tool,
"edit_release": edit_release_tool,
"create_branch": create_branch_tool,
"create_milestone": create_milestone_tool,
"edit_issue_comment": edit_issue_comment_tool,
}
+64
View File
@@ -253,6 +253,70 @@ class RemoveLabelsArgs(RepositoryArgs):
labels: list[str] = Field(..., min_length=1, max_length=20)
class CreatePullRequestArgs(RepositoryArgs):
"""Arguments for create_pull_request."""
title: str = Field(..., min_length=1, max_length=256)
head: GitRef = Field(..., min_length=1, max_length=200)
base: GitRef = Field(..., min_length=1, max_length=200)
body: str = Field(default="", max_length=20_000)
class CreateReleaseArgs(RepositoryArgs):
"""Arguments for create_release."""
tag_name: GitRef = Field(..., min_length=1, max_length=200)
name: str = Field(default="", max_length=256)
body: str = Field(default="", max_length=20_000)
draft: bool = Field(default=False)
prerelease: bool = Field(default=False)
target: str | None = Field(default=None, min_length=1, max_length=200)
class EditReleaseArgs(RepositoryArgs):
"""Arguments for edit_release."""
release_id: int = Field(..., ge=1)
name: str | None = Field(default=None, max_length=256)
body: str | None = Field(default=None, max_length=20_000)
draft: bool | None = Field(default=None)
prerelease: bool | None = Field(default=None)
@model_validator(mode="after")
def require_change(self) -> EditReleaseArgs:
"""Require at least one mutable field in the update payload."""
if (
self.name is None
and self.body is None
and self.draft is None
and self.prerelease is None
):
raise ValueError("At least one of name, body, draft, or prerelease must be provided")
return self
class CreateBranchArgs(RepositoryArgs):
"""Arguments for create_branch."""
new_branch_name: GitRef = Field(..., min_length=1, max_length=200)
old_branch_name: str | None = Field(default=None, min_length=1, max_length=200)
class CreateMilestoneArgs(RepositoryArgs):
"""Arguments for create_milestone."""
title: str = Field(..., min_length=1, max_length=256)
description: str = Field(default="", max_length=10_000)
due_on: str | None = Field(default=None, max_length=64)
class EditIssueCommentArgs(RepositoryArgs):
"""Arguments for edit_issue_comment."""
comment_id: int = Field(..., ge=1)
body: str = Field(..., min_length=1, max_length=10_000)
def extract_repository(arguments: dict[str, object]) -> str | None:
"""Extract `owner/repo` from raw argument mapping.
+150
View File
@@ -14,10 +14,16 @@ from aegis_gitea_mcp.response_limits import limit_text
from aegis_gitea_mcp.tools.arguments import (
AddLabelsArgs,
AssignIssueArgs,
CreateBranchArgs,
CreateIssueArgs,
CreateIssueCommentArgs,
CreateLabelArgs,
CreateMilestoneArgs,
CreatePrCommentArgs,
CreatePullRequestArgs,
CreateReleaseArgs,
EditIssueCommentArgs,
EditReleaseArgs,
RemoveLabelsArgs,
UpdateIssueArgs,
UpdateLabelArgs,
@@ -249,3 +255,147 @@ async def assign_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> di
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to assign issue: {exc}") from exc
async def create_pull_request_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Open a pull request in write mode."""
parsed = CreatePullRequestArgs.model_validate(arguments)
try:
pull = await gitea.create_pull_request(
parsed.owner,
parsed.repo,
title=parsed.title,
head=parsed.head,
base=parsed.base,
body=parsed.body,
)
return {
"number": pull.get("number", 0),
"title": limit_text(str(pull.get("title", ""))),
"state": pull.get("state", ""),
"url": pull.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to create pull request: {exc}") from exc
async def create_release_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Create a release in write mode."""
parsed = CreateReleaseArgs.model_validate(arguments)
try:
release = await gitea.create_release(
parsed.owner,
parsed.repo,
tag_name=parsed.tag_name,
name=parsed.name,
body=parsed.body,
draft=parsed.draft,
prerelease=parsed.prerelease,
target=parsed.target,
)
return {
"id": release.get("id", 0),
"tag_name": release.get("tag_name", ""),
"name": limit_text(str(release.get("name", ""))),
"draft": release.get("draft", False),
"prerelease": release.get("prerelease", False),
"url": release.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to create release: {exc}") from exc
async def edit_release_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Edit an existing release in write mode."""
parsed = EditReleaseArgs.model_validate(arguments)
try:
release = await gitea.edit_release(
parsed.owner,
parsed.repo,
parsed.release_id,
name=parsed.name,
body=parsed.body,
draft=parsed.draft,
prerelease=parsed.prerelease,
)
return {
"id": release.get("id", parsed.release_id),
"tag_name": release.get("tag_name", ""),
"name": limit_text(str(release.get("name", ""))),
"draft": release.get("draft", False),
"prerelease": release.get("prerelease", False),
"url": release.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to edit release: {exc}") from exc
async def create_branch_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Create a branch in write mode."""
parsed = CreateBranchArgs.model_validate(arguments)
try:
branch = await gitea.create_branch(
parsed.owner,
parsed.repo,
new_branch_name=parsed.new_branch_name,
old_branch_name=parsed.old_branch_name,
)
commit = branch.get("commit", {}) if isinstance(branch, dict) else {}
return {
"name": branch.get("name", parsed.new_branch_name),
"commit": commit.get("id", "") if isinstance(commit, dict) else "",
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to create branch: {exc}") from exc
async def create_milestone_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Create a milestone in write mode."""
parsed = CreateMilestoneArgs.model_validate(arguments)
try:
milestone = await gitea.create_milestone(
parsed.owner,
parsed.repo,
title=parsed.title,
description=parsed.description,
due_on=parsed.due_on,
)
return {
"id": milestone.get("id", 0),
"title": limit_text(str(milestone.get("title", ""))),
"state": milestone.get("state", ""),
"url": milestone.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to create milestone: {exc}") from exc
async def edit_issue_comment_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Edit an existing issue or PR comment in write mode."""
parsed = EditIssueCommentArgs.model_validate(arguments)
try:
comment = await gitea.edit_issue_comment(
parsed.owner,
parsed.repo,
parsed.comment_id,
parsed.body,
)
return {
"id": comment.get("id", parsed.comment_id),
"body": limit_text(str(comment.get("body", ""))),
"url": comment.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to edit comment: {exc}") from exc
+58
View File
@@ -20,10 +20,16 @@ from aegis_gitea_mcp.tools.read_tools import (
from aegis_gitea_mcp.tools.write_tools import (
add_labels_tool,
assign_issue_tool,
create_branch_tool,
create_issue_comment_tool,
create_issue_tool,
create_label_tool,
create_milestone_tool,
create_pr_comment_tool,
create_pull_request_tool,
create_release_tool,
edit_issue_comment_tool,
edit_release_tool,
remove_labels_tool,
update_issue_tool,
update_label_tool,
@@ -114,6 +120,28 @@ class StubGitea:
async def remove_labels(self, owner, repo, index, labels):
return []
async def create_pull_request(self, owner, repo, *, title, head, base, body=""):
return {"number": 7, "title": title, "state": "open"}
async def create_release(
self, owner, repo, *, tag_name, name="", body="", draft=False, prerelease=False, target=None
):
return {"id": 3, "tag_name": tag_name, "name": name or tag_name}
async def edit_release(
self, owner, repo, release_id, *, name=None, body=None, draft=None, prerelease=None
):
return {"id": release_id, "tag_name": "v1", "name": name or "rel"}
async def create_branch(self, owner, repo, *, new_branch_name, old_branch_name=None):
return {"name": new_branch_name, "commit": {"id": "abc"}}
async def create_milestone(self, owner, repo, *, title, description="", due_on=None):
return {"id": 4, "title": title, "state": "open"}
async def edit_issue_comment(self, owner, repo, comment_id, body):
return {"id": comment_id, "body": body}
class ErrorGitea(StubGitea):
"""Stub that raises backend errors for failure-mode coverage."""
@@ -201,6 +229,36 @@ async def test_extended_read_tools_failure_mode() -> None:
{"owner": "acme", "repo": "app", "issue_number": 1, "labels": ["bug"]},
"removed",
),
(
create_pull_request_tool,
{"owner": "acme", "repo": "app", "title": "PR", "head": "feature", "base": "main"},
"number",
),
(
create_release_tool,
{"owner": "acme", "repo": "app", "tag_name": "v1.0.0"},
"id",
),
(
edit_release_tool,
{"owner": "acme", "repo": "app", "release_id": 3, "name": "x"},
"id",
),
(
create_branch_tool,
{"owner": "acme", "repo": "app", "new_branch_name": "feature/x"},
"name",
),
(
create_milestone_tool,
{"owner": "acme", "repo": "app", "title": "M1"},
"id",
),
(
edit_issue_comment_tool,
{"owner": "acme", "repo": "app", "comment_id": 5, "body": "edited"},
"id",
),
],
)
async def test_write_tools_success(tool, args, expected_key):