From c282ffe359b00c618543ed8107ef83f0ae4f6e0c Mon Sep 17 00:00:00 2001 From: latte Date: Sun, 14 Jun 2026 20:34:35 +0200 Subject: [PATCH] feat: complete label management (name->id resolution, update/remove) Resolves the long-standing problem that label tools passed names while Gitea's API requires numeric label ids. - gitea_client: add _resolve_label_ids() helper; create_issue and add_labels now resolve label names to ids (case-insensitive) and raise a clear "Unknown label(s)" error instead of a generic 500. - New tools: remove_labels (by name) and update_label (located by current name). - Register both write tools and document the name-based label contract. - Tests: resolver mapping + unknown-label error, add_labels id translation, update_label and remove_labels handlers. Co-Authored-By: Claude Opus 4.8 --- docs/api-reference.md | 7 +- src/aegis_gitea_mcp/gitea_client.py | 128 +++++++++++++++++++++-- src/aegis_gitea_mcp/mcp_protocol.py | 34 ++++++ src/aegis_gitea_mcp/server.py | 4 + src/aegis_gitea_mcp/tools/arguments.py | 23 ++++ src/aegis_gitea_mcp/tools/write_tools.py | 51 +++++++++ tests/test_gitea_client.py | 47 ++++++++- tests/test_tools_extended.py | 23 ++++ 8 files changed, 306 insertions(+), 11 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 269331c..69fab01 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -65,9 +65,14 @@ Scope requirements: - `update_issue` (`owner`, `repo`, `issue_number`, one or more of `title`, `body`, `state`) - `create_issue_comment` (`owner`, `repo`, `issue_number`, `body`) - `create_pr_comment` (`owner`, `repo`, `pull_number`, `body`) -- `add_labels` (`owner`, `repo`, `issue_number`, `labels`) +- `add_labels` (`owner`, `repo`, `issue_number`, `labels` by name) +- `remove_labels` (`owner`, `repo`, `issue_number`, `labels` by name) - `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`) + +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. ## Validation and Limits diff --git a/src/aegis_gitea_mcp/gitea_client.py b/src/aegis_gitea_mcp/gitea_client.py index 323f716..5248acc 100644 --- a/src/aegis_gitea_mcp/gitea_client.py +++ b/src/aegis_gitea_mcp/gitea_client.py @@ -582,6 +582,45 @@ class GiteaClient: ) return result if isinstance(result, list) else [] + async def _resolve_label_ids( + self, owner: str, repo: str, names: list[str], *, correlation_id: str + ) -> list[int]: + """Resolve label names to Gitea label ids for a repository. + + Gitea's issue/label APIs require numeric label ids, not names. This maps + the caller-supplied names (case-insensitive) to ids and raises a clear + error for any name that does not exist in the repository. + """ + existing = await self._request( + "GET", + f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/labels", + params={"limit": 100}, + correlation_id=correlation_id, + ) + by_name: dict[str, int] = {} + if isinstance(existing, list): + for item in existing: + if isinstance(item, dict): + label_name = str(item.get("name", "")) + label_id = item.get("id") + if label_name and isinstance(label_id, int): + by_name[label_name.lower()] = label_id + + ids: list[int] = [] + unknown: list[str] = [] + for name in names: + match = by_name.get(name.strip().lower()) + if match is None: + unknown.append(name) + else: + ids.append(match) + if unknown: + raise GiteaError( + f"Unknown label(s) for {owner}/{repo}: {', '.join(unknown)}. " + "Create them first with create_label." + ) + return ids + async def create_issue( self, owner: str, @@ -593,18 +632,21 @@ class GiteaClient: assignees: list[str] | None = None, ) -> dict[str, Any]: """Create repository issue.""" + correlation_id = str( + self.audit.log_tool_invocation(tool_name="create_issue", result_status="pending") + ) payload: dict[str, Any] = {"title": title, "body": body} if labels: - payload["labels"] = labels + payload["labels"] = await self._resolve_label_ids( + owner, repo, labels, correlation_id=correlation_id + ) if assignees: payload["assignees"] = assignees result = await self._request( "POST", f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/issues", json_body=payload, - correlation_id=str( - self.audit.log_tool_invocation(tool_name="create_issue", result_status="pending") - ), + correlation_id=correlation_id, ) return result if isinstance(result, dict) else {} @@ -702,14 +744,82 @@ class GiteaClient: index: int, labels: list[str], ) -> dict[str, Any]: - """Add labels to issue/PR.""" + """Add labels to issue/PR by label name.""" + correlation_id = str( + self.audit.log_tool_invocation(tool_name="add_labels", result_status="pending") + ) + label_ids = await self._resolve_label_ids( + owner, repo, labels, correlation_id=correlation_id + ) result = await self._request( "POST", f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/issues/{index}/labels", - json_body={"labels": labels}, - correlation_id=str( - self.audit.log_tool_invocation(tool_name="add_labels", result_status="pending") - ), + json_body={"labels": label_ids}, + correlation_id=correlation_id, + ) + return result if isinstance(result, dict) else {} + + async def remove_labels( + self, + owner: str, + repo: str, + index: int, + labels: list[str], + ) -> list[dict[str, Any]]: + """Remove the given labels (by name) from an issue/PR. + + Returns the issue's remaining labels. + """ + correlation_id = str( + self.audit.log_tool_invocation(tool_name="remove_labels", result_status="pending") + ) + label_ids = await self._resolve_label_ids( + owner, repo, labels, correlation_id=correlation_id + ) + owner_q = quote(owner, safe="") + repo_q = quote(repo, safe="") + for label_id in label_ids: + await self._request( + "DELETE", + f"/api/v1/repos/{owner_q}/{repo_q}/issues/{index}/labels/{label_id}", + correlation_id=correlation_id, + ) + result = await self._request( + "GET", + f"/api/v1/repos/{owner_q}/{repo_q}/issues/{index}/labels", + correlation_id=correlation_id, + ) + return result if isinstance(result, list) else [] + + async def update_label( + self, + owner: str, + repo: str, + *, + name: str, + new_name: str | None = None, + color: str | None = None, + description: str | None = None, + ) -> dict[str, Any]: + """Update an existing label, located by its current name.""" + correlation_id = str( + self.audit.log_tool_invocation(tool_name="update_label", result_status="pending") + ) + label_ids = await self._resolve_label_ids( + owner, repo, [name], correlation_id=correlation_id + ) + payload: dict[str, Any] = {} + if new_name is not None: + payload["name"] = new_name + if color is not None: + payload["color"] = color + if description is not None: + payload["description"] = description + result = await self._request( + "PATCH", + f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/labels/{label_ids[0]}", + json_body=payload, + correlation_id=correlation_id, ) 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 9aed0ca..cf909a8 100644 --- a/src/aegis_gitea_mcp/mcp_protocol.py +++ b/src/aegis_gitea_mcp/mcp_protocol.py @@ -392,6 +392,40 @@ AVAILABLE_TOOLS: list[MCPTool] = [ }, write_operation=True, ), + _tool( + "update_label", + "Update an existing repository label, located by its current name (write-mode only).", + { + "type": "object", + "properties": { + "owner": {"type": "string"}, + "repo": {"type": "string"}, + "name": {"type": "string", "description": "Current label name"}, + "new_name": {"type": "string"}, + "color": {"type": "string", "description": "Hex color, e.g. #00aabb"}, + "description": {"type": "string"}, + }, + "required": ["owner", "repo", "name"], + "additionalProperties": False, + }, + write_operation=True, + ), + _tool( + "remove_labels", + "Remove labels (by name) from an issue or pull request (write-mode only).", + { + "type": "object", + "properties": { + "owner": {"type": "string"}, + "repo": {"type": "string"}, + "issue_number": {"type": "integer", "minimum": 1}, + "labels": {"type": "array", "items": {"type": "string"}, "minItems": 1}, + }, + "required": ["owner", "repo", "issue_number", "labels"], + "additionalProperties": False, + }, + write_operation=True, + ), ] diff --git a/src/aegis_gitea_mcp/server.py b/src/aegis_gitea_mcp/server.py index aa36e9b..8c5ce63 100644 --- a/src/aegis_gitea_mcp/server.py +++ b/src/aegis_gitea_mcp/server.py @@ -85,7 +85,9 @@ from aegis_gitea_mcp.tools.write_tools import ( create_issue_tool, create_label_tool, create_pr_comment_tool, + remove_labels_tool, update_issue_tool, + update_label_tool, ) logger = logging.getLogger(__name__) @@ -344,6 +346,8 @@ TOOL_HANDLERS: dict[str, ToolHandler] = { "add_labels": add_labels_tool, "assign_issue": assign_issue_tool, "create_label": create_label_tool, + "update_label": update_label_tool, + "remove_labels": remove_labels_tool, } diff --git a/src/aegis_gitea_mcp/tools/arguments.py b/src/aegis_gitea_mcp/tools/arguments.py index c6523e6..c439080 100644 --- a/src/aegis_gitea_mcp/tools/arguments.py +++ b/src/aegis_gitea_mcp/tools/arguments.py @@ -230,6 +230,29 @@ class CreateLabelArgs(RepositoryArgs): exclusive: bool = Field(default=False) +class UpdateLabelArgs(RepositoryArgs): + """Arguments for update_label (located by current name).""" + + name: str = Field(..., min_length=1, max_length=50) + new_name: str | None = Field(default=None, min_length=1, max_length=50) + color: str | None = Field(default=None, pattern=r"^#?[0-9A-Fa-f]{6}$") + description: str | None = Field(default=None, max_length=1000) + + @model_validator(mode="after") + def require_change(self) -> UpdateLabelArgs: + """Require at least one mutable field in the update payload.""" + if self.new_name is None and self.color is None and self.description is None: + raise ValueError("At least one of new_name, color, or description must be provided") + return self + + +class RemoveLabelsArgs(RepositoryArgs): + """Arguments for remove_labels.""" + + issue_number: int = Field(..., ge=1) + labels: list[str] = Field(..., min_length=1, max_length=20) + + 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 2b3fea5..c36dbf8 100644 --- a/src/aegis_gitea_mcp/tools/write_tools.py +++ b/src/aegis_gitea_mcp/tools/write_tools.py @@ -18,7 +18,9 @@ from aegis_gitea_mcp.tools.arguments import ( CreateIssueCommentArgs, CreateLabelArgs, CreatePrCommentArgs, + RemoveLabelsArgs, UpdateIssueArgs, + UpdateLabelArgs, ) @@ -51,6 +53,55 @@ async def create_label_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> di raise RuntimeError(f"Failed to create label: {exc}") from exc +async def update_label_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]: + """Update an existing repository label (located by current name).""" + parsed = UpdateLabelArgs.model_validate(arguments) + color = parsed.color + if color is not None and not color.startswith("#"): + color = f"#{color}" + try: + label = await gitea.update_label( + parsed.owner, + parsed.repo, + name=parsed.name, + new_name=parsed.new_name, + color=color, + description=parsed.description, + ) + return { + "id": label.get("id", 0), + "name": limit_text(str(label.get("name", ""))), + "color": label.get("color", ""), + "description": limit_text(str(label.get("description", ""))), + "url": label.get("url", ""), + } + except (GiteaAuthenticationError, GiteaAuthorizationError): + raise + except GiteaError as exc: + raise RuntimeError(f"Failed to update label: {exc}") from exc + + +async def remove_labels_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]: + """Remove labels (by name) from an issue or pull request.""" + parsed = RemoveLabelsArgs.model_validate(arguments) + try: + result = await gitea.remove_labels( + parsed.owner, parsed.repo, parsed.issue_number, parsed.labels + ) + remaining: list[str] = [] + if isinstance(result, list): + remaining = [label.get("name", "") for label in result if isinstance(label, dict)] + return { + "issue_number": parsed.issue_number, + "removed": parsed.labels, + "remaining_labels": remaining, + } + except (GiteaAuthenticationError, GiteaAuthorizationError): + raise + except GiteaError as exc: + raise RuntimeError(f"Failed to remove labels: {exc}") from exc + + async def create_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]: """Create a new issue in write mode.""" parsed = CreateIssueArgs.model_validate(arguments) diff --git a/tests/test_gitea_client.py b/tests/test_gitea_client.py index 02bc9af..061c95c 100644 --- a/tests/test_gitea_client.py +++ b/tests/test_gitea_client.py @@ -118,7 +118,7 @@ async def test_public_methods_delegate_to_request_and_normalize() -> None: if endpoint == "/api/v1/repos/acme/demo/pulls/2": return {"number": 2} if endpoint == "/api/v1/repos/acme/demo/labels": - return [{"name": "bug"}] + return [{"id": 1, "name": "bug"}] if endpoint == "/api/v1/repos/acme/demo/tags": return [{"name": "v1"}] if endpoint == "/api/v1/repos/acme/demo/releases": @@ -246,6 +246,51 @@ async def test_list_user_repositories_unknown_user_returns_empty() -> None: assert await client.list_user_repositories("ghost") == [] +@pytest.mark.asyncio +async def test_resolve_label_ids_maps_names_case_insensitively() -> None: + """Label names are resolved to ids regardless of case.""" + client = GiteaClient(token="user-token") + + async def fake_request(method: str, endpoint: str, **kwargs): + return [{"id": 3, "name": "Bug"}, {"id": 9, "name": "wontfix"}] + + client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign] + ids = await client._resolve_label_ids("o", "r", ["bug", "WONTFIX"], correlation_id="c") + assert ids == [3, 9] + + +@pytest.mark.asyncio +async def test_resolve_label_ids_rejects_unknown_label() -> None: + """An unknown label name raises a clear error instead of a silent failure.""" + client = GiteaClient(token="user-token") + + async def fake_request(method: str, endpoint: str, **kwargs): + return [{"id": 3, "name": "bug"}] + + client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign] + with pytest.raises(GiteaError, match="Unknown label"): + await client._resolve_label_ids("o", "r", ["ghost"], correlation_id="c") + + +@pytest.mark.asyncio +async def test_add_labels_resolves_names_to_ids() -> None: + """add_labels translates names to ids before POSTing to Gitea.""" + client = GiteaClient(token="user-token") + captured: dict = {} + + async def fake_request(method: str, endpoint: str, **kwargs): + if endpoint.endswith("/labels") and method == "GET": + return [{"id": 42, "name": "bug"}] + if endpoint.endswith("/issues/1/labels") and method == "POST": + captured["body"] = kwargs.get("json_body") + return {"labels": [{"name": "bug"}]} + return {} + + client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign] + await client.add_labels("o", "r", 1, ["bug"]) + assert captured["body"] == {"labels": [42]} + + def test_git_refs_allow_slash_containing_refs() -> None: """Legitimate refs that contain '/' validate successfully.""" tree = FileTreeArgs(owner="o", repo="r", ref="feature/foo") diff --git a/tests/test_tools_extended.py b/tests/test_tools_extended.py index 5dedee5..5e3ae48 100644 --- a/tests/test_tools_extended.py +++ b/tests/test_tools_extended.py @@ -24,7 +24,9 @@ from aegis_gitea_mcp.tools.write_tools import ( create_issue_tool, create_label_tool, create_pr_comment_tool, + remove_labels_tool, update_issue_tool, + update_label_tool, ) @@ -101,6 +103,17 @@ class StubGitea: async def create_label(self, owner, repo, *, name, color, description="", exclusive=False): return {"id": 5, "name": name, "color": color, "description": description, "url": "u"} + async def update_label(self, owner, repo, *, name, new_name=None, color=None, description=None): + return { + "id": 5, + "name": new_name or name, + "color": color or "#ffffff", + "description": description or "", + } + + async def remove_labels(self, owner, repo, index, labels): + return [] + class ErrorGitea(StubGitea): """Stub that raises backend errors for failure-mode coverage.""" @@ -178,6 +191,16 @@ async def test_extended_read_tools_failure_mode() -> None: {"owner": "acme", "repo": "app", "name": "bug", "color": "#ff0000"}, "id", ), + ( + update_label_tool, + {"owner": "acme", "repo": "app", "name": "bug", "new_name": "defect"}, + "id", + ), + ( + remove_labels_tool, + {"owner": "acme", "repo": "app", "issue_number": 1, "labels": ["bug"]}, + "removed", + ), ], ) async def test_write_tools_success(tool, args, expected_key):