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:
@@ -70,6 +70,15 @@ Scope requirements:
|
|||||||
- `assign_issue` (`owner`, `repo`, `issue_number`, `assignees`)
|
- `assign_issue` (`owner`, `repo`, `issue_number`, `assignees`)
|
||||||
- `create_label` (`owner`, `repo`, `name`, `color` hex e.g. `#00aabb`, optional `description`, `exclusive`)
|
- `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`)
|
- `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
|
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.
|
server resolves them to Gitea label ids and returns a clear error for unknown labels.
|
||||||
|
|||||||
@@ -840,3 +840,152 @@ class GiteaClient:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
return result if isinstance(result, dict) else {}
|
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 {}
|
||||||
|
|||||||
@@ -426,6 +426,112 @@ AVAILABLE_TOOLS: list[MCPTool] = [
|
|||||||
},
|
},
|
||||||
write_operation=True,
|
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,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -81,10 +81,16 @@ from aegis_gitea_mcp.tools.repository import (
|
|||||||
from aegis_gitea_mcp.tools.write_tools import (
|
from aegis_gitea_mcp.tools.write_tools import (
|
||||||
add_labels_tool,
|
add_labels_tool,
|
||||||
assign_issue_tool,
|
assign_issue_tool,
|
||||||
|
create_branch_tool,
|
||||||
create_issue_comment_tool,
|
create_issue_comment_tool,
|
||||||
create_issue_tool,
|
create_issue_tool,
|
||||||
create_label_tool,
|
create_label_tool,
|
||||||
|
create_milestone_tool,
|
||||||
create_pr_comment_tool,
|
create_pr_comment_tool,
|
||||||
|
create_pull_request_tool,
|
||||||
|
create_release_tool,
|
||||||
|
edit_issue_comment_tool,
|
||||||
|
edit_release_tool,
|
||||||
remove_labels_tool,
|
remove_labels_tool,
|
||||||
update_issue_tool,
|
update_issue_tool,
|
||||||
update_label_tool,
|
update_label_tool,
|
||||||
@@ -348,6 +354,12 @@ TOOL_HANDLERS: dict[str, ToolHandler] = {
|
|||||||
"create_label": create_label_tool,
|
"create_label": create_label_tool,
|
||||||
"update_label": update_label_tool,
|
"update_label": update_label_tool,
|
||||||
"remove_labels": remove_labels_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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -253,6 +253,70 @@ class RemoveLabelsArgs(RepositoryArgs):
|
|||||||
labels: list[str] = Field(..., min_length=1, max_length=20)
|
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:
|
def extract_repository(arguments: dict[str, object]) -> str | None:
|
||||||
"""Extract `owner/repo` from raw argument mapping.
|
"""Extract `owner/repo` from raw argument mapping.
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,16 @@ from aegis_gitea_mcp.response_limits import limit_text
|
|||||||
from aegis_gitea_mcp.tools.arguments import (
|
from aegis_gitea_mcp.tools.arguments import (
|
||||||
AddLabelsArgs,
|
AddLabelsArgs,
|
||||||
AssignIssueArgs,
|
AssignIssueArgs,
|
||||||
|
CreateBranchArgs,
|
||||||
CreateIssueArgs,
|
CreateIssueArgs,
|
||||||
CreateIssueCommentArgs,
|
CreateIssueCommentArgs,
|
||||||
CreateLabelArgs,
|
CreateLabelArgs,
|
||||||
|
CreateMilestoneArgs,
|
||||||
CreatePrCommentArgs,
|
CreatePrCommentArgs,
|
||||||
|
CreatePullRequestArgs,
|
||||||
|
CreateReleaseArgs,
|
||||||
|
EditIssueCommentArgs,
|
||||||
|
EditReleaseArgs,
|
||||||
RemoveLabelsArgs,
|
RemoveLabelsArgs,
|
||||||
UpdateIssueArgs,
|
UpdateIssueArgs,
|
||||||
UpdateLabelArgs,
|
UpdateLabelArgs,
|
||||||
@@ -249,3 +255,147 @@ async def assign_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> di
|
|||||||
raise
|
raise
|
||||||
except GiteaError as exc:
|
except GiteaError as exc:
|
||||||
raise RuntimeError(f"Failed to assign issue: {exc}") from 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
|
||||||
|
|||||||
@@ -20,10 +20,16 @@ from aegis_gitea_mcp.tools.read_tools import (
|
|||||||
from aegis_gitea_mcp.tools.write_tools import (
|
from aegis_gitea_mcp.tools.write_tools import (
|
||||||
add_labels_tool,
|
add_labels_tool,
|
||||||
assign_issue_tool,
|
assign_issue_tool,
|
||||||
|
create_branch_tool,
|
||||||
create_issue_comment_tool,
|
create_issue_comment_tool,
|
||||||
create_issue_tool,
|
create_issue_tool,
|
||||||
create_label_tool,
|
create_label_tool,
|
||||||
|
create_milestone_tool,
|
||||||
create_pr_comment_tool,
|
create_pr_comment_tool,
|
||||||
|
create_pull_request_tool,
|
||||||
|
create_release_tool,
|
||||||
|
edit_issue_comment_tool,
|
||||||
|
edit_release_tool,
|
||||||
remove_labels_tool,
|
remove_labels_tool,
|
||||||
update_issue_tool,
|
update_issue_tool,
|
||||||
update_label_tool,
|
update_label_tool,
|
||||||
@@ -114,6 +120,28 @@ class StubGitea:
|
|||||||
async def remove_labels(self, owner, repo, index, labels):
|
async def remove_labels(self, owner, repo, index, labels):
|
||||||
return []
|
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):
|
class ErrorGitea(StubGitea):
|
||||||
"""Stub that raises backend errors for failure-mode coverage."""
|
"""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"]},
|
{"owner": "acme", "repo": "app", "issue_number": 1, "labels": ["bug"]},
|
||||||
"removed",
|
"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):
|
async def test_write_tools_success(tool, args, expected_key):
|
||||||
|
|||||||
Reference in New Issue
Block a user