Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
## 2026-09-14 — Added `include_user_stories` toggle to `list_milestones`/`get_milestone`
**What:** Added `include_user_stories: bool = True` to the MCP server's
`list_milestones` and `get_milestone` tools. `Milestone.user_stories` is always
fully expanded by python-taiga's parser into complete `UserStory` objects, which
`to_jsonable()` then serializes in full — so the embedded field can be large.
When `include_user_stories=False`, a new `_strip_user_stories()` helper pops that
key from each returned milestone dict (handles both the list shape from
`list_milestones` and the single-dict shape from `get_milestone`). Default `True`
preserves today's output exactly for existing callers. Updated `docs/mcp.rst`,
splitting the milestone tools' doc entry so the two changed tools get their own
line mentioning the flag.
**Why:** User request via `/nephila-flow`: "add to
taiga.mcp_server.server.list_milestones the option to return the full taiga
response for the given endpoint or to remove user_stories attribute from returned
data."
**Decisions:** Classified as "bounded" per superpowers:brainstorming (existing
tool, new flag) — short in-chat design instead of a written spec/plan doc, then
straight to TDD implementation; matches the same-day precedent from
[[2026-09-14-mcp-optional-project-param]]. Confirmed with the user that "full
Taiga response" meant one toggle (embed vs. strip `user_stories`), not a
raw/unparsed API passthrough. Extended the flag to `get_milestone` too (not just
`list_milestones` as literally asked) since it shares the identical
embedded-`user_stories` issue — user's explicit call when asked. Default `True`
chosen over `False` to avoid a breaking change to existing callers' output shape.
**Agent usage:**

| Stage | Agent/skill | Tokens | Time |
|---|---|---|---|
| Review | superpowers:requesting-code-review (general-purpose subagent) | ~70k | ~60s |

**Considered & dropped:** N/A — single clear approach, no alternatives seriously
weighed beyond the toggle-meaning clarification above.
**Follow-ups:** Review flagged two Minor nits (helper's `Any` type hint too broad;
no direct unit test of the helper) — both applied immediately as a follow-up commit
(narrowed the type hint, added 3 direct unit tests) rather than deferred.
**Refs:** Branch `issue/mcp-milestone-user-stories-toggle`, off
`issue/mcp-optional-project-param` (itself off `feature/issue-267-add-mcp`), not yet
merged/pushed.
Eval: 94% — artifacts/evaluations/2026-09-14-mcp-milestone-user-stories-toggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Evaluation — mcp-milestone-user-stories-toggle

- **Date:** 2026-09-14
- **Branch:** issue/mcp-milestone-user-stories-toggle (vs issue/mcp-optional-project-param, 2 commits: 0b91d67, 26404b5)
- **Task:** add `include_user_stories` option to `list_milestones`/`get_milestone` to strip the embedded `user_stories` field on request
- **Coverage:** full — both changed files read in full; approved short in-chat design (bounded path) used as the spec

## Scores
| Dimension | Score | Weight | Key evidence |
|---|---|---|---|
| Functionality | 5 | 20 | Implementation matches the approved design exactly (default `True`, trailing optional param, `_strip_user_stories` helper); review subagent independently verified backward compatibility with old-style positional/keyword calls |
| Testing | 5 | 15 | RED confirmed (`TypeError: unexpected keyword argument`) before GREEN; 4 include/exclude × list/get cases plus 3 direct unit tests of `_strip_user_stories` (dict, list, no-op-when-absent) added post-review; full suite 347/347 pass |
| Security | 5 | 15 | Pure in-memory dict transformation, no new trust-boundary input handling |
| Code quality & best practices | 5 | 15 | `ruff`/`black` clean; helper type hint narrowed from `Any` to `dict[str, Any] \| list[dict[str, Any]]` per review; matches sibling docstring style |
| Maintainability & flexibility | 5 | 15 | Single-purpose `_strip_user_stories` helper, two clear call sites, no coupling introduced |
| Error handling | 4 | 10 | `.pop(key, None)` correctly no-ops when `user_stories` is absent; no new failure paths |
| Documentation | 3 | 10 | Docstrings on both changed tools updated; `docs/mcp.rst:240-241` still documents the milestone tools as one undifferentiated group with no mention of the new flag — to be closed by doc-sync next |

## Recommendations
- Documentation: split `list_milestones`/`get_milestone` out from `create_milestone`/`delete_milestone` in `docs/mcp.rst` and mention `include_user_stories` — handled next via doc-sync.

## Total
**94%** — Precise, well-tested, fully backward-compatible change with review-driven polish already applied; only the pending doc-sync pass keeps Documentation below top marks.
10 changes: 8 additions & 2 deletions docs/mcp.rst
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,14 @@ Available tools
you already hold the database id (for example from a prior tool
response), not a ref.

``list_milestones``, ``get_milestone``, ``create_milestone``, ``delete_milestone``
Manage milestones (sprints), optionally scoped to a project.
``list_milestones``, ``get_milestone``
List/get milestones (sprints), optionally scoped to a project (``list_milestones``
only). Each milestone embeds its full ``user_stories`` - pass
``include_user_stories=False`` to strip that (potentially large) field from the
result.

``create_milestone``, ``delete_milestone``
Create/delete milestones (sprints).

