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:
@@ -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
|
||||
Reference in New Issue
Block a user