diff --git a/docs/api-reference.md b/docs/api-reference.md index 69fab01..7cc75ea 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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. diff --git a/src/aegis_gitea_mcp/gitea_client.py b/src/aegis_gitea_mcp/gitea_client.py index 5248acc..f5a7c1a 100644 --- a/src/aegis_gitea_mcp/gitea_client.py +++ b/src/aegis_gitea_mcp/gitea_client.py @@ -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 {} diff --git a/src/aegis_gitea_mcp/mcp_protocol.py b/src/aegis_gitea_mcp/mcp_protocol.py index cf909a8..aaf76fd 100644 --- a/src/aegis_gitea_mcp/mcp_protocol.py +++ b/src/aegis_gitea_mcp/mcp_protocol.py @@ -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, + ), ] diff --git a/src/aegis_gitea_mcp/server.py b/src/aegis_gitea_mcp/server.py index 8c5ce63..1de5bf1 100644 --- a/src/aegis_gitea_mcp/server.py +++ b/src/aegis_gitea_mcp/server.py @@ -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, } diff --git a/src/aegis_gitea_mcp/tools/arguments.py b/src/aegis_gitea_mcp/tools/arguments.py index c439080..7f169d0 100644 --- a/src/aegis_gitea_mcp/tools/arguments.py +++ b/src/aegis_gitea_mcp/tools/arguments.py @@ -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. diff --git a/src/aegis_gitea_mcp/tools/write_tools.py b/src/aegis_gitea_mcp/tools/write_tools.py index c36dbf8..a68fdde 100644 --- a/src/aegis_gitea_mcp/tools/write_tools.py +++ b/src/aegis_gitea_mcp/tools/write_tools.py @@ -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 diff --git a/tests/test_tools_extended.py b/tests/test_tools_extended.py index 5e3ae48..a60b64d 100644 --- a/tests/test_tools_extended.py +++ b/tests/test_tools_extended.py @@ -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):