trello-tools: плагин и маркетплейс gp-tools для Claude Code

Сервер запускается через uv run --script, ключи запрашиваются
при установке плагина (userConfig) вместо .env.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 14:14:29 +00:00
commit b4cabbf146
14 changed files with 2100 additions and 0 deletions
+283
View File
@@ -0,0 +1,283 @@
import json
import sys
from pathlib import Path
import httpx
import pytest
import respx
from mcp.server.mcpserver import AcceptedElicitation, DeclinedElicitation
from mcp.shared.exceptions import MCPError
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import server # noqa: E402
import trello # noqa: E402
LISTS_URL = "https://api.trello.com/1/boards/board1/lists"
CARDS_URL = "https://api.trello.com/1/cards"
BOARD_LISTS = [
{"id": "list-backlog", "name": "Бэклог"},
{"id": "list-bugs", "name": "Баги с прода"},
{"id": "list-done", "name": "Готово"},
]
@pytest.fixture(autouse=True)
def trello_env(monkeypatch):
monkeypatch.setenv("TRELLO_API_KEY", "key123")
monkeypatch.setenv("TRELLO_TOKEN", "tok456")
monkeypatch.setenv("TRELLO_BOARD_ID", "board1")
monkeypatch.setenv("TRELLO_CREATE_LISTS", " Бэклог , Баги с прода,")
class FakeContext:
def __init__(self, answer=None, error=None):
self.answer = answer
self.error = error
self.messages = []
async def elicit(self, message, schema):
self.messages.append(message)
if self.error:
raise self.error
return self.answer
def test_create_targets_from_env():
assert trello.create_targets() == ["Бэклог", "Баги с прода"]
@pytest.mark.asyncio
async def test_resolve_rejects_list_not_in_env():
# «Готово» есть на доске, но не разрешён — в API даже не ходим.
with respx.mock:
with pytest.raises(ValueError, match="не разрешён"):
await trello.resolve_create_list("Готово")
@pytest.mark.asyncio
async def test_resolve_finds_list_case_insensitive():
with respx.mock:
respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS))
target = await trello.resolve_create_list("баги С прода")
assert target == {"id": "list-bugs", "name": "Баги с прода"}
@pytest.mark.asyncio
async def test_resolve_list_missing_on_board():
with respx.mock:
respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS[2:]))
with pytest.raises(ValueError, match="не найден"):
await trello.resolve_create_list("Бэклог")
@pytest.mark.asyncio
async def test_confirmed_card_is_created_with_exact_text():
ctx = FakeContext(AcceptedElicitation(data=server.CardConfirmation(confirm=True)))
with respx.mock:
respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS))
create = respx.post(CARDS_URL).mock(
return_value=httpx.Response(
200, json={"id": "c1", "name": "Падает экспорт", "shortUrl": "https://trello.com/c/xyz"}
)
)
result = await server.create_card_confirmed(ctx, "Бэклог", "Падает экспорт", "Шаги: ...")
assert json.loads(create.calls.last.request.content) == {
"idList": "list-backlog",
"name": "Падает экспорт",
"desc": "Шаги: ...",
}
assert result == {
"created": True,
"list": "Бэклог",
"template": None,
"labels": [],
"id": "c1",
"name": "Падает экспорт",
"url": "https://trello.com/c/xyz",
}
assert "Падает экспорт" in ctx.messages[0] and "Шаги: ..." in ctx.messages[0]
@pytest.mark.parametrize(
"ctx",
[
FakeContext(DeclinedElicitation()),
FakeContext(AcceptedElicitation(data=server.CardConfirmation(confirm=False))),
FakeContext(error=MCPError(code=-32601, message="elicitation not supported")),
],
)
@pytest.mark.asyncio
async def test_card_not_created_without_confirmation(ctx):
with respx.mock:
respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS))
create = respx.post(CARDS_URL)
result = await server.create_card_confirmed(ctx, "Бэклог", "T", "D")
assert result["created"] is False
assert not create.called
BOARD_CARDS_URL = "https://api.trello.com/1/boards/board1/cards"
BOARD_CARDS = [
{"id": "card-plain", "name": "Обычная", "desc": "", "idList": "list-backlog", "labels": [], "isTemplate": False},
{
"id": "tpl-bug",
"name": "баг",
"desc": "## Шаги\n## Ожидаемо",
"idList": "list-backlog",
"labels": [{"name": "Bug", "color": "red"}],
"isTemplate": True,
"idMembers": ["m1"],
"cover": {"color": "red", "size": "normal", "brightness": "light", "yPosition": 0.5},
},
# Архивный шаблон в архивном списке — тоже нужен.
{"id": "tpl-old", "name": "старый", "desc": "", "idList": "list-archived", "labels": [], "isTemplate": True},
]
TEMPLATE_CHECKLISTS = {
"tpl-bug": [{"name": "DoD", "checkItems": [{"name": "Тесты"}]}],
"tpl-old": [],
}
ALL_LISTS = BOARD_LISTS + [{"id": "list-archived", "name": "How to use this Board"}]
BOARD_MEMBERS_URL = "https://api.trello.com/1/boards/board1/members"
BOARD_LABELS_URL = "https://api.trello.com/1/boards/board1/labels"
BOARD_LABELS = [
{"id": "lb-bug", "name": "bug", "color": "orange", "uses": 102},
{"id": "lb-np", "name": "Нова Пошта", "color": "yellow_light", "uses": 41},
{"id": "lb-np-old", "name": "нова пошта", "color": "yellow", "uses": 1},
{"id": "lb-empty", "name": "", "color": "purple", "uses": 2},
]
def _mock_board():
respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=ALL_LISTS))
respx.get(BOARD_MEMBERS_URL).mock(
return_value=httpx.Response(200, json=[{"id": "m1", "fullName": "Alexey Poddubniy"}])
)
respx.get(BOARD_LABELS_URL).mock(return_value=httpx.Response(200, json=BOARD_LABELS))
for card_id, checklists in TEMPLATE_CHECKLISTS.items():
respx.get(f"{CARDS_URL}/{card_id}/checklists").mock(
return_value=httpx.Response(200, json=checklists)
)
return respx.get(BOARD_CARDS_URL).mock(return_value=httpx.Response(200, json=BOARD_CARDS))
@pytest.mark.asyncio
async def test_list_templates_only_template_cards():
with respx.mock:
cards = _mock_board()
templates = await trello.list_templates()
assert cards.calls.last.request.url.params["filter"] == "all"
assert templates == [
{
"id": "tpl-bug",
"name": "баг",
"list": "Бэклог",
"desc": "## Шаги\n## Ожидаемо",
"members": ["Alexey Poddubniy"],
"cover": {"color": "red", "size": "normal", "brightness": "light"},
"labels": [{"name": "Bug", "color": "red"}],
"checklists": [{"name": "DoD", "items": ["Тесты"]}],
},
{
"id": "tpl-old",
"name": "старый",
"list": "How to use this Board",
"desc": "",
"members": [],
"cover": None,
"labels": [],
"checklists": [],
},
]
@pytest.mark.asyncio
async def test_resolve_template_unknown_lists_available():
with respx.mock:
_mock_board()
with pytest.raises(ValueError, match="Есть: баг, старый"):
await trello.resolve_template("Фича")
@pytest.mark.asyncio
async def test_card_from_template_copies_and_fixes_desc_and_flag():
ctx = FakeContext(AcceptedElicitation(data=server.CardConfirmation()))
with respx.mock:
_mock_board()
create = respx.post(CARDS_URL).mock(
return_value=httpx.Response(
200,
json={
"id": "c1",
"name": "Падает экспорт",
"shortUrl": "https://trello.com/c/xyz",
# Trello скопировал описание и флаг шаблона.
"desc": "## Шаги\n## Ожидаемо",
"isTemplate": True,
"idLabels": ["lb-tpl"],
},
)
)
fix = respx.put(f"{CARDS_URL}/c1").mock(return_value=httpx.Response(200, json={}))
result = await server.create_card_confirmed(
ctx,
"Бэклог",
"Падает экспорт",
"## Шаги\n1. Нажать",
template_name="БАГ",
label_names=["НОВА ПОШТА", "bug"],
)
sent = json.loads(create.calls.last.request.content)
assert sent["idCardSource"] == "tpl-bug"
assert {"checklists", "labels", "members"} <= set(sent["keepFromSource"].split(","))
assert json.loads(fix.calls.last.request.content) == {
"desc": "## Шаги\n1. Нажать",
"isTemplate": False,
"cover": {"color": "red", "size": "normal", "brightness": "light"},
# Метки шаблона сохраняются, выбранные добавляются; при дублях
# названий берётся самая используемая метка.
"idLabels": "lb-tpl,lb-np,lb-bug",
}
assert result["template"] == "баг"
assert result["labels"] == ["Нова Пошта", "bug"]
preview = ctx.messages[0]
assert "Шаблон: баг" in preview and "чек-лист «DoD»" in preview
assert "участники: Alexey Poddubniy" in preview and "обложка: red" in preview
assert "Метки: Нова Пошта, bug" in preview
@pytest.mark.asyncio
async def test_list_labels_skips_unnamed_and_sorts():
with respx.mock:
_mock_board()
labels = await trello.list_labels()
assert [label["name"] for label in labels] == ["bug", "Нова Пошта", "нова пошта"]
@pytest.mark.asyncio
async def test_unknown_label_fails_before_confirmation():
ctx = FakeContext(AcceptedElicitation(data=server.CardConfirmation()))
with respx.mock:
_mock_board()
create = respx.post(CARDS_URL)
with pytest.raises(ValueError, match="'Нет такой'"):
await server.create_card_confirmed(
ctx, "Бэклог", "T", "D", label_names=["bug", "Нет такой"]
)
assert not ctx.messages and not create.called
def test_confirmation_checked_by_default():
assert server.CardConfirmation().confirm is True