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 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 20:34:35 +02:00
parent f0db219ee8
commit c282ffe359
8 changed files with 306 additions and 11 deletions
+46 -1
View File
@@ -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")