fix(deck): address PR #759 round-2 review feedback
- Move description_max_length validation to tool layer
(_validate_description_max_length), matching the existing
_validate_comment_message pattern; helper now trusts callers per
CLAUDE.md ("validate at system boundaries only").
- Fix mutation/return inconsistency: deck_get_stacks now uses a list
comprehension to capture _apply_stack_filters' return, matching
deck_get_stack / deck_get_archived_stacks.
- Rename include_archived -> include_archived_cards on deck_get_cards
and _apply_card_filters for consistency with deck_get_stacks.
- Route deck_get_archived_stacks through _apply_stack_filters so
future filters apply uniformly to active + archived paths.
- Trim _truncate_card_descriptions docstring to one line; add inline
comment in _apply_stack_filters explaining the breaking-change
default (mirrors Deck UI archived-card filtering).
- Replace fragile call_args[0][1] with call_args.args[1] in the
archived-stacks client test.
- Modernize Optional[X] -> X | None throughout deck.py (adjacent
cleanup called out in the review).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
7d633a945d
commit
b7805c2180
@@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
@@ -31,32 +30,21 @@ from nextcloud_mcp_server.observability.metrics import instrument_tool
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _truncate_card_descriptions(
|
def _validate_description_max_length(description_max_length: int | None) -> None:
|
||||||
cards: list[DeckCard], description_max_length: int | None
|
"""Tool-layer guard: reject zero/negative truncation thresholds."""
|
||||||
) -> None:
|
if description_max_length is not None and description_max_length <= 0:
|
||||||
"""Truncate each card's description in-place when it strictly exceeds the
|
|
||||||
limit.
|
|
||||||
|
|
||||||
Descriptions whose length is less than or equal to ``description_max_length``
|
|
||||||
are left untouched (no ellipsis is appended). Descriptions longer than the
|
|
||||||
limit are truncated to ``description_max_length`` characters and an
|
|
||||||
ellipsis ("…") is appended, so the resulting string is
|
|
||||||
``description_max_length + 1`` characters total.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cards: Cards to mutate in place.
|
|
||||||
description_max_length: Positive truncation threshold, or ``None`` to
|
|
||||||
skip truncation entirely.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If ``description_max_length`` is not positive.
|
|
||||||
"""
|
|
||||||
if description_max_length is None:
|
|
||||||
return
|
|
||||||
if description_max_length <= 0:
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"description_max_length must be positive, got {description_max_length}"
|
f"description_max_length must be positive, got {description_max_length}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_card_descriptions(
|
||||||
|
cards: list[DeckCard], description_max_length: int | None
|
||||||
|
) -> None:
|
||||||
|
"""Truncate descriptions strictly longer than the limit; appends "…" so
|
||||||
|
the truncated result is ``description_max_length + 1`` chars."""
|
||||||
|
if description_max_length is None:
|
||||||
|
return
|
||||||
for card in cards:
|
for card in cards:
|
||||||
if card.description and len(card.description) > description_max_length:
|
if card.description and len(card.description) > description_max_length:
|
||||||
card.description = card.description[:description_max_length] + "…"
|
card.description = card.description[:description_max_length] + "…"
|
||||||
@@ -87,6 +75,10 @@ def _apply_stack_filters(
|
|||||||
description_max_length: int | None,
|
description_max_length: int | None,
|
||||||
) -> DeckStack:
|
) -> DeckStack:
|
||||||
"""Apply card-shaping filters to a single stack (in-place)."""
|
"""Apply card-shaping filters to a single stack (in-place)."""
|
||||||
|
# Note: the upstream Deck API returns archived cards inline within
|
||||||
|
# active stacks (the Deck UI filters them frontend-side). Defaulting
|
||||||
|
# include_archived_cards to False mirrors that UI behavior — this is
|
||||||
|
# the breaking change called out in the PR description.
|
||||||
if not include_cards:
|
if not include_cards:
|
||||||
stack.cards = None
|
stack.cards = None
|
||||||
elif stack.cards:
|
elif stack.cards:
|
||||||
@@ -99,11 +91,11 @@ def _apply_stack_filters(
|
|||||||
def _apply_card_filters(
|
def _apply_card_filters(
|
||||||
cards: list[DeckCard],
|
cards: list[DeckCard],
|
||||||
*,
|
*,
|
||||||
include_archived: bool,
|
include_archived_cards: bool,
|
||||||
description_max_length: int | None,
|
description_max_length: int | None,
|
||||||
) -> list[DeckCard]:
|
) -> list[DeckCard]:
|
||||||
"""Apply filters to a flat list of cards. Returns a (possibly new) list."""
|
"""Apply filters to a flat list of cards. Returns a (possibly new) list."""
|
||||||
if not include_archived:
|
if not include_archived_cards:
|
||||||
cards = [c for c in cards if not c.archived]
|
cards = [c for c in cards if not c.archived]
|
||||||
_truncate_card_descriptions(cards, description_max_length)
|
_truncate_card_descriptions(cards, description_max_length)
|
||||||
return cards
|
return cards
|
||||||
@@ -275,15 +267,18 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
to this many characters. Useful for keeping responses compact
|
to this many characters. Useful for keeping responses compact
|
||||||
on boards with long card specs.
|
on boards with long card specs.
|
||||||
"""
|
"""
|
||||||
|
_validate_description_max_length(description_max_length)
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
stacks = await client.deck.get_stacks(board_id)
|
stacks = await client.deck.get_stacks(board_id)
|
||||||
for stack in stacks:
|
stacks = [
|
||||||
_apply_stack_filters(
|
_apply_stack_filters(
|
||||||
stack,
|
stack,
|
||||||
include_cards=include_cards,
|
include_cards=include_cards,
|
||||||
include_archived_cards=include_archived_cards,
|
include_archived_cards=include_archived_cards,
|
||||||
description_max_length=description_max_length,
|
description_max_length=description_max_length,
|
||||||
)
|
)
|
||||||
|
for stack in stacks
|
||||||
|
]
|
||||||
return ListStacksResponse(stacks=stacks, total=len(stacks))
|
return ListStacksResponse(stacks=stacks, total=len(stacks))
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
@@ -311,6 +306,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
description_max_length: If set, truncate each card's description
|
description_max_length: If set, truncate each card's description
|
||||||
to this many characters.
|
to this many characters.
|
||||||
"""
|
"""
|
||||||
|
_validate_description_max_length(description_max_length)
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
stack = await client.deck.get_stack(board_id, stack_id)
|
stack = await client.deck.get_stack(board_id, stack_id)
|
||||||
return _apply_stack_filters(
|
return _apply_stack_filters(
|
||||||
@@ -343,11 +339,21 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
description_max_length: If set, truncate each card's description
|
description_max_length: If set, truncate each card's description
|
||||||
to this many characters.
|
to this many characters.
|
||||||
"""
|
"""
|
||||||
|
_validate_description_max_length(description_max_length)
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
stacks = await client.deck.get_archived_stacks(board_id)
|
stacks = await client.deck.get_archived_stacks(board_id)
|
||||||
for stack in stacks:
|
# All cards in archived stacks are themselves archived; route through
|
||||||
if stack.cards:
|
# the same helper as the active-stack path so future filter additions
|
||||||
_truncate_card_descriptions(stack.cards, description_max_length)
|
# apply uniformly.
|
||||||
|
stacks = [
|
||||||
|
_apply_stack_filters(
|
||||||
|
stack,
|
||||||
|
include_cards=True,
|
||||||
|
include_archived_cards=True,
|
||||||
|
description_max_length=description_max_length,
|
||||||
|
)
|
||||||
|
for stack in stacks
|
||||||
|
]
|
||||||
return ListStacksResponse(stacks=stacks, total=len(stacks))
|
return ListStacksResponse(stacks=stacks, total=len(stacks))
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
@@ -360,7 +366,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
ctx: Context,
|
ctx: Context,
|
||||||
board_id: int,
|
board_id: int,
|
||||||
stack_id: int,
|
stack_id: int,
|
||||||
include_archived: bool = False,
|
include_archived_cards: bool = False,
|
||||||
description_max_length: int | None = None,
|
description_max_length: int | None = None,
|
||||||
) -> ListCardsResponse:
|
) -> ListCardsResponse:
|
||||||
"""Get all cards in a Nextcloud Deck stack.
|
"""Get all cards in a Nextcloud Deck stack.
|
||||||
@@ -368,17 +374,18 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
Args:
|
Args:
|
||||||
board_id: The ID of the board
|
board_id: The ID of the board
|
||||||
stack_id: The ID of the stack
|
stack_id: The ID of the stack
|
||||||
include_archived: Include archived cards (default False). Archived
|
include_archived_cards: Include archived cards (default False).
|
||||||
cards can also be retrieved per-board via
|
Archived cards can also be retrieved per-board via
|
||||||
deck_get_archived_stacks.
|
deck_get_archived_stacks.
|
||||||
description_max_length: If set, truncate each card's description
|
description_max_length: If set, truncate each card's description
|
||||||
to this many characters.
|
to this many characters.
|
||||||
"""
|
"""
|
||||||
|
_validate_description_max_length(description_max_length)
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
stack = await client.deck.get_stack(board_id, stack_id)
|
stack = await client.deck.get_stack(board_id, stack_id)
|
||||||
cards = _apply_card_filters(
|
cards = _apply_card_filters(
|
||||||
stack.cards or [],
|
stack.cards or [],
|
||||||
include_archived=include_archived,
|
include_archived_cards=include_archived_cards,
|
||||||
description_max_length=description_max_length,
|
description_max_length=description_max_length,
|
||||||
)
|
)
|
||||||
return ListCardsResponse(cards=cards, total=len(cards))
|
return ListCardsResponse(cards=cards, total=len(cards))
|
||||||
@@ -475,8 +482,8 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
ctx: Context,
|
ctx: Context,
|
||||||
board_id: int,
|
board_id: int,
|
||||||
stack_id: int,
|
stack_id: int,
|
||||||
title: Optional[str] = None,
|
title: str | None = None,
|
||||||
order: Optional[int] = None,
|
order: int | None = None,
|
||||||
) -> StackOperationResponse:
|
) -> StackOperationResponse:
|
||||||
"""Update a Nextcloud Deck stack
|
"""Update a Nextcloud Deck stack
|
||||||
|
|
||||||
@@ -535,8 +542,8 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title: str,
|
title: str,
|
||||||
type: str = "plain",
|
type: str = "plain",
|
||||||
order: int = 999,
|
order: int = 999,
|
||||||
description: Optional[str] = None,
|
description: str | None = None,
|
||||||
duedate: Optional[str] = None,
|
duedate: str | None = None,
|
||||||
) -> CreateCardResponse:
|
) -> CreateCardResponse:
|
||||||
"""Create a new card in a Nextcloud Deck stack
|
"""Create a new card in a Nextcloud Deck stack
|
||||||
|
|
||||||
@@ -571,14 +578,14 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id: int,
|
board_id: int,
|
||||||
stack_id: int,
|
stack_id: int,
|
||||||
card_id: int,
|
card_id: int,
|
||||||
title: Optional[str] = None,
|
title: str | None = None,
|
||||||
description: Optional[str] = None,
|
description: str | None = None,
|
||||||
type: Optional[str] = None,
|
type: str | None = None,
|
||||||
owner: Optional[str] = None,
|
owner: str | None = None,
|
||||||
order: Optional[int] = None,
|
order: int | None = None,
|
||||||
duedate: Optional[str] = None,
|
duedate: str | None = None,
|
||||||
archived: Optional[bool] = None,
|
archived: bool | None = None,
|
||||||
done: Optional[str] = None,
|
done: str | None = None,
|
||||||
) -> CardOperationResponse:
|
) -> CardOperationResponse:
|
||||||
"""Update a Nextcloud Deck card
|
"""Update a Nextcloud Deck card
|
||||||
|
|
||||||
@@ -763,8 +770,8 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
ctx: Context,
|
ctx: Context,
|
||||||
board_id: int,
|
board_id: int,
|
||||||
label_id: int,
|
label_id: int,
|
||||||
title: Optional[str] = None,
|
title: str | None = None,
|
||||||
color: Optional[str] = None,
|
color: str | None = None,
|
||||||
) -> LabelOperationResponse:
|
) -> LabelOperationResponse:
|
||||||
"""Update a Nextcloud Deck label
|
"""Update a Nextcloud Deck label
|
||||||
|
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ async def test_deck_get_archived_stacks(mocker):
|
|||||||
assert stacks[0].id == 9
|
assert stacks[0].id == 9
|
||||||
|
|
||||||
mock_make_request.assert_called_once()
|
mock_make_request.assert_called_once()
|
||||||
assert "/boards/123/stacks/archived" in mock_make_request.call_args[0][1]
|
assert "/boards/123/stacks/archived" in mock_make_request.call_args.args[1]
|
||||||
|
|
||||||
|
|
||||||
# Card Tests
|
# Card Tests
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from nextcloud_mcp_server.server.deck import (
|
|||||||
_apply_card_filters,
|
_apply_card_filters,
|
||||||
_apply_stack_filters,
|
_apply_stack_filters,
|
||||||
_truncate_card_descriptions,
|
_truncate_card_descriptions,
|
||||||
|
_validate_description_max_length,
|
||||||
)
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
@@ -128,18 +129,30 @@ def test_truncate_card_descriptions_shorter_than_limit_no_ellipsis():
|
|||||||
assert cards[0].description == "hello"
|
assert cards[0].description == "hello"
|
||||||
|
|
||||||
|
|
||||||
def test_truncate_card_descriptions_rejects_zero():
|
# _validate_description_max_length ----------------------------------------
|
||||||
"""A zero limit is invalid (would wipe descriptions to a single ellipsis)."""
|
|
||||||
cards = [_make_card(1, "anything")]
|
|
||||||
with pytest.raises(ValueError, match="must be positive"):
|
|
||||||
_truncate_card_descriptions(cards, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_truncate_card_descriptions_rejects_negative():
|
def test_validate_description_max_length_accepts_none():
|
||||||
"""Negative limits are invalid."""
|
"""None is the documented sentinel for "no truncation"."""
|
||||||
cards = [_make_card(1, "anything")]
|
_validate_description_max_length(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_description_max_length_accepts_positive():
|
||||||
|
"""Positive values pass through silently."""
|
||||||
|
_validate_description_max_length(1)
|
||||||
|
_validate_description_max_length(1000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_description_max_length_rejects_zero():
|
||||||
|
"""Zero would wipe descriptions to a single ellipsis — reject at the boundary."""
|
||||||
with pytest.raises(ValueError, match="must be positive"):
|
with pytest.raises(ValueError, match="must be positive"):
|
||||||
_truncate_card_descriptions(cards, -10)
|
_validate_description_max_length(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_description_max_length_rejects_negative():
|
||||||
|
"""Negative values produce surprising slice semantics — reject at the boundary."""
|
||||||
|
with pytest.raises(ValueError, match="must be positive"):
|
||||||
|
_validate_description_max_length(-10)
|
||||||
|
|
||||||
|
|
||||||
# _apply_board_filters ------------------------------------------------------
|
# _apply_board_filters ------------------------------------------------------
|
||||||
@@ -287,23 +300,23 @@ def test_apply_stack_filters_handles_none_cards():
|
|||||||
|
|
||||||
|
|
||||||
def test_apply_card_filters_excludes_archived_by_default():
|
def test_apply_card_filters_excludes_archived_by_default():
|
||||||
"""include_archived=False filters archived cards out of the flat list."""
|
"""include_archived_cards=False filters archived cards out of the flat list."""
|
||||||
cards = [
|
cards = [
|
||||||
_make_card(1, archived=False),
|
_make_card(1, archived=False),
|
||||||
_make_card(2, archived=True),
|
_make_card(2, archived=True),
|
||||||
_make_card(3, archived=False),
|
_make_card(3, archived=False),
|
||||||
]
|
]
|
||||||
result = _apply_card_filters(
|
result = _apply_card_filters(
|
||||||
cards, include_archived=False, description_max_length=None
|
cards, include_archived_cards=False, description_max_length=None
|
||||||
)
|
)
|
||||||
assert [c.id for c in result] == [1, 3]
|
assert [c.id for c in result] == [1, 3]
|
||||||
|
|
||||||
|
|
||||||
def test_apply_card_filters_keeps_archived_when_requested():
|
def test_apply_card_filters_keeps_archived_when_requested():
|
||||||
"""include_archived=True retains archived cards."""
|
"""include_archived_cards=True retains archived cards."""
|
||||||
cards = [_make_card(1, archived=False), _make_card(2, archived=True)]
|
cards = [_make_card(1, archived=False), _make_card(2, archived=True)]
|
||||||
result = _apply_card_filters(
|
result = _apply_card_filters(
|
||||||
cards, include_archived=True, description_max_length=None
|
cards, include_archived_cards=True, description_max_length=None
|
||||||
)
|
)
|
||||||
assert [c.id for c in result] == [1, 2]
|
assert [c.id for c in result] == [1, 2]
|
||||||
|
|
||||||
@@ -312,7 +325,7 @@ def test_apply_card_filters_truncates_descriptions():
|
|||||||
"""description_max_length is honored on the returned cards."""
|
"""description_max_length is honored on the returned cards."""
|
||||||
cards = [_make_card(1, description="x" * 50)]
|
cards = [_make_card(1, description="x" * 50)]
|
||||||
result = _apply_card_filters(
|
result = _apply_card_filters(
|
||||||
cards, include_archived=True, description_max_length=10
|
cards, include_archived_cards=True, description_max_length=10
|
||||||
)
|
)
|
||||||
assert result[0].description is not None
|
assert result[0].description is not None
|
||||||
assert result[0].description.endswith("…")
|
assert result[0].description.endswith("…")
|
||||||
@@ -320,5 +333,7 @@ def test_apply_card_filters_truncates_descriptions():
|
|||||||
|
|
||||||
def test_apply_card_filters_empty_list_is_noop():
|
def test_apply_card_filters_empty_list_is_noop():
|
||||||
"""An empty input returns an empty output."""
|
"""An empty input returns an empty output."""
|
||||||
result = _apply_card_filters([], include_archived=False, description_max_length=10)
|
result = _apply_card_filters(
|
||||||
|
[], include_archived_cards=False, description_max_length=10
|
||||||
|
)
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|||||||
Reference in New Issue
Block a user