``list_wiki_pages``, ``get_wiki_page``, ``create_wiki_page``, ``update_wiki_page``
Manage wiki pages, optionally scoped to a project.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ commit = true
message = "Release {new_version}"
commit_args = "--no-verify"
tag = false
current_version = "2.0.0b2"
current_version = "2.0.0b3"
parse = """(?x)
(?P<major>[0-9]+)
\\.(?P<minor>[0-9]+)
Expand Down
2 changes: 1 addition & 1 deletion taiga/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
Taiga Python API library
"""

__version__ = "2.0.0b2"
__version__ = "2.0.0b3"
__author__ = "Nephila"
__license__ = "MIT"
__all__ = ["TaigaAPI"]
Expand Down
31 changes: 26 additions & 5 deletions taiga/mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -614,23 +614,44 @@ def link_epic_user_story_by_id(epic_id: int, user_story_id: int) -> dict[str, An
# --- Milestones (sprints) -----------------------------------------------------------------


def _strip_user_stories(
data: dict[str, Any] | list[dict[str, Any]],
) -> dict[str, Any] | list[dict[str, Any]]:
"""Drop the 'user_stories' key from one or more serialized milestone dicts."""
for item in data if isinstance(data, list) else [data]:
item.pop("user_stories", None)
return data


@mcp.tool()
def list_milestones(project: str | int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]:
def list_milestones(
project: str | int | None = None,
filters: dict[str, Any] | None = None,
include_user_stories: bool = True,
) -> list[dict[str, Any]]:
"""List milestones (sprints), optionally scoped to a project.

Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/
`page_size` to page further, or `order_by` (e.g. '-created_date') to control order.
Each milestone embeds its full `user_stories`; pass `include_user_stories=False`
to strip that (potentially large) field from every returned milestone.
"""
query = dict(filters or {})
if project is not None:
query["project"] = _resolve_project_id(project)
return to_jsonable(get_client().milestones.list(**_paginated(query)))
result = to_jsonable(get_client().milestones.list(**_paginated(query)))
return result if include_user_stories else _strip_user_stories(result)


@mcp.tool()
def get_milestone(id: int) -> dict[str, Any]: # noqa: A002
"""Get a milestone by id."""
return to_jsonable(get_client().milestones.get(id))
def get_milestone(id: int, include_user_stories: bool = True) -> dict[str, Any]: # noqa: A002
"""Get a milestone by id.

The milestone embeds its full `user_stories`; pass `include_user_stories=False`
to strip that (potentially large) field from the returned milestone.
"""
result = to_jsonable(get_client().milestones.get(id))
return result if include_user_stories else _strip_user_stories(result)


@mcp.tool()
Expand Down
57 changes: 57 additions & 0 deletions tests/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -980,6 +980,19 @@ def test_link_epic_user_story_by_id(mock_get_client):
# --- Milestones ------------------------------------------------------------------------


def test_strip_user_stories_removes_key_from_dict():
assert server._strip_user_stories({"id": 1, "user_stories": [{"id": 10}]}) == {"id": 1}


def test_strip_user_stories_removes_key_from_each_item_in_list():
data = [{"id": 1, "user_stories": []}, {"id": 2, "user_stories": [{"id": 10}]}]
assert server._strip_user_stories(data) == [{"id": 1}, {"id": 2}]


def test_strip_user_stories_no_op_when_key_absent():
assert server._strip_user_stories({"id": 1}) == {"id": 1}


@patch("taiga.mcp_server.server.get_client")
def test_list_milestones_no_project(mock_get_client):
mock_client = MagicMock()
Expand All @@ -1004,6 +1017,28 @@ def test_list_milestones_with_project(mock_get_client):
assert result == [{"id": 1}]


@patch("taiga.mcp_server.server.get_client")
def test_list_milestones_includes_user_stories_by_default(mock_get_client):
mock_client = MagicMock()
mock_client.milestones.list.return_value = [{"id": 1, "user_stories": [{"id": 10}]}]
mock_get_client.return_value = mock_client

result = server.list_milestones()

assert result == [{"id": 1, "user_stories": [{"id": 10}]}]


@patch("taiga.mcp_server.server.get_client")
def test_list_milestones_excludes_user_stories_when_disabled(mock_get_client):
mock_client = MagicMock()
mock_client.milestones.list.return_value = [{"id": 1, "user_stories": [{"id": 10}]}]
mock_get_client.return_value = mock_client

result = server.list_milestones(include_user_stories=False)

assert result == [{"id": 1}]


@patch("taiga.mcp_server.server.get_client")
def test_get_milestone(mock_get_client):
mock_client = MagicMock()
Expand All @@ -1016,6 +1051,28 @@ def test_get_milestone(mock_get_client):
assert result == {"id": 1}


@patch("taiga.mcp_server.server.get_client")
def test_get_milestone_includes_user_stories_by_default(mock_get_client):
mock_client = MagicMock()
mock_client.milestones.get.return_value = {"id": 1, "user_stories": [{"id": 10}]}
mock_get_client.return_value = mock_client

result = server.get_milestone(1)

assert result == {"id": 1, "user_stories": [{"id": 10}]}


@patch("taiga.mcp_server.server.get_client")
def test_get_milestone_excludes_user_stories_when_disabled(mock_get_client):
mock_client = MagicMock()
mock_client.milestones.get.return_value = {"id": 1, "user_stories": [{"id": 10}]}
mock_get_client.return_value = mock_client

result = server.get_milestone(1, include_user_stories=False)

assert result == {"id": 1}


@patch("taiga.mcp_server.server.get_client")
def test_create_milestone(mock_get_client):
mock_client = MagicMock()
Expand Down
Loading