fix(deck): include archived cards in list tools for status=all/archived

The active Deck listing endpoints (StackService::findAll /
CardMapper::findAllForStacks and StackService::find / CardMapper::findAll)
filter out archived cards at the SQL level — only the /stacks/archived
endpoint returns them. The client-side status="all"/"archived" filters in
deck_get_cards, deck_get_stacks, deck_get_stack and deck_get_board_overview
therefore operated on a list the server had already stripped of archived
cards, so they could never surface one. deck_get_card (by ID) bypasses the
filter, which is why it appeared to work. Fixes #842.

When status is "all" or "archived", also fetch /stacks/archived
(client.deck.get_archived_stacks) and merge those cards back in per stack —
concurrently with the active fetch where applicable. status="open"/"done"
are unchanged and cost no extra call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-07 18:03:28 +02:00
co-authored by Claude Opus 4.8
parent 792c536802
commit 961449be30
4 changed files with 273 additions and 9 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ services:
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts # Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
# The post-installation hook will register /opt/apps as an additional app directory # The post-installation hook will register /opt/apps as an additional app directory
#- ./third_party:/opt/apps:ro #- ./third_party:/opt/apps:ro
- ./third_party/astrolabe:/opt/apps/astrolabe:ro #- ./third_party/astrolabe:/opt/apps/astrolabe:ro
#- ./third_party/oidc:/opt/apps/oidc:ro #- ./third_party/oidc:/opt/apps/oidc:ro
environment: environment:
- NEXTCLOUD_TRUSTED_DOMAINS=app - NEXTCLOUD_TRUSTED_DOMAINS=app
+114 -8
View File
@@ -215,6 +215,36 @@ def _apply_stack_filters(
return stack return stack
# Statuses whose result set can contain archived cards. The active Deck
# listing endpoints (get_stacks/get_stack) exclude archived cards at the SQL
# level — only the /stacks/archived endpoint returns them — so these statuses
# need a second fetch and merge. See issue #842.
_ARCHIVED_STATUSES: frozenset[str] = frozenset({"all", "archived"})
async def _archived_cards_by_stack(client, board_id: int) -> dict[int, list[DeckCard]]:
"""Map stack_id -> archived DeckCards for a board.
The active stack/card listing endpoints filter out archived cards in SQL;
this hits ``/stacks/archived`` (the only endpoint that returns them) and
keys the cards by their stack so the list tools can merge them back in.
"""
archived_stacks = await client.deck.get_archived_stacks(board_id)
return {
stack.id: cast(list[DeckCard], stack.cards or []) for stack in archived_stacks
}
def _append_archived_cards(stack: DeckStack, extra: list[DeckCard]) -> None:
"""Append archived cards onto a stack's existing card list, in place.
Kept separate so the assignment stays correctly typed against
``DeckStack.cards`` (``list[DeckCard | DeckCardSummary] | None``)."""
merged: list[DeckCard | DeckCardSummary] = list(stack.cards or [])
merged.extend(extra)
stack.cards = merged
def _truncate_comment_message(message: str, message_max_length: int | None) -> str: def _truncate_comment_message(message: str, message_max_length: int | None) -> str:
"""Truncate a comment strictly longer than the limit; appends "".""" """Truncate a comment strictly longer than the limit; appends ""."""
if message_max_length is not None and len(message) > message_max_length: if message_max_length is not None and len(message) > message_max_length:
@@ -480,6 +510,8 @@ def configure_deck_tools(mcp: FastMCP):
status: Which cards to include — "open" (default), "done", status: Which cards to include — "open" (default), "done",
"archived", or "all". The first three partition the board "archived", or "all". The first three partition the board
(a card that is both done and archived counts as "archived"). (a card that is both done and archived counts as "archived").
"archived"/"all" include archived cards, which the active
listing endpoint omits — this costs one extra API call.
label: If set, only cards carrying a label with this exact title. label: If set, only cards carrying a label with this exact title.
assigned_to: If set, only cards assigned to this user UID. assigned_to: If set, only cards assigned to this user UID.
description_max_length: In detail="full", truncate each card's description_max_length: In detail="full", truncate each card's
@@ -492,7 +524,33 @@ def configure_deck_tools(mcp: FastMCP):
description_preview_length, "description_preview_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)
# Fetch active stacks and (when archived cards are in scope) the
# archived endpoint concurrently, then merge archived cards onto each
# stack by id before filtering. The active endpoint omits archived
# cards, so without this merge status="archived"/"all" would drop them.
stacks_holder: list[list[DeckStack]] = []
archived_by_stack: dict[int, list[DeckCard]] = {}
merge_archived = include_cards and status in _ARCHIVED_STATUSES
async def _get_active() -> None:
stacks_holder.append(await client.deck.get_stacks(board_id))
async def _get_archived() -> None:
archived_by_stack.update(await _archived_cards_by_stack(client, board_id))
async with anyio.create_task_group() as tg:
tg.start_soon(_get_active)
if merge_archived:
tg.start_soon(_get_archived)
stacks = stacks_holder[0]
if merge_archived:
for stack in stacks:
extra = archived_by_stack.get(stack.id)
if extra:
_append_archived_cards(stack, extra)
stacks = [ stacks = [
_apply_stack_filters( _apply_stack_filters(
stack, stack,
@@ -538,6 +596,8 @@ def configure_deck_tools(mcp: FastMCP):
detail: "summary" (default) or "full". detail: "summary" (default) or "full".
status: "open" (default), "done", "archived", or "all" status: "open" (default), "done", "archived", or "all"
(non-overlapping; a done+archived card counts as "archived"). (non-overlapping; a done+archived card counts as "archived").
"archived"/"all" include archived cards, which the active
listing endpoint omits — this costs one extra API call.
label: If set, only cards carrying a label with this exact title. label: If set, only cards carrying a label with this exact title.
assigned_to: If set, only cards assigned to this user UID. assigned_to: If set, only cards assigned to this user UID.
description_max_length: In detail="full", truncate descriptions. description_max_length: In detail="full", truncate descriptions.
@@ -549,6 +609,12 @@ def configure_deck_tools(mcp: FastMCP):
) )
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)
# Merge archived cards (omitted by the active endpoint) when in scope.
if include_cards and status in _ARCHIVED_STATUSES:
archived_by_stack = await _archived_cards_by_stack(client, board_id)
extra = archived_by_stack.get(stack_id)
if extra:
_append_archived_cards(stack, extra)
return _apply_stack_filters( return _apply_stack_filters(
stack, stack,
include_cards=include_cards, include_cards=include_cards,
@@ -578,9 +644,14 @@ def configure_deck_tools(mcp: FastMCP):
"""List archived stacks (with their archived cards) for a Nextcloud """List archived stacks (with their archived cards) for a Nextcloud
Deck board. Deck board.
Use this to audit completed work that has been archived off the This is the archived-only shortcut: it returns *only* archived cards
active board (e.g. cards moved through a "Done" stack and then in a single call. The active list tools (deck_get_cards,
archived via deck_archive_card). The shape mirrors deck_get_stacks. deck_get_stacks, deck_get_board_overview) also include archived cards
when called with status="archived"/"all"; use this tool when you want
archived cards exclusively and don't need the open ones. Typical use:
auditing completed work archived off the active board (e.g. cards moved
through a "Done" stack and then 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) and returned as compact without its cards would have no audit value) and returned as compact
@@ -652,7 +723,8 @@ def configure_deck_tools(mcp: FastMCP):
returns the complete card objects. returns the complete card objects.
status: "open" (default), "done", "archived", or "all". The first status: "open" (default), "done", "archived", or "all". The first
three partition the board (a done+archived card counts as three partition the board (a done+archived card counts as
"archived"). "archived"). "archived"/"all" include archived cards, which the
active listing endpoint omits — this costs one extra API call.
label: If set, only cards carrying a label with this exact title. label: If set, only cards carrying a label with this exact title.
assigned_to: If set, only cards assigned to this user UID. assigned_to: If set, only cards assigned to this user UID.
description_max_length: In detail="full", truncate descriptions. description_max_length: In detail="full", truncate descriptions.
@@ -663,9 +735,31 @@ def configure_deck_tools(mcp: FastMCP):
description_preview_length, "description_preview_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)
# Archived cards are excluded by the active stack endpoint, so for
# statuses that can include them we also fetch /stacks/archived and
# merge. "open"/"done" need only the active stack (no extra call).
active_cards: list[DeckCard] = []
archived_cards: list[DeckCard] = []
need_active = status != "archived"
need_archived = status in _ARCHIVED_STATUSES
async def _get_active() -> None:
stack = await client.deck.get_stack(board_id, stack_id)
active_cards.extend(cast(list[DeckCard], stack.cards or []))
async def _get_archived() -> None:
by_stack = await _archived_cards_by_stack(client, board_id)
archived_cards.extend(by_stack.get(stack_id, []))
async with anyio.create_task_group() as tg:
if need_active:
tg.start_soon(_get_active)
if need_archived:
tg.start_soon(_get_archived)
cards = _shape_cards( cards = _shape_cards(
cast(list[DeckCard], stack.cards or []), active_cards + archived_cards,
detail=detail, detail=detail,
status=status, status=status,
label=label, label=label,
@@ -703,6 +797,8 @@ def configure_deck_tools(mcp: FastMCP):
status: Which cards to include — "open" (default), "done", status: Which cards to include — "open" (default), "done",
"archived", or "all". The first three partition the board "archived", or "all". The first three partition the board
(a card that is both done and archived counts as "archived"). (a card that is both done and archived counts as "archived").
"archived"/"all" include archived cards, which the active
listing endpoint omits — this costs one extra API call.
label: If set, only cards carrying a label with this exact title. label: If set, only cards carrying a label with this exact title.
assigned_to: If set, only cards assigned to this user UID. assigned_to: If set, only cards assigned to this user UID.
description_preview_length: Length of the description preview description_preview_length: Length of the description preview
@@ -715,6 +811,8 @@ def configure_deck_tools(mcp: FastMCP):
board_holder: list[DeckBoard] = [] board_holder: list[DeckBoard] = []
stacks_holder: list[list[DeckStack]] = [] stacks_holder: list[list[DeckStack]] = []
archived_by_stack: dict[int, list[DeckCard]] = {}
merge_archived = status in _ARCHIVED_STATUSES
async def _get_board() -> None: async def _get_board() -> None:
board_holder.append(await client.deck.get_board(board_id)) board_holder.append(await client.deck.get_board(board_id))
@@ -722,9 +820,14 @@ def configure_deck_tools(mcp: FastMCP):
async def _get_stacks() -> None: async def _get_stacks() -> None:
stacks_holder.append(await client.deck.get_stacks(board_id)) stacks_holder.append(await client.deck.get_stacks(board_id))
async def _get_archived() -> None:
archived_by_stack.update(await _archived_cards_by_stack(client, board_id))
async with anyio.create_task_group() as tg: async with anyio.create_task_group() as tg:
tg.start_soon(_get_board) tg.start_soon(_get_board)
tg.start_soon(_get_stacks) tg.start_soon(_get_stacks)
if merge_archived:
tg.start_soon(_get_archived)
board = board_holder[0] board = board_holder[0]
stacks = stacks_holder[0] stacks = stacks_holder[0]
@@ -732,10 +835,13 @@ def configure_deck_tools(mcp: FastMCP):
stack_overviews: list[StackOverview] = [] stack_overviews: list[StackOverview] = []
total_cards = 0 total_cards = 0
for stack in stacks: for stack in stacks:
cards = cast(list[DeckCard], stack.cards or [])
if merge_archived:
cards = cards + archived_by_stack.get(stack.id, [])
summaries = [ summaries = [
_summarize_card(c, description_preview_length) _summarize_card(c, description_preview_length)
for c in _filter_cards( for c in _filter_cards(
cast(list[DeckCard], stack.cards or []), cards,
status=status, status=status,
label=label, label=label,
assigned_to=assigned_to, assigned_to=assigned_to,
+120
View File
@@ -421,3 +421,123 @@ async def test_deck_get_board_overview_mcp(
assert stack["card_count"] == len(stack["cards"]) assert stack["card_count"] == len(stack["cards"])
card_ids = [c["id"] for c in stack["cards"]] card_ids = [c["id"] for c in stack["cards"]]
assert card_data["id"] in card_ids assert card_data["id"] in card_ids
# Archived-card visibility in the active list tools (issue #842)
async def _create_and_archive_card(
nc_mcp_client: ClientSession, board_id: int, stack_id: int
) -> int:
"""Create a card in the given stack, archive it, and return its id.
The card is cleaned up when the temporary stack is deleted by the
fixture teardown (deleting a stack removes its cards)."""
create_result = await nc_mcp_client.call_tool(
"deck_create_card",
{
"board_id": board_id,
"stack_id": stack_id,
"title": f"Archived Card {uuid.uuid4().hex[:8]}",
},
)
assert create_result.isError is False, f"create failed: {create_result.content}"
archived_id = json.loads(create_result.content[0].text)["id"]
archive_result = await nc_mcp_client.call_tool(
"deck_archive_card",
{"board_id": board_id, "stack_id": stack_id, "card_id": archived_id},
)
assert archive_result.isError is False, f"archive failed: {archive_result.content}"
return archived_id
async def test_deck_get_cards_status_includes_archived_mcp(
nc_mcp_client: ClientSession, temporary_board_with_card: tuple
):
"""deck_get_cards: archived cards appear under status="all"/"archived" and
are excluded under the default "open" — the regression behind issue #842."""
board_data, stack_data, card_data = temporary_board_with_card
board_id = board_data["id"]
stack_id = stack_data["id"]
open_id = card_data["id"]
archived_id = await _create_and_archive_card(nc_mcp_client, board_id, stack_id)
async def card_ids(status: str) -> list[int]:
result = await nc_mcp_client.call_tool(
"deck_get_cards",
{"board_id": board_id, "stack_id": stack_id, "status": status},
)
assert result.isError is False, f"deck_get_cards({status}) failed"
return [c["id"] for c in json.loads(result.content[0].text)["cards"]]
open_ids = await card_ids("open")
assert open_id in open_ids
assert archived_id not in open_ids, "archived card must not show under 'open'"
all_ids = await card_ids("all")
assert open_id in all_ids and archived_id in all_ids, (
"status='all' must include both open and archived cards"
)
archived_only = await card_ids("archived")
assert archived_only == [archived_id], (
f"status='archived' should return only the archived card, got {archived_only}"
)
async def test_deck_get_stack_status_includes_archived_mcp(
nc_mcp_client: ClientSession, temporary_board_with_card: tuple
):
"""deck_get_stack (single stack) honours archived cards for status
"all"/"archived" too, mirroring deck_get_cards (issue #842)."""
board_data, stack_data, card_data = temporary_board_with_card
board_id = board_data["id"]
stack_id = stack_data["id"]
archived_id = await _create_and_archive_card(nc_mcp_client, board_id, stack_id)
async def stack_card_ids(status: str) -> list[int]:
result = await nc_mcp_client.call_tool(
"deck_get_stack",
{"board_id": board_id, "stack_id": stack_id, "status": status},
)
assert result.isError is False, f"deck_get_stack({status}) failed"
payload = json.loads(result.content[0].text)
return [c["id"] for c in (payload.get("cards") or [])]
assert archived_id not in await stack_card_ids("open")
assert archived_id in await stack_card_ids("all")
assert await stack_card_ids("archived") == [archived_id]
async def test_deck_get_stacks_and_overview_include_archived_mcp(
nc_mcp_client: ClientSession, temporary_board_with_card: tuple
):
"""deck_get_stacks and deck_get_board_overview surface archived cards when
status="all" (issue #842), keyed onto the correct stack."""
board_data, stack_data, card_data = temporary_board_with_card
board_id = board_data["id"]
stack_id = stack_data["id"]
archived_id = await _create_and_archive_card(nc_mcp_client, board_id, stack_id)
# deck_get_stacks(status="all")
stacks_result = await nc_mcp_client.call_tool(
"deck_get_stacks", {"board_id": board_id, "status": "all"}
)
assert stacks_result.isError is False, "deck_get_stacks(all) failed"
stacks_payload = json.loads(stacks_result.content[0].text)
stack = next(s for s in stacks_payload["stacks"] if s["id"] == stack_id)
stack_card_ids = [c["id"] for c in (stack.get("cards") or [])]
assert card_data["id"] in stack_card_ids
assert archived_id in stack_card_ids, "archived card missing from deck_get_stacks"
# deck_get_board_overview(status="all")
overview_result = await nc_mcp_client.call_tool(
"deck_get_board_overview", {"board_id": board_id, "status": "all"}
)
assert overview_result.isError is False, "board overview failed"
overview_payload = json.loads(overview_result.content[0].text)
ov_stack = next(s for s in overview_payload["stacks"] if s["id"] == stack_id)
ov_card_ids = [c["id"] for c in ov_stack["cards"]]
assert archived_id in ov_card_ids, "archived card missing from board overview"
assert ov_stack["card_count"] == len(ov_stack["cards"])
+38
View File
@@ -17,8 +17,10 @@ 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,
_append_archived_cards,
_apply_board_filters, _apply_board_filters,
_apply_stack_filters, _apply_stack_filters,
_archived_cards_by_stack,
_extract_uid, _extract_uid,
_filter_cards, _filter_cards,
_resolve_note_attach_path, _resolve_note_attach_path,
@@ -667,3 +669,39 @@ async def test_resolve_note_attach_path_handles_null_category(mocker):
path = await _resolve_note_attach_path(client, note_id=7) path = await _resolve_note_attach_path(client, note_id=7)
assert path == "/Notes/Bare.md" assert path == "/Notes/Bare.md"
# Archived-card merge (issue #842) -----------------------------------------
def test_append_archived_cards_merges_onto_existing():
"""Archived cards are appended after the stack's existing (open) cards."""
stack = _make_stack(cards=[_make_card(1, archived=False)])
_append_archived_cards(stack, [_make_card(2, archived=True)])
assert stack.cards is not None
assert [c.id for c in stack.cards] == [1, 2]
def test_append_archived_cards_onto_none_cards():
"""A stack with cards=None gets a fresh list of the archived cards."""
stack = _make_stack(cards=None)
_append_archived_cards(stack, [_make_card(2, archived=True)])
assert stack.cards is not None
assert [c.id for c in stack.cards] == [2]
async def test_archived_cards_by_stack_maps_stack_id_to_cards(mocker):
"""The helper keys archived cards by stack id, coercing None to []."""
archived = [
_make_stack(stack_id=3, cards=[_make_card(10, archived=True)]),
_make_stack(stack_id=4, cards=None),
]
client = mocker.MagicMock()
client.deck.get_archived_stacks = mocker.AsyncMock(return_value=archived)
result = await _archived_cards_by_stack(client, board_id=1)
assert set(result) == {3, 4}
assert [c.id for c in result[3]] == [10]
assert result[4] == []
client.deck.get_archived_stacks.assert_awaited_once_with(1)