Merge pull request #826 from cbcoutinho/feat/deck-compact-retrieval
feat(deck): compact card/comment retrieval (summaries, filters, board overview)
This commit is contained in:
@@ -4,6 +4,16 @@
|
|||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
|
| `deck_get_boards` | List all Deck boards |
|
||||||
|
| `deck_get_board` | Get a board (toggle `include_acl` / `include_users` / `include_labels`) |
|
||||||
|
| `deck_get_board_overview` | **Compact whole-board snapshot** — board → stacks → summary card rows in one call |
|
||||||
|
| `deck_get_stacks` | List stacks in a board (cards as compact summaries by default) |
|
||||||
|
| `deck_get_stack` | Get a single stack (cards as compact summaries by default) |
|
||||||
|
| `deck_get_archived_stacks` | List archived stacks and their cards |
|
||||||
|
| `deck_get_cards` | List cards in a stack (compact summaries by default) |
|
||||||
|
| `deck_get_card` | Get a single card in full detail |
|
||||||
|
| `deck_get_labels` / `deck_get_label` | List / get board labels |
|
||||||
|
| `deck_get_card_comments` | List card comments (compact, newest-first by default) |
|
||||||
| `deck_create_board` | Create a new Deck board with title and color |
|
| `deck_create_board` | Create a new Deck board with title and color |
|
||||||
| `deck_create_stack` | Create a new stack in a board |
|
| `deck_create_stack` | Create a new stack in a board |
|
||||||
| `deck_update_stack` | Update stack title and order |
|
| `deck_update_stack` | Update stack title and order |
|
||||||
@@ -36,6 +46,44 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Compact Retrieval (token efficiency)
|
||||||
|
|
||||||
|
On large boards the full card objects (description, nested labels, assigned
|
||||||
|
users, attachments, etags) make `deck_get_stacks` responses too large to be
|
||||||
|
practical. The read tools therefore return **compact card summaries by
|
||||||
|
default** and support filtering so you fetch only what you need.
|
||||||
|
|
||||||
|
**Shared knobs** on `deck_get_cards`, `deck_get_stacks`, `deck_get_stack`
|
||||||
|
(and `deck_get_archived_stacks`, minus `status`):
|
||||||
|
|
||||||
|
| Parameter | Default | Effect |
|
||||||
|
|-----------|---------|--------|
|
||||||
|
| `detail` | `summary` | `summary` returns compact rows (id, title, stackId, labels as titles, assignee UIDs, due/done, counts, a short `descriptionPreview`); `full` returns the complete card objects (the pre-0.92 shape). |
|
||||||
|
| `status` | `open` | Filter before serialization: `open`, `done`, `archived`, or `all`. The first three **partition** the board (no overlap) — a card that is both done and archived is reported only under `archived`. |
|
||||||
|
| `label` | – | Only cards carrying a label with this exact title. |
|
||||||
|
| `assigned_to` | – | Only cards assigned to this user UID. |
|
||||||
|
| `description_max_length` | – | In `detail="full"`, truncate each description. |
|
||||||
|
| `description_preview_length` | `140` | In `detail="summary"`, length of the preview. |
|
||||||
|
|
||||||
|
**`deck_get_board_overview(board_id, status="open", label=…, assigned_to=…)`**
|
||||||
|
is the token-efficient way to see a whole board: it returns the board title,
|
||||||
|
its label legend, and every stack with compact card rows in a single call —
|
||||||
|
prefer it over `deck_get_board` + `deck_get_stacks` for "show me the board"
|
||||||
|
requests. Use `deck_get_card` for the full body of a specific card.
|
||||||
|
|
||||||
|
**Comments** — `deck_get_card_comments` returns compact comments
|
||||||
|
(`id`, `actorId`, `message`, `creationDateTime`) by default. Use
|
||||||
|
`detail="full"` for the complete objects, `message_max_length` to truncate,
|
||||||
|
`order` (`newest`/`oldest`) to sort the page, and `limit`/`offset` to page.
|
||||||
|
|
||||||
|
> **Breaking change:** list tools now default to `detail="summary"`
|
||||||
|
> and `status="open"`. The previous `include_archived_cards` parameter has
|
||||||
|
> been replaced by `status` (`status="all"` includes archived cards, matching
|
||||||
|
> `include_archived_cards=True`). Pass `detail="full"` to restore the old
|
||||||
|
> per-card shape.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Deck Project Management
|
### Deck Project Management
|
||||||
|
|
||||||
The server provides complete Nextcloud Deck integration, enabling you to manage projects, tasks, and workflows:
|
The server provides complete Nextcloud Deck integration, enabling you to manage projects, tasks, and workflows:
|
||||||
|
|||||||
@@ -129,6 +129,44 @@ class DeckCard(BaseModel):
|
|||||||
return validated_users
|
return validated_users
|
||||||
|
|
||||||
|
|
||||||
|
class DeckCardSummary(BaseModel):
|
||||||
|
"""Compact projection of a :class:`DeckCard` for list/overview views.
|
||||||
|
|
||||||
|
Drops the heavy fields that dominate token cost when many cards are
|
||||||
|
returned at once — the full ``description`` (kept only as a short
|
||||||
|
``descriptionPreview``), the nested ``labels``/``assignedUsers``/
|
||||||
|
``attachments`` objects (kept as flat title/uid lists + counts), and the
|
||||||
|
``etag``/``order``/``*Modified``/``createdAt``/``deletedAt``/``overdue``/
|
||||||
|
``type``/``owner`` bookkeeping fields. Fetch a single card with
|
||||||
|
``deck_get_card`` (or ``detail="full"``) when the full body is needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
stackId: int
|
||||||
|
archived: bool = False
|
||||||
|
duedate: datetime | None = None
|
||||||
|
done: datetime | None = None
|
||||||
|
labels: list[str] = Field(
|
||||||
|
default_factory=list, description="Label titles assigned to the card"
|
||||||
|
)
|
||||||
|
assignedUsers: list[str] = Field(
|
||||||
|
default_factory=list, description="UIDs of users assigned to the card"
|
||||||
|
)
|
||||||
|
attachmentCount: int | None = Field(
|
||||||
|
default=None, description="Number of attachments on the card"
|
||||||
|
)
|
||||||
|
commentsUnread: int | None = Field(
|
||||||
|
default=None, description="Number of unread comments (Deck exposes no total)"
|
||||||
|
)
|
||||||
|
hasDescription: bool = Field(
|
||||||
|
default=False, description="Whether the card has a non-empty description"
|
||||||
|
)
|
||||||
|
descriptionPreview: str | None = Field(
|
||||||
|
default=None, description="Truncated preview of the card description"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DeckStack(BaseModel):
|
class DeckStack(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
@@ -136,7 +174,9 @@ class DeckStack(BaseModel):
|
|||||||
order: int
|
order: int
|
||||||
deletedAt: int
|
deletedAt: int
|
||||||
lastModified: Optional[int] = None
|
lastModified: Optional[int] = None
|
||||||
cards: Optional[List[DeckCard]] = None
|
# Cards may be projected to DeckCardSummary when a tool is called with
|
||||||
|
# detail="summary" (the default for list tools).
|
||||||
|
cards: list[DeckCard | DeckCardSummary] | None = None
|
||||||
etag: Optional[str] = Field(default=None, alias="ETag")
|
etag: Optional[str] = Field(default=None, alias="ETag")
|
||||||
|
|
||||||
|
|
||||||
@@ -175,6 +215,20 @@ class DeckComment(BaseModel):
|
|||||||
replyTo: Optional[Any] = None # Self-referencing, handle later if needed
|
replyTo: Optional[Any] = None # Self-referencing, handle later if needed
|
||||||
|
|
||||||
|
|
||||||
|
class DeckCommentSummary(BaseModel):
|
||||||
|
"""Compact projection of a :class:`DeckComment` for list views.
|
||||||
|
|
||||||
|
Drops ``mentions``, ``actorType``, ``actorDisplayName`` and ``replyTo``,
|
||||||
|
keeping only the fields needed to read the conversation. Long messages are
|
||||||
|
truncated by the tool layer via ``message_max_length``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
actorId: str
|
||||||
|
message: str
|
||||||
|
creationDateTime: datetime
|
||||||
|
|
||||||
|
|
||||||
class DeckSession(BaseModel):
|
class DeckSession(BaseModel):
|
||||||
token: str
|
token: str
|
||||||
|
|
||||||
@@ -220,6 +274,36 @@ class ListStacksResponse(BaseResponse):
|
|||||||
total: int = Field(description="Total number of stacks")
|
total: int = Field(description="Total number of stacks")
|
||||||
|
|
||||||
|
|
||||||
|
class StackOverview(BaseModel):
|
||||||
|
"""A stack plus its compact card rows, for the board-overview tool."""
|
||||||
|
|
||||||
|
id: int = Field(description="Stack ID")
|
||||||
|
title: str = Field(description="Stack title")
|
||||||
|
order: int | None = Field(default=None, description="Stack sort order")
|
||||||
|
card_count: int = Field(description="Number of cards returned for this stack")
|
||||||
|
cards: list[DeckCardSummary] = Field(
|
||||||
|
default_factory=list, description="Compact card rows in this stack"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BoardOverviewResponse(BaseResponse):
|
||||||
|
"""Compact whole-board snapshot: board → stacks → summary card rows.
|
||||||
|
|
||||||
|
A single-call replacement for deck_get_board + deck_get_stacks that keeps
|
||||||
|
the response small enough to fit the token budget on large boards.
|
||||||
|
"""
|
||||||
|
|
||||||
|
board_id: int = Field(description="Board ID")
|
||||||
|
title: str = Field(description="Board title")
|
||||||
|
labels: list[str] = Field(
|
||||||
|
default_factory=list, description="Board label titles (legend)"
|
||||||
|
)
|
||||||
|
stacks: list[StackOverview] = Field(
|
||||||
|
default_factory=list, description="Stacks with compact card rows"
|
||||||
|
)
|
||||||
|
total_cards: int = Field(description="Total cards across all returned stacks")
|
||||||
|
|
||||||
|
|
||||||
class CreateStackResponse(BaseResponse):
|
class CreateStackResponse(BaseResponse):
|
||||||
"""Response model for stack creation."""
|
"""Response model for stack creation."""
|
||||||
|
|
||||||
@@ -269,7 +353,9 @@ class CreateLabelResponse(BaseResponse):
|
|||||||
class ListCardsResponse(BaseResponse):
|
class ListCardsResponse(BaseResponse):
|
||||||
"""Response model for listing deck cards."""
|
"""Response model for listing deck cards."""
|
||||||
|
|
||||||
cards: list[DeckCard] = Field(description="List of deck cards")
|
cards: list[DeckCard | DeckCardSummary] = Field(
|
||||||
|
description="List of deck cards (summaries unless detail='full')"
|
||||||
|
)
|
||||||
total: int = Field(description="Total number of cards")
|
total: int = Field(description="Total number of cards")
|
||||||
|
|
||||||
|
|
||||||
@@ -293,7 +379,9 @@ class LabelOperationResponse(StatusResponse):
|
|||||||
class ListCardCommentsResponse(BaseResponse):
|
class ListCardCommentsResponse(BaseResponse):
|
||||||
"""Response model for listing card comments."""
|
"""Response model for listing card comments."""
|
||||||
|
|
||||||
results: list[DeckComment] = Field(description="Card comments in this page")
|
results: list[DeckComment | DeckCommentSummary] = Field(
|
||||||
|
description="Card comments in this page (summaries unless detail='full')"
|
||||||
|
)
|
||||||
count: int = Field(
|
count: int = Field(
|
||||||
description=(
|
description=(
|
||||||
"Number of comments returned in this page (page size, not the "
|
"Number of comments returned in this page (page size, not the "
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from typing import Literal, cast
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
@@ -9,6 +10,7 @@ from nextcloud_mcp_server.context import get_client
|
|||||||
from nextcloud_mcp_server.models.deck import (
|
from nextcloud_mcp_server.models.deck import (
|
||||||
AttachFileResponse,
|
AttachFileResponse,
|
||||||
AttachmentOperationResponse,
|
AttachmentOperationResponse,
|
||||||
|
BoardOverviewResponse,
|
||||||
CardCommentOperationResponse,
|
CardCommentOperationResponse,
|
||||||
CardCommentResponse,
|
CardCommentResponse,
|
||||||
CardOperationResponse,
|
CardOperationResponse,
|
||||||
@@ -16,10 +18,15 @@ from nextcloud_mcp_server.models.deck import (
|
|||||||
CreateCardResponse,
|
CreateCardResponse,
|
||||||
CreateLabelResponse,
|
CreateLabelResponse,
|
||||||
CreateStackResponse,
|
CreateStackResponse,
|
||||||
|
DeckAssignedUser,
|
||||||
DeckBoard,
|
DeckBoard,
|
||||||
DeckCard,
|
DeckCard,
|
||||||
|
DeckCardSummary,
|
||||||
|
DeckComment,
|
||||||
|
DeckCommentSummary,
|
||||||
DeckLabel,
|
DeckLabel,
|
||||||
DeckStack,
|
DeckStack,
|
||||||
|
DeckUser,
|
||||||
LabelOperationResponse,
|
LabelOperationResponse,
|
||||||
ListAttachmentsResponse,
|
ListAttachmentsResponse,
|
||||||
ListBoardsResponse,
|
ListBoardsResponse,
|
||||||
@@ -28,18 +35,33 @@ from nextcloud_mcp_server.models.deck import (
|
|||||||
ListLabelsResponse,
|
ListLabelsResponse,
|
||||||
ListStacksResponse,
|
ListStacksResponse,
|
||||||
StackOperationResponse,
|
StackOperationResponse,
|
||||||
|
StackOverview,
|
||||||
)
|
)
|
||||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Card status filter applied before serialization. "open" (the default for
|
||||||
|
# list tools) hides archived and explicitly-done cards — the actionable set.
|
||||||
|
CardStatus = Literal["all", "open", "done", "archived"]
|
||||||
|
# Per-card detail level. "summary" (the default for list tools) projects each
|
||||||
|
# card to a compact DeckCardSummary; "full" returns the heavy DeckCard.
|
||||||
|
DetailLevel = Literal["summary", "full"]
|
||||||
|
# Default length for the description preview carried in card summaries.
|
||||||
|
_DEFAULT_DESCRIPTION_PREVIEW = 140
|
||||||
|
|
||||||
def _validate_description_max_length(description_max_length: int | None) -> None:
|
|
||||||
"""Tool-layer guard: reject zero/negative truncation thresholds."""
|
def _validate_positive_length(
|
||||||
if description_max_length is not None and description_max_length <= 0:
|
value: int | None, name: str = "description_max_length"
|
||||||
raise ValueError(
|
) -> None:
|
||||||
f"description_max_length must be positive, got {description_max_length}"
|
"""Tool-layer guard: reject zero/negative length thresholds.
|
||||||
)
|
|
||||||
|
Reused for every positive-length knob (description truncation/preview,
|
||||||
|
comment message truncation); ``name`` keeps the error message pointed at
|
||||||
|
the parameter the caller actually passed.
|
||||||
|
"""
|
||||||
|
if value is not None and value <= 0:
|
||||||
|
raise ValueError(f"{name} must be positive, got {value}")
|
||||||
|
|
||||||
|
|
||||||
def _truncate_card_descriptions(
|
def _truncate_card_descriptions(
|
||||||
@@ -71,38 +93,161 @@ def _apply_board_filters(
|
|||||||
return board
|
return board
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_uid(user: "DeckUser | DeckAssignedUser") -> str | None:
|
||||||
|
"""Pull the bare UID out of either assigned-user shape the API returns."""
|
||||||
|
if isinstance(user, DeckAssignedUser):
|
||||||
|
return user.participant.uid
|
||||||
|
if isinstance(user, DeckUser):
|
||||||
|
return user.uid
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_cards(
|
||||||
|
cards: list[DeckCard],
|
||||||
|
*,
|
||||||
|
status: CardStatus,
|
||||||
|
label: str | None,
|
||||||
|
assigned_to: str | None,
|
||||||
|
) -> list[DeckCard]:
|
||||||
|
"""Narrow a flat card list by status/label/assignee before serialization.
|
||||||
|
|
||||||
|
The upstream Deck API returns every card (including archived ones) inline,
|
||||||
|
so this filtering reduces the tokens the caller sees but not network
|
||||||
|
bandwidth.
|
||||||
|
|
||||||
|
``open``/``done``/``archived`` partition the cards (no overlap): a card
|
||||||
|
that is both done and archived is reported only under ``archived``, since
|
||||||
|
archiving is the stronger "off the active board" state.
|
||||||
|
"""
|
||||||
|
if status == "open":
|
||||||
|
cards = [c for c in cards if not c.archived and c.done is None]
|
||||||
|
elif status == "done":
|
||||||
|
cards = [c for c in cards if c.done is not None and not c.archived]
|
||||||
|
elif status == "archived":
|
||||||
|
cards = [c for c in cards if c.archived]
|
||||||
|
# status == "all": no status filter
|
||||||
|
|
||||||
|
if label is not None:
|
||||||
|
cards = [
|
||||||
|
c for c in cards if any(lbl.title == label for lbl in (c.labels or []))
|
||||||
|
]
|
||||||
|
if assigned_to is not None:
|
||||||
|
cards = [
|
||||||
|
c
|
||||||
|
for c in cards
|
||||||
|
if assigned_to in {_extract_uid(u) for u in (c.assignedUsers or [])}
|
||||||
|
]
|
||||||
|
return cards
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_card(card: DeckCard, description_preview_length: int) -> DeckCardSummary:
|
||||||
|
"""Project a full DeckCard down to its compact DeckCardSummary."""
|
||||||
|
description = card.description or ""
|
||||||
|
has_description = bool(description.strip())
|
||||||
|
preview: str | None = None
|
||||||
|
if has_description:
|
||||||
|
preview = description[:description_preview_length]
|
||||||
|
if len(description) > description_preview_length:
|
||||||
|
preview += "…"
|
||||||
|
assignees = [
|
||||||
|
uid for u in (card.assignedUsers or []) if (uid := _extract_uid(u)) is not None
|
||||||
|
]
|
||||||
|
return DeckCardSummary(
|
||||||
|
id=card.id,
|
||||||
|
title=card.title,
|
||||||
|
stackId=card.stackId,
|
||||||
|
archived=card.archived,
|
||||||
|
duedate=card.duedate,
|
||||||
|
done=card.done,
|
||||||
|
labels=[lbl.title for lbl in (card.labels or [])],
|
||||||
|
assignedUsers=assignees,
|
||||||
|
attachmentCount=card.attachmentCount,
|
||||||
|
commentsUnread=card.commentsUnread,
|
||||||
|
hasDescription=has_description,
|
||||||
|
descriptionPreview=preview,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _shape_cards(
|
||||||
|
cards: list[DeckCard],
|
||||||
|
*,
|
||||||
|
detail: DetailLevel,
|
||||||
|
status: CardStatus,
|
||||||
|
label: str | None,
|
||||||
|
assigned_to: str | None,
|
||||||
|
description_max_length: int | None,
|
||||||
|
description_preview_length: int,
|
||||||
|
) -> list[DeckCard | DeckCardSummary]:
|
||||||
|
"""Filter then project a card list according to the requested detail level."""
|
||||||
|
filtered = _filter_cards(cards, status=status, label=label, assigned_to=assigned_to)
|
||||||
|
if detail == "full":
|
||||||
|
_truncate_card_descriptions(filtered, description_max_length)
|
||||||
|
return list(filtered)
|
||||||
|
return [_summarize_card(c, description_preview_length) for c in filtered]
|
||||||
|
|
||||||
|
|
||||||
def _apply_stack_filters(
|
def _apply_stack_filters(
|
||||||
stack: DeckStack,
|
stack: DeckStack,
|
||||||
*,
|
*,
|
||||||
include_cards: bool,
|
include_cards: bool,
|
||||||
include_archived_cards: bool,
|
detail: DetailLevel,
|
||||||
|
status: CardStatus,
|
||||||
|
label: str | None,
|
||||||
|
assigned_to: str | None,
|
||||||
description_max_length: int | None,
|
description_max_length: int | None,
|
||||||
|
description_preview_length: int,
|
||||||
) -> DeckStack:
|
) -> DeckStack:
|
||||||
"""Apply card-shaping filters to a single stack; returns the stack."""
|
"""Apply card filtering + projection to a single stack; returns the stack."""
|
||||||
# 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:
|
||||||
if not include_archived_cards:
|
# Cards come straight from the client as DeckCard; the field type is a
|
||||||
stack.cards = [c for c in stack.cards if not c.archived]
|
# union only because summary projection writes summaries back into it.
|
||||||
_truncate_card_descriptions(stack.cards, description_max_length)
|
stack.cards = _shape_cards(
|
||||||
|
cast(list[DeckCard], stack.cards),
|
||||||
|
detail=detail,
|
||||||
|
status=status,
|
||||||
|
label=label,
|
||||||
|
assigned_to=assigned_to,
|
||||||
|
description_max_length=description_max_length,
|
||||||
|
description_preview_length=description_preview_length,
|
||||||
|
)
|
||||||
return stack
|
return stack
|
||||||
|
|
||||||
|
|
||||||
def _apply_card_filters(
|
def _truncate_comment_message(message: str, message_max_length: int | None) -> str:
|
||||||
cards: list[DeckCard],
|
"""Truncate a comment strictly longer than the limit; appends "…"."""
|
||||||
|
if message_max_length is not None and len(message) > message_max_length:
|
||||||
|
return message[:message_max_length] + "…"
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def _shape_comments(
|
||||||
|
comments: list[DeckComment],
|
||||||
*,
|
*,
|
||||||
include_archived_cards: bool,
|
detail: DetailLevel,
|
||||||
description_max_length: int | None,
|
message_max_length: int | None,
|
||||||
) -> list[DeckCard]:
|
order: Literal["newest", "oldest"],
|
||||||
"""Apply filters to a flat list of cards; returns the (possibly new) list."""
|
) -> list[DeckComment | DeckCommentSummary]:
|
||||||
if not include_archived_cards:
|
"""Order, truncate and (optionally) project a page of card comments."""
|
||||||
cards = [c for c in cards if not c.archived]
|
ordered = sorted(
|
||||||
_truncate_card_descriptions(cards, description_max_length)
|
comments, key=lambda c: c.creationDateTime, reverse=(order == "newest")
|
||||||
return cards
|
)
|
||||||
|
if detail == "full":
|
||||||
|
for comment in ordered:
|
||||||
|
comment.message = _truncate_comment_message(
|
||||||
|
comment.message, message_max_length
|
||||||
|
)
|
||||||
|
return list(ordered)
|
||||||
|
return [
|
||||||
|
DeckCommentSummary(
|
||||||
|
id=c.id,
|
||||||
|
actorId=c.actorId,
|
||||||
|
message=_truncate_comment_message(c.message, message_max_length),
|
||||||
|
creationDateTime=c.creationDateTime,
|
||||||
|
)
|
||||||
|
for c in ordered
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# Card attachments — file shares ("Share from Files" picker in the Deck UI).
|
# Card attachments — file shares ("Share from Files" picker in the Deck UI).
|
||||||
@@ -311,31 +456,53 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
ctx: Context,
|
ctx: Context,
|
||||||
board_id: int,
|
board_id: int,
|
||||||
include_cards: bool = True,
|
include_cards: bool = True,
|
||||||
include_archived_cards: bool = False,
|
detail: DetailLevel = "summary",
|
||||||
|
status: CardStatus = "open",
|
||||||
|
label: str | None = None,
|
||||||
|
assigned_to: str | None = None,
|
||||||
description_max_length: int | None = None,
|
description_max_length: int | None = None,
|
||||||
|
description_preview_length: int = _DEFAULT_DESCRIPTION_PREVIEW,
|
||||||
) -> ListStacksResponse:
|
) -> ListStacksResponse:
|
||||||
"""Get all stacks in a Nextcloud Deck board.
|
"""Get all stacks in a Nextcloud Deck board.
|
||||||
|
|
||||||
|
Cards are returned as compact summaries by default to keep the
|
||||||
|
response small on large boards. Filtering/projection happen
|
||||||
|
client-side after the API returns the full board, so they reduce the
|
||||||
|
tokens the caller sees but not network bandwidth.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
board_id: The ID of the board
|
board_id: The ID of the board
|
||||||
include_cards: Include cards inside each stack (default True). Set
|
include_cards: Include cards inside each stack (default True). Set
|
||||||
False for a lightweight stack listing; fetch cards separately
|
False for a lightweight stack listing; fetch cards separately
|
||||||
via deck_get_cards.
|
via deck_get_cards.
|
||||||
include_archived_cards: Include archived cards (default False).
|
detail: "summary" (default) returns compact card rows; "full"
|
||||||
Only relevant when include_cards is True.
|
returns the complete card objects (the old behavior).
|
||||||
description_max_length: If set, truncate each card's description
|
status: Which cards to include — "open" (default), "done",
|
||||||
to this many characters. Useful for keeping responses compact
|
"archived", or "all". The first three partition the board
|
||||||
on boards with long card specs.
|
(a card that is both done and archived counts as "archived").
|
||||||
|
label: If set, only cards carrying a label with this exact title.
|
||||||
|
assigned_to: If set, only cards assigned to this user UID.
|
||||||
|
description_max_length: In detail="full", truncate each card's
|
||||||
|
description to this many characters.
|
||||||
|
description_preview_length: In detail="summary", length of the
|
||||||
|
description preview carried on each card (default 140).
|
||||||
"""
|
"""
|
||||||
_validate_description_max_length(description_max_length)
|
_validate_positive_length(description_max_length)
|
||||||
|
_validate_positive_length(
|
||||||
|
description_preview_length, "description_preview_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)
|
||||||
stacks = [
|
stacks = [
|
||||||
_apply_stack_filters(
|
_apply_stack_filters(
|
||||||
stack,
|
stack,
|
||||||
include_cards=include_cards,
|
include_cards=include_cards,
|
||||||
include_archived_cards=include_archived_cards,
|
detail=detail,
|
||||||
|
status=status,
|
||||||
|
label=label,
|
||||||
|
assigned_to=assigned_to,
|
||||||
description_max_length=description_max_length,
|
description_max_length=description_max_length,
|
||||||
|
description_preview_length=description_preview_length,
|
||||||
)
|
)
|
||||||
for stack in stacks
|
for stack in stacks
|
||||||
]
|
]
|
||||||
@@ -352,28 +519,45 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id: int,
|
board_id: int,
|
||||||
stack_id: int,
|
stack_id: int,
|
||||||
include_cards: bool = True,
|
include_cards: bool = True,
|
||||||
include_archived_cards: bool = False,
|
detail: DetailLevel = "summary",
|
||||||
|
status: CardStatus = "open",
|
||||||
|
label: str | None = None,
|
||||||
|
assigned_to: str | None = None,
|
||||||
description_max_length: int | None = None,
|
description_max_length: int | None = None,
|
||||||
|
description_preview_length: int = _DEFAULT_DESCRIPTION_PREVIEW,
|
||||||
) -> DeckStack:
|
) -> DeckStack:
|
||||||
"""Get details of a specific Nextcloud Deck stack.
|
"""Get details of a specific Nextcloud Deck stack.
|
||||||
|
|
||||||
|
Cards are returned as compact summaries by default; see
|
||||||
|
deck_get_stacks for the shared parameter semantics.
|
||||||
|
|
||||||
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_cards: Include cards in the stack (default True).
|
include_cards: Include cards in the stack (default True).
|
||||||
include_archived_cards: Include archived cards (default False).
|
detail: "summary" (default) or "full".
|
||||||
Only relevant when include_cards is True.
|
status: "open" (default), "done", "archived", or "all"
|
||||||
description_max_length: If set, truncate each card's description
|
(non-overlapping; a done+archived card counts as "archived").
|
||||||
to this many characters.
|
label: If set, only cards carrying a label with this exact title.
|
||||||
|
assigned_to: If set, only cards assigned to this user UID.
|
||||||
|
description_max_length: In detail="full", truncate descriptions.
|
||||||
|
description_preview_length: In detail="summary", preview length.
|
||||||
"""
|
"""
|
||||||
_validate_description_max_length(description_max_length)
|
_validate_positive_length(description_max_length)
|
||||||
|
_validate_positive_length(
|
||||||
|
description_preview_length, "description_preview_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(
|
||||||
stack,
|
stack,
|
||||||
include_cards=include_cards,
|
include_cards=include_cards,
|
||||||
include_archived_cards=include_archived_cards,
|
detail=detail,
|
||||||
|
status=status,
|
||||||
|
label=label,
|
||||||
|
assigned_to=assigned_to,
|
||||||
description_max_length=description_max_length,
|
description_max_length=description_max_length,
|
||||||
|
description_preview_length=description_preview_length,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
@@ -385,7 +569,11 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
async def deck_get_archived_stacks(
|
async def deck_get_archived_stacks(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
board_id: int,
|
board_id: int,
|
||||||
|
detail: DetailLevel = "summary",
|
||||||
|
label: str | None = None,
|
||||||
|
assigned_to: str | None = None,
|
||||||
description_max_length: int | None = None,
|
description_max_length: int | None = None,
|
||||||
|
description_preview_length: int = _DEFAULT_DESCRIPTION_PREVIEW,
|
||||||
) -> ListStacksResponse:
|
) -> ListStacksResponse:
|
||||||
"""List archived stacks (with their archived cards) for a Nextcloud
|
"""List archived stacks (with their archived cards) for a Nextcloud
|
||||||
Deck board.
|
Deck board.
|
||||||
@@ -395,26 +583,38 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
archived via deck_archive_card). The shape mirrors deck_get_stacks.
|
archived via deck_archive_card). The shape mirrors deck_get_stacks.
|
||||||
|
|
||||||
Cards are always included on the returned stacks (an archived stack
|
Cards are always included on the returned stacks (an archived stack
|
||||||
without its cards would have no audit value); pass
|
without its cards would have no audit value) and returned as compact
|
||||||
``description_max_length`` if you need to keep the response compact.
|
summaries by default. There is no ``status`` filter — every card here
|
||||||
|
is archived by definition — but ``label``/``assigned_to`` narrow the
|
||||||
|
set just like the active-stack tools.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
board_id: The ID of the board
|
board_id: The ID of the board
|
||||||
description_max_length: If set, truncate each card's description
|
detail: "summary" (default) or "full".
|
||||||
to this many characters.
|
label: If set, only cards carrying a label with this exact title.
|
||||||
|
assigned_to: If set, only cards assigned to this user UID.
|
||||||
|
description_max_length: In detail="full", truncate descriptions.
|
||||||
|
description_preview_length: In detail="summary", preview length.
|
||||||
"""
|
"""
|
||||||
_validate_description_max_length(description_max_length)
|
_validate_positive_length(description_max_length)
|
||||||
|
_validate_positive_length(
|
||||||
|
description_preview_length, "description_preview_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)
|
||||||
# All cards in archived stacks are themselves archived; route through
|
# All cards in archived stacks are themselves archived; status="all"
|
||||||
# the same helper as the active-stack path so future filter additions
|
# keeps them (an "open"/"done" filter would drop the whole point).
|
||||||
# apply uniformly.
|
# label/assigned_to still apply for targeted audits.
|
||||||
stacks = [
|
stacks = [
|
||||||
_apply_stack_filters(
|
_apply_stack_filters(
|
||||||
stack,
|
stack,
|
||||||
include_cards=True,
|
include_cards=True,
|
||||||
include_archived_cards=True,
|
detail=detail,
|
||||||
|
status="all",
|
||||||
|
label=label,
|
||||||
|
assigned_to=assigned_to,
|
||||||
description_max_length=description_max_length,
|
description_max_length=description_max_length,
|
||||||
|
description_preview_length=description_preview_length,
|
||||||
)
|
)
|
||||||
for stack in stacks
|
for stack in stacks
|
||||||
]
|
]
|
||||||
@@ -430,36 +630,136 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
ctx: Context,
|
ctx: Context,
|
||||||
board_id: int,
|
board_id: int,
|
||||||
stack_id: int,
|
stack_id: int,
|
||||||
include_archived_cards: bool = False,
|
detail: DetailLevel = "summary",
|
||||||
|
status: CardStatus = "open",
|
||||||
|
label: str | None = None,
|
||||||
|
assigned_to: str | None = None,
|
||||||
description_max_length: int | None = None,
|
description_max_length: int | None = None,
|
||||||
|
description_preview_length: int = _DEFAULT_DESCRIPTION_PREVIEW,
|
||||||
) -> ListCardsResponse:
|
) -> ListCardsResponse:
|
||||||
"""Get all cards in a Nextcloud Deck stack.
|
"""Get all cards in a Nextcloud Deck stack.
|
||||||
|
|
||||||
Filtering is applied client-side after the API returns the full
|
Cards are returned as compact summaries by default. Filtering and
|
||||||
stack, so ``include_archived_cards=False`` and
|
projection are applied client-side after the API returns the full
|
||||||
``description_max_length`` reduce response size visible to the
|
stack, so they reduce the tokens the caller sees but not network
|
||||||
caller but not network bandwidth — network-wise this tool is
|
bandwidth — network-wise this tool is equivalent to
|
||||||
equivalent to deck_get_stack(include_cards=True).
|
deck_get_stack(include_cards=True).
|
||||||
|
|
||||||
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_cards: Include archived cards (default False).
|
detail: "summary" (default) returns compact card rows; "full"
|
||||||
Archived cards can also be retrieved per-board via
|
returns the complete card objects.
|
||||||
deck_get_archived_stacks.
|
status: "open" (default), "done", "archived", or "all". The first
|
||||||
description_max_length: If set, truncate each card's description
|
three partition the board (a done+archived card counts as
|
||||||
to this many characters.
|
"archived").
|
||||||
|
label: If set, only cards carrying a label with this exact title.
|
||||||
|
assigned_to: If set, only cards assigned to this user UID.
|
||||||
|
description_max_length: In detail="full", truncate descriptions.
|
||||||
|
description_preview_length: In detail="summary", preview length.
|
||||||
"""
|
"""
|
||||||
_validate_description_max_length(description_max_length)
|
_validate_positive_length(description_max_length)
|
||||||
|
_validate_positive_length(
|
||||||
|
description_preview_length, "description_preview_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 = _shape_cards(
|
||||||
stack.cards or [],
|
cast(list[DeckCard], stack.cards or []),
|
||||||
include_archived_cards=include_archived_cards,
|
detail=detail,
|
||||||
|
status=status,
|
||||||
|
label=label,
|
||||||
|
assigned_to=assigned_to,
|
||||||
description_max_length=description_max_length,
|
description_max_length=description_max_length,
|
||||||
|
description_preview_length=description_preview_length,
|
||||||
)
|
)
|
||||||
return ListCardsResponse(cards=cards, total=len(cards))
|
return ListCardsResponse(cards=cards, total=len(cards))
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
title="Get Deck Board Overview",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
|
@require_scopes("deck.read")
|
||||||
|
@instrument_tool
|
||||||
|
async def deck_get_board_overview(
|
||||||
|
ctx: Context,
|
||||||
|
board_id: int,
|
||||||
|
status: CardStatus = "open",
|
||||||
|
label: str | None = None,
|
||||||
|
assigned_to: str | None = None,
|
||||||
|
description_preview_length: int = _DEFAULT_DESCRIPTION_PREVIEW,
|
||||||
|
) -> BoardOverviewResponse:
|
||||||
|
"""Get a compact, whole-board snapshot in a single call.
|
||||||
|
|
||||||
|
Returns the board title, its label legend, and every stack with its
|
||||||
|
cards projected to compact summary rows. Prefer it for "show me the
|
||||||
|
board" / "what's in progress" style requests on large boards — it is
|
||||||
|
the token-efficient way to view board *state*. It intentionally omits
|
||||||
|
the board-management fields (ACL, user list, full label objects) that
|
||||||
|
deck_get_board exposes; reach for deck_get_board when you need those.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
board_id: The ID of the board
|
||||||
|
status: Which cards to include — "open" (default), "done",
|
||||||
|
"archived", or "all". The first three partition the board
|
||||||
|
(a card that is both done and archived counts as "archived").
|
||||||
|
label: If set, only cards carrying a label with this exact title.
|
||||||
|
assigned_to: If set, only cards assigned to this user UID.
|
||||||
|
description_preview_length: Length of the description preview
|
||||||
|
carried on each card summary (default 140).
|
||||||
|
"""
|
||||||
|
_validate_positive_length(
|
||||||
|
description_preview_length, "description_preview_length"
|
||||||
|
)
|
||||||
|
client = await get_client(ctx)
|
||||||
|
|
||||||
|
board_holder: list[DeckBoard] = []
|
||||||
|
stacks_holder: list[list[DeckStack]] = []
|
||||||
|
|
||||||
|
async def _get_board() -> None:
|
||||||
|
board_holder.append(await client.deck.get_board(board_id))
|
||||||
|
|
||||||
|
async def _get_stacks() -> None:
|
||||||
|
stacks_holder.append(await client.deck.get_stacks(board_id))
|
||||||
|
|
||||||
|
async with anyio.create_task_group() as tg:
|
||||||
|
tg.start_soon(_get_board)
|
||||||
|
tg.start_soon(_get_stacks)
|
||||||
|
|
||||||
|
board = board_holder[0]
|
||||||
|
stacks = stacks_holder[0]
|
||||||
|
|
||||||
|
stack_overviews: list[StackOverview] = []
|
||||||
|
total_cards = 0
|
||||||
|
for stack in stacks:
|
||||||
|
summaries = [
|
||||||
|
_summarize_card(c, description_preview_length)
|
||||||
|
for c in _filter_cards(
|
||||||
|
cast(list[DeckCard], stack.cards or []),
|
||||||
|
status=status,
|
||||||
|
label=label,
|
||||||
|
assigned_to=assigned_to,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
total_cards += len(summaries)
|
||||||
|
stack_overviews.append(
|
||||||
|
StackOverview(
|
||||||
|
id=stack.id,
|
||||||
|
title=stack.title,
|
||||||
|
order=stack.order,
|
||||||
|
card_count=len(summaries),
|
||||||
|
cards=summaries,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return BoardOverviewResponse(
|
||||||
|
board_id=board.id,
|
||||||
|
title=board.title,
|
||||||
|
labels=[lbl.title for lbl in (board.labels or [])],
|
||||||
|
stacks=stack_overviews,
|
||||||
|
total_cards=total_cards,
|
||||||
|
)
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Get Deck Card",
|
title="Get Deck Card",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
@@ -1016,18 +1316,40 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
@require_scopes("deck.read")
|
@require_scopes("deck.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_card_comments(
|
async def deck_get_card_comments(
|
||||||
ctx: Context, card_id: int, limit: int = 20, offset: int = 0
|
ctx: Context,
|
||||||
|
card_id: int,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
detail: DetailLevel = "summary",
|
||||||
|
message_max_length: int | None = None,
|
||||||
|
order: Literal["newest", "oldest"] = "newest",
|
||||||
) -> ListCardCommentsResponse:
|
) -> ListCardCommentsResponse:
|
||||||
"""List comments on a Nextcloud Deck card
|
"""List comments on a Nextcloud Deck card.
|
||||||
|
|
||||||
|
Returns compact comments by default (dropping mentions, actor type and
|
||||||
|
display name). Ordering and truncation apply within the returned page.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
card_id: The ID of the card
|
card_id: The ID of the card
|
||||||
limit: Maximum number of comments to return (default 20, max 200)
|
limit: Maximum number of comments to return (default 20, max 200)
|
||||||
offset: Pagination offset (default 0)
|
offset: Pagination offset (default 0)
|
||||||
|
detail: "summary" (default) returns compact comments; "full"
|
||||||
|
returns the complete comment objects.
|
||||||
|
message_max_length: If set, truncate each comment message to this
|
||||||
|
many characters.
|
||||||
|
order: "newest" (default) or "oldest" — sort the page by creation
|
||||||
|
time.
|
||||||
"""
|
"""
|
||||||
|
_validate_positive_length(message_max_length, "message_max_length")
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
comments = await client.deck.get_comments(card_id, limit=limit, offset=offset)
|
comments = await client.deck.get_comments(card_id, limit=limit, offset=offset)
|
||||||
return ListCardCommentsResponse(results=comments, count=len(comments))
|
shaped = _shape_comments(
|
||||||
|
comments,
|
||||||
|
detail=detail,
|
||||||
|
message_max_length=message_max_length,
|
||||||
|
order=order,
|
||||||
|
)
|
||||||
|
return ListCardCommentsResponse(results=shaped, count=len(shaped))
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Create Deck Card Comment",
|
title="Create Deck Card Comment",
|
||||||
|
|||||||
@@ -353,3 +353,71 @@ async def test_deck_card_comment_message_too_long_mcp(
|
|||||||
{"card_id": card_id, "message": too_long},
|
{"card_id": card_id, "message": too_long},
|
||||||
)
|
)
|
||||||
assert result.isError is True, "Expected validation error for >1000 char message"
|
assert result.isError is True, "Expected validation error for >1000 char message"
|
||||||
|
|
||||||
|
|
||||||
|
# Compact retrieval (summary projection + board overview)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_deck_get_stacks_summary_default_omits_full_card_fields_mcp(
|
||||||
|
nc_mcp_client: ClientSession, temporary_board_with_card: tuple
|
||||||
|
):
|
||||||
|
"""deck_get_stacks defaults to compact summaries: the card row carries
|
||||||
|
title/labels but not the heavy full-card fields (owner/type/etag)."""
|
||||||
|
board_data, stack_data, card_data = temporary_board_with_card
|
||||||
|
board_id = board_data["id"]
|
||||||
|
|
||||||
|
result = await nc_mcp_client.call_tool("deck_get_stacks", {"board_id": board_id})
|
||||||
|
assert result.isError is False, f"deck_get_stacks failed: {result.content}"
|
||||||
|
payload = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
cards = [c for stack in payload["stacks"] for c in (stack.get("cards") or [])]
|
||||||
|
card = next(c for c in cards if c["id"] == card_data["id"])
|
||||||
|
# Summary fields present...
|
||||||
|
assert card["title"] == card_data["title"]
|
||||||
|
assert "hasDescription" in card
|
||||||
|
assert "labels" in card and isinstance(card["labels"], list)
|
||||||
|
# ...heavy full-card fields absent.
|
||||||
|
assert "owner" not in card
|
||||||
|
assert "type" not in card
|
||||||
|
|
||||||
|
|
||||||
|
async def test_deck_get_stacks_detail_full_keeps_card_fields_mcp(
|
||||||
|
nc_mcp_client: ClientSession, temporary_board_with_card: tuple
|
||||||
|
):
|
||||||
|
"""detail="full" restores the complete card objects (owner/type present)."""
|
||||||
|
board_data, _, card_data = temporary_board_with_card
|
||||||
|
board_id = board_data["id"]
|
||||||
|
|
||||||
|
result = await nc_mcp_client.call_tool(
|
||||||
|
"deck_get_stacks", {"board_id": board_id, "detail": "full"}
|
||||||
|
)
|
||||||
|
assert result.isError is False, f"deck_get_stacks(full) failed: {result.content}"
|
||||||
|
payload = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
cards = [c for stack in payload["stacks"] for c in (stack.get("cards") or [])]
|
||||||
|
card = next(c for c in cards if c["id"] == card_data["id"])
|
||||||
|
assert "owner" in card
|
||||||
|
assert "type" in card
|
||||||
|
|
||||||
|
|
||||||
|
async def test_deck_get_board_overview_mcp(
|
||||||
|
nc_mcp_client: ClientSession, temporary_board_with_card: tuple
|
||||||
|
):
|
||||||
|
"""deck_get_board_overview returns board title + stacks with compact rows."""
|
||||||
|
board_data, stack_data, card_data = temporary_board_with_card
|
||||||
|
board_id = board_data["id"]
|
||||||
|
|
||||||
|
result = await nc_mcp_client.call_tool(
|
||||||
|
"deck_get_board_overview", {"board_id": board_id}
|
||||||
|
)
|
||||||
|
assert result.isError is False, f"board overview failed: {result.content}"
|
||||||
|
payload = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
assert payload["board_id"] == board_id
|
||||||
|
assert payload["title"] == board_data["title"]
|
||||||
|
assert payload["total_cards"] >= 1
|
||||||
|
|
||||||
|
stack = next(s for s in payload["stacks"] if s["id"] == stack_data["id"])
|
||||||
|
assert stack["card_count"] == len(stack["cards"])
|
||||||
|
card_ids = [c["id"] for c in stack["cards"]]
|
||||||
|
assert card_data["id"] in card_ids
|
||||||
|
|||||||
+286
-87
@@ -1,9 +1,15 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nextcloud_mcp_server.models.deck import (
|
from nextcloud_mcp_server.models.deck import (
|
||||||
DeckACL,
|
DeckACL,
|
||||||
|
DeckAssignedUser,
|
||||||
DeckBoard,
|
DeckBoard,
|
||||||
DeckCard,
|
DeckCard,
|
||||||
|
DeckCardSummary,
|
||||||
|
DeckComment,
|
||||||
|
DeckCommentSummary,
|
||||||
DeckLabel,
|
DeckLabel,
|
||||||
DeckPermissions,
|
DeckPermissions,
|
||||||
DeckStack,
|
DeckStack,
|
||||||
@@ -12,12 +18,17 @@ from nextcloud_mcp_server.models.deck import (
|
|||||||
from nextcloud_mcp_server.server.deck import (
|
from nextcloud_mcp_server.server.deck import (
|
||||||
_SHARE_TYPE_DECK,
|
_SHARE_TYPE_DECK,
|
||||||
_apply_board_filters,
|
_apply_board_filters,
|
||||||
_apply_card_filters,
|
|
||||||
_apply_stack_filters,
|
_apply_stack_filters,
|
||||||
|
_extract_uid,
|
||||||
|
_filter_cards,
|
||||||
_resolve_note_attach_path,
|
_resolve_note_attach_path,
|
||||||
_resolve_note_path,
|
_resolve_note_path,
|
||||||
|
_shape_cards,
|
||||||
|
_shape_comments,
|
||||||
|
_summarize_card,
|
||||||
_truncate_card_descriptions,
|
_truncate_card_descriptions,
|
||||||
_validate_description_max_length,
|
_truncate_comment_message,
|
||||||
|
_validate_positive_length,
|
||||||
)
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
@@ -30,6 +41,12 @@ def _make_card(
|
|||||||
card_id: int,
|
card_id: int,
|
||||||
description: str | None = "desc",
|
description: str | None = "desc",
|
||||||
archived: bool = False,
|
archived: bool = False,
|
||||||
|
*,
|
||||||
|
done: datetime | None = None,
|
||||||
|
labels: list[DeckLabel] | None = None,
|
||||||
|
assigned_users: list | None = None,
|
||||||
|
attachment_count: int | None = None,
|
||||||
|
comments_unread: int | None = None,
|
||||||
) -> DeckCard:
|
) -> DeckCard:
|
||||||
return DeckCard(
|
return DeckCard(
|
||||||
id=card_id,
|
id=card_id,
|
||||||
@@ -40,6 +57,30 @@ def _make_card(
|
|||||||
archived=archived,
|
archived=archived,
|
||||||
owner="testuser",
|
owner="testuser",
|
||||||
description=description,
|
description=description,
|
||||||
|
done=done,
|
||||||
|
labels=labels,
|
||||||
|
assignedUsers=assigned_users,
|
||||||
|
attachmentCount=attachment_count,
|
||||||
|
commentsUnread=comments_unread,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_comment(
|
||||||
|
comment_id: int,
|
||||||
|
message: str = "hello",
|
||||||
|
*,
|
||||||
|
actor: str = "alice",
|
||||||
|
created: datetime | None = None,
|
||||||
|
) -> DeckComment:
|
||||||
|
return DeckComment(
|
||||||
|
id=comment_id,
|
||||||
|
objectId=1,
|
||||||
|
message=message,
|
||||||
|
actorId=actor,
|
||||||
|
actorType="users",
|
||||||
|
actorDisplayName=actor.title(),
|
||||||
|
creationDateTime=created or datetime(2024, 1, comment_id, tzinfo=timezone.utc),
|
||||||
|
mentions=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -141,30 +182,30 @@ def test_truncate_card_descriptions_shorter_than_limit_no_ellipsis():
|
|||||||
assert cards[0].description == "hello"
|
assert cards[0].description == "hello"
|
||||||
|
|
||||||
|
|
||||||
# _validate_description_max_length ----------------------------------------
|
# _validate_positive_length ----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_validate_description_max_length_accepts_none():
|
def test_validate_positive_length_accepts_none():
|
||||||
"""None is the documented sentinel for "no truncation"."""
|
"""None is the documented sentinel for "no truncation"."""
|
||||||
_validate_description_max_length(None)
|
_validate_positive_length(None)
|
||||||
|
|
||||||
|
|
||||||
def test_validate_description_max_length_accepts_positive():
|
def test_validate_positive_length_accepts_positive():
|
||||||
"""Positive values pass through silently."""
|
"""Positive values pass through silently."""
|
||||||
_validate_description_max_length(1)
|
_validate_positive_length(1)
|
||||||
_validate_description_max_length(1000)
|
_validate_positive_length(1000)
|
||||||
|
|
||||||
|
|
||||||
def test_validate_description_max_length_rejects_zero():
|
def test_validate_positive_length_rejects_zero():
|
||||||
"""Zero would wipe descriptions to a single ellipsis — reject at the boundary."""
|
"""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"):
|
||||||
_validate_description_max_length(0)
|
_validate_positive_length(0)
|
||||||
|
|
||||||
|
|
||||||
def test_validate_description_max_length_rejects_negative():
|
def test_validate_positive_length_rejects_negative():
|
||||||
"""Negative values produce surprising slice semantics — reject at the boundary."""
|
"""Negative values produce surprising slice semantics — reject at the boundary."""
|
||||||
with pytest.raises(ValueError, match="must be positive"):
|
with pytest.raises(ValueError, match="must be positive"):
|
||||||
_validate_description_max_length(-10)
|
_validate_positive_length(-10)
|
||||||
|
|
||||||
|
|
||||||
# _apply_board_filters ------------------------------------------------------
|
# _apply_board_filters ------------------------------------------------------
|
||||||
@@ -231,67 +272,231 @@ def test_apply_board_filters_excludes_all():
|
|||||||
assert result.labels == []
|
assert result.labels == []
|
||||||
|
|
||||||
|
|
||||||
|
# Shared default kwargs for _apply_stack_filters in summary mode ------------
|
||||||
|
|
||||||
|
_STACK_DEFAULTS = dict(
|
||||||
|
detail="summary",
|
||||||
|
status="open",
|
||||||
|
label=None,
|
||||||
|
assigned_to=None,
|
||||||
|
description_max_length=None,
|
||||||
|
description_preview_length=140,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# _filter_cards -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_cards_open_excludes_archived_and_done():
|
||||||
|
"""status="open" drops both archived and explicitly-done cards."""
|
||||||
|
done_at = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||||
|
cards = [
|
||||||
|
_make_card(1),
|
||||||
|
_make_card(2, archived=True),
|
||||||
|
_make_card(3, done=done_at),
|
||||||
|
]
|
||||||
|
result = _filter_cards(cards, status="open", label=None, assigned_to=None)
|
||||||
|
assert [c.id for c in result] == [1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_cards_done_keeps_only_done_and_not_archived():
|
||||||
|
"""status="done" keeps done cards that are not archived."""
|
||||||
|
done_at = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||||
|
cards = [_make_card(1), _make_card(2, done=done_at)]
|
||||||
|
result = _filter_cards(cards, status="done", label=None, assigned_to=None)
|
||||||
|
assert [c.id for c in result] == [2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_cards_archived_keeps_only_archived():
|
||||||
|
"""status="archived" keeps only archived cards."""
|
||||||
|
cards = [_make_card(1), _make_card(2, archived=True)]
|
||||||
|
result = _filter_cards(cards, status="archived", label=None, assigned_to=None)
|
||||||
|
assert [c.id for c in result] == [2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_cards_statuses_partition_the_board():
|
||||||
|
"""open/done/archived are non-overlapping; a done+archived card counts
|
||||||
|
only as "archived", not "done"."""
|
||||||
|
done_at = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||||
|
open_card = _make_card(1)
|
||||||
|
done_card = _make_card(2, done=done_at)
|
||||||
|
done_and_archived = _make_card(3, done=done_at, archived=True)
|
||||||
|
cards = [open_card, done_card, done_and_archived]
|
||||||
|
|
||||||
|
assert [
|
||||||
|
c.id for c in _filter_cards(cards, status="open", label=None, assigned_to=None)
|
||||||
|
] == [1]
|
||||||
|
assert [
|
||||||
|
c.id for c in _filter_cards(cards, status="done", label=None, assigned_to=None)
|
||||||
|
] == [2]
|
||||||
|
assert [
|
||||||
|
c.id
|
||||||
|
for c in _filter_cards(cards, status="archived", label=None, assigned_to=None)
|
||||||
|
] == [3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_cards_all_keeps_everything():
|
||||||
|
"""status="all" applies no status filter."""
|
||||||
|
cards = [_make_card(1), _make_card(2, archived=True)]
|
||||||
|
result = _filter_cards(cards, status="all", label=None, assigned_to=None)
|
||||||
|
assert [c.id for c in result] == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_cards_by_label_matches_title_exactly():
|
||||||
|
"""label filtering matches the exact label title."""
|
||||||
|
a = _make_card(1, labels=[DeckLabel(id=1, title="bug", color="f00")])
|
||||||
|
b = _make_card(2, labels=[DeckLabel(id=2, title="feature", color="0f0")])
|
||||||
|
result = _filter_cards([a, b], status="all", label="bug", assigned_to=None)
|
||||||
|
assert [c.id for c in result] == [1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_cards_by_assignee_handles_both_user_shapes():
|
||||||
|
"""assigned_to matches DeckUser and DeckAssignedUser shapes alike."""
|
||||||
|
direct = _make_card(1, assigned_users=[_make_user("alice")])
|
||||||
|
wrapped = _make_card(
|
||||||
|
2,
|
||||||
|
assigned_users=[
|
||||||
|
DeckAssignedUser(id=9, participant=_make_user("bob"), cardId=2, type=0)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = _filter_cards(
|
||||||
|
[direct, wrapped], status="all", label=None, assigned_to="bob"
|
||||||
|
)
|
||||||
|
assert [c.id for c in result] == [2]
|
||||||
|
|
||||||
|
|
||||||
|
# _extract_uid --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_uid_from_deck_user():
|
||||||
|
assert _extract_uid(_make_user("alice")) == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_uid_from_assigned_user():
|
||||||
|
assigned = DeckAssignedUser(id=1, participant=_make_user("bob"), cardId=1, type=0)
|
||||||
|
assert _extract_uid(assigned) == "bob"
|
||||||
|
|
||||||
|
|
||||||
|
# _summarize_card -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_card_projects_compact_fields():
|
||||||
|
"""Summary carries flat label titles, assignee uids, counts, and a preview."""
|
||||||
|
card = _make_card(
|
||||||
|
1,
|
||||||
|
description="x" * 50,
|
||||||
|
labels=[DeckLabel(id=1, title="bug", color="f00")],
|
||||||
|
assigned_users=[_make_user("alice")],
|
||||||
|
attachment_count=3,
|
||||||
|
comments_unread=2,
|
||||||
|
)
|
||||||
|
summary = _summarize_card(card, description_preview_length=10)
|
||||||
|
assert isinstance(summary, DeckCardSummary)
|
||||||
|
assert summary.labels == ["bug"]
|
||||||
|
assert summary.assignedUsers == ["alice"]
|
||||||
|
assert summary.attachmentCount == 3
|
||||||
|
assert summary.commentsUnread == 2
|
||||||
|
assert summary.hasDescription is True
|
||||||
|
assert summary.descriptionPreview is not None
|
||||||
|
assert summary.descriptionPreview.endswith("…")
|
||||||
|
assert len(summary.descriptionPreview) == 11 # 10 chars + ellipsis
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_card_short_description_has_no_ellipsis():
|
||||||
|
"""A description within the preview length is carried verbatim."""
|
||||||
|
summary = _summarize_card(_make_card(1, description="hi"), 140)
|
||||||
|
assert summary.descriptionPreview == "hi"
|
||||||
|
assert summary.hasDescription is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_card_empty_description():
|
||||||
|
"""An empty/whitespace description yields hasDescription=False, no preview."""
|
||||||
|
summary = _summarize_card(_make_card(1, description=" "), 140)
|
||||||
|
assert summary.hasDescription is False
|
||||||
|
assert summary.descriptionPreview is None
|
||||||
|
|
||||||
|
|
||||||
|
# _shape_cards --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_shape_cards_summary_returns_summaries():
|
||||||
|
"""detail="summary" projects every (filtered) card to a DeckCardSummary."""
|
||||||
|
cards = [_make_card(1), _make_card(2, archived=True)]
|
||||||
|
result = _shape_cards(
|
||||||
|
cards,
|
||||||
|
detail="summary",
|
||||||
|
status="open",
|
||||||
|
label=None,
|
||||||
|
assigned_to=None,
|
||||||
|
description_max_length=None,
|
||||||
|
description_preview_length=140,
|
||||||
|
)
|
||||||
|
assert [type(c) for c in result] == [DeckCardSummary]
|
||||||
|
assert result[0].id == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_shape_cards_full_returns_truncated_full_cards():
|
||||||
|
"""detail="full" returns DeckCard objects with descriptions truncated."""
|
||||||
|
cards = [_make_card(1, description="x" * 50)]
|
||||||
|
result = _shape_cards(
|
||||||
|
cards,
|
||||||
|
detail="full",
|
||||||
|
status="all",
|
||||||
|
label=None,
|
||||||
|
assigned_to=None,
|
||||||
|
description_max_length=10,
|
||||||
|
description_preview_length=140,
|
||||||
|
)
|
||||||
|
assert isinstance(result[0], DeckCard)
|
||||||
|
assert result[0].description is not None
|
||||||
|
assert result[0].description.endswith("…")
|
||||||
|
|
||||||
|
|
||||||
# _apply_stack_filters ------------------------------------------------------
|
# _apply_stack_filters ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_apply_stack_filters_include_cards_false_strips_cards():
|
def test_apply_stack_filters_include_cards_false_strips_cards():
|
||||||
"""include_cards=False sets cards to None regardless of other flags."""
|
"""include_cards=False sets cards to None regardless of other flags."""
|
||||||
stack = _make_stack(cards=[_make_card(1), _make_card(2, archived=True)])
|
stack = _make_stack(cards=[_make_card(1), _make_card(2, archived=True)])
|
||||||
result = _apply_stack_filters(
|
result = _apply_stack_filters(stack, include_cards=False, **_STACK_DEFAULTS)
|
||||||
stack,
|
|
||||||
include_cards=False,
|
|
||||||
include_archived_cards=True,
|
|
||||||
description_max_length=None,
|
|
||||||
)
|
|
||||||
assert result.cards is None
|
assert result.cards is None
|
||||||
|
|
||||||
|
|
||||||
def test_apply_stack_filters_excludes_archived_by_default():
|
def test_apply_stack_filters_summary_excludes_archived_by_default():
|
||||||
"""include_archived_cards=False filters out archived cards."""
|
"""Default status="open" filters out archived cards and returns summaries."""
|
||||||
stack = _make_stack(
|
stack = _make_stack(
|
||||||
cards=[_make_card(1, archived=False), _make_card(2, archived=True)]
|
cards=[_make_card(1, archived=False), _make_card(2, archived=True)]
|
||||||
)
|
)
|
||||||
result = _apply_stack_filters(
|
result = _apply_stack_filters(stack, include_cards=True, **_STACK_DEFAULTS)
|
||||||
stack,
|
|
||||||
include_cards=True,
|
|
||||||
include_archived_cards=False,
|
|
||||||
description_max_length=None,
|
|
||||||
)
|
|
||||||
assert result.cards is not None
|
assert result.cards is not None
|
||||||
assert [c.id for c in result.cards] == [1]
|
assert [c.id for c in result.cards] == [1]
|
||||||
|
assert all(isinstance(c, DeckCardSummary) for c in result.cards)
|
||||||
|
|
||||||
|
|
||||||
def test_apply_stack_filters_keeps_archived_when_requested():
|
def test_apply_stack_filters_status_all_keeps_archived():
|
||||||
"""include_archived_cards=True retains archived cards."""
|
"""status="all" retains archived cards."""
|
||||||
stack = _make_stack(
|
stack = _make_stack(
|
||||||
cards=[_make_card(1, archived=False), _make_card(2, archived=True)]
|
cards=[_make_card(1, archived=False), _make_card(2, archived=True)]
|
||||||
)
|
)
|
||||||
result = _apply_stack_filters(
|
kwargs = {**_STACK_DEFAULTS, "status": "all"}
|
||||||
stack,
|
result = _apply_stack_filters(stack, include_cards=True, **kwargs)
|
||||||
include_cards=True,
|
|
||||||
include_archived_cards=True,
|
|
||||||
description_max_length=None,
|
|
||||||
)
|
|
||||||
assert result.cards is not None
|
assert result.cards is not None
|
||||||
assert [c.id for c in result.cards] == [1, 2]
|
assert [c.id for c in result.cards] == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
def test_apply_stack_filters_truncates_descriptions_after_archive_filter():
|
def test_apply_stack_filters_full_truncates_descriptions_after_filter():
|
||||||
"""Truncation runs on the post-archive-filter card set."""
|
"""detail="full" truncation runs on the post-status-filter card set."""
|
||||||
stack = _make_stack(
|
stack = _make_stack(
|
||||||
cards=[
|
cards=[
|
||||||
_make_card(1, description="x" * 50, archived=False),
|
_make_card(1, description="x" * 50, archived=False),
|
||||||
_make_card(2, description="y" * 50, archived=True),
|
_make_card(2, description="y" * 50, archived=True),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
result = _apply_stack_filters(
|
kwargs = {**_STACK_DEFAULTS, "detail": "full", "description_max_length": 10}
|
||||||
stack,
|
result = _apply_stack_filters(stack, include_cards=True, **kwargs)
|
||||||
include_cards=True,
|
|
||||||
include_archived_cards=False,
|
|
||||||
description_max_length=10,
|
|
||||||
)
|
|
||||||
assert result.cards is not None
|
assert result.cards is not None
|
||||||
assert len(result.cards) == 1
|
assert len(result.cards) == 1
|
||||||
|
assert isinstance(result.cards[0], DeckCard)
|
||||||
assert result.cards[0].description is not None
|
assert result.cards[0].description is not None
|
||||||
assert result.cards[0].description.endswith("…")
|
assert result.cards[0].description.endswith("…")
|
||||||
|
|
||||||
@@ -299,78 +504,72 @@ def test_apply_stack_filters_truncates_descriptions_after_archive_filter():
|
|||||||
def test_apply_stack_filters_handles_none_cards():
|
def test_apply_stack_filters_handles_none_cards():
|
||||||
"""A stack with no cards (cards=None) is left untouched."""
|
"""A stack with no cards (cards=None) is left untouched."""
|
||||||
stack = _make_stack(cards=None)
|
stack = _make_stack(cards=None)
|
||||||
result = _apply_stack_filters(
|
result = _apply_stack_filters(stack, include_cards=True, **_STACK_DEFAULTS)
|
||||||
stack,
|
|
||||||
include_cards=True,
|
|
||||||
include_archived_cards=False,
|
|
||||||
description_max_length=10,
|
|
||||||
)
|
|
||||||
assert result.cards is None
|
assert result.cards is None
|
||||||
|
|
||||||
|
|
||||||
def test_apply_stack_filters_all_archived_yields_empty_list_not_none():
|
def test_apply_stack_filters_all_filtered_yields_empty_list_not_none():
|
||||||
"""A stack whose cards are all archived yields cards == [], not None.
|
"""A stack whose cards are all filtered out yields cards == [], not None.
|
||||||
|
|
||||||
Pin the contract: include_cards=True with all cards filtered out
|
Pin the contract: include_cards=True with all cards filtered out
|
||||||
means "the stack was loaded but had nothing to show", which is
|
means "the stack was loaded but had nothing to show", which is
|
||||||
semantically distinct from include_cards=False (cards=None,
|
semantically distinct from include_cards=False (cards=None,
|
||||||
"explicitly suppressed"). Callers checking ``stack.cards is None``
|
"explicitly suppressed").
|
||||||
can use that to distinguish the two states.
|
|
||||||
"""
|
"""
|
||||||
stack = _make_stack(
|
stack = _make_stack(
|
||||||
cards=[_make_card(1, archived=True), _make_card(2, archived=True)]
|
cards=[_make_card(1, archived=True), _make_card(2, archived=True)]
|
||||||
)
|
)
|
||||||
result = _apply_stack_filters(
|
result = _apply_stack_filters(stack, include_cards=True, **_STACK_DEFAULTS)
|
||||||
stack,
|
|
||||||
include_cards=True,
|
|
||||||
include_archived_cards=False,
|
|
||||||
description_max_length=None,
|
|
||||||
)
|
|
||||||
assert result.cards == []
|
assert result.cards == []
|
||||||
assert result.cards is not None
|
assert result.cards is not None
|
||||||
|
|
||||||
|
|
||||||
# _apply_card_filters -------------------------------------------------------
|
# _shape_comments -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_apply_card_filters_excludes_archived_by_default():
|
def test_shape_comments_summary_drops_actor_metadata():
|
||||||
"""include_archived_cards=False filters archived cards out of the flat list."""
|
"""detail="summary" projects comments to compact DeckCommentSummary rows."""
|
||||||
cards = [
|
comments = [_make_comment(1, "hi", actor="alice")]
|
||||||
_make_card(1, archived=False),
|
result = _shape_comments(
|
||||||
_make_card(2, archived=True),
|
comments, detail="summary", message_max_length=None, order="newest"
|
||||||
_make_card(3, archived=False),
|
|
||||||
]
|
|
||||||
result = _apply_card_filters(
|
|
||||||
cards, include_archived_cards=False, description_max_length=None
|
|
||||||
)
|
)
|
||||||
assert [c.id for c in result] == [1, 3]
|
assert isinstance(result[0], DeckCommentSummary)
|
||||||
|
assert result[0].actorId == "alice"
|
||||||
|
assert result[0].message == "hi"
|
||||||
|
|
||||||
|
|
||||||
def test_apply_card_filters_keeps_archived_when_requested():
|
def test_shape_comments_newest_first():
|
||||||
"""include_archived_cards=True retains archived cards."""
|
"""order="newest" sorts the page by creation time descending."""
|
||||||
cards = [_make_card(1, archived=False), _make_card(2, archived=True)]
|
comments = [_make_comment(1), _make_comment(3), _make_comment(2)]
|
||||||
result = _apply_card_filters(
|
result = _shape_comments(
|
||||||
cards, include_archived_cards=True, description_max_length=None
|
comments, detail="summary", message_max_length=None, order="newest"
|
||||||
)
|
)
|
||||||
assert [c.id for c in result] == [1, 2]
|
assert [c.id for c in result] == [3, 2, 1]
|
||||||
|
|
||||||
|
|
||||||
def test_apply_card_filters_truncates_descriptions():
|
def test_shape_comments_oldest_first():
|
||||||
"""description_max_length is honored on the returned cards."""
|
"""order="oldest" sorts the page by creation time ascending."""
|
||||||
cards = [_make_card(1, description="x" * 50)]
|
comments = [_make_comment(3), _make_comment(1), _make_comment(2)]
|
||||||
result = _apply_card_filters(
|
result = _shape_comments(
|
||||||
cards, include_archived_cards=True, description_max_length=10
|
comments, detail="summary", message_max_length=None, order="oldest"
|
||||||
)
|
)
|
||||||
assert result[0].description is not None
|
assert [c.id for c in result] == [1, 2, 3]
|
||||||
assert result[0].description.endswith("…")
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_card_filters_empty_list_is_noop():
|
def test_shape_comments_full_truncates_message():
|
||||||
"""An empty input returns an empty output."""
|
"""detail="full" keeps DeckComment but truncates long messages when asked."""
|
||||||
result = _apply_card_filters(
|
comments = [_make_comment(1, "x" * 50)]
|
||||||
[], include_archived_cards=False, description_max_length=10
|
result = _shape_comments(
|
||||||
|
comments, detail="full", message_max_length=10, order="newest"
|
||||||
)
|
)
|
||||||
assert result == []
|
assert isinstance(result[0], DeckComment)
|
||||||
|
assert result[0].message.endswith("…")
|
||||||
|
assert len(result[0].message) == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncate_comment_message_no_op_when_within_limit():
|
||||||
|
assert _truncate_comment_message("short", 100) == "short"
|
||||||
|
assert _truncate_comment_message("short", None) == "short"
|
||||||
|
|
||||||
|
|
||||||
# _resolve_note_path -------------------------------------------------------
|
# _resolve_note_path -------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user