diff --git a/artifacts/activity-log/2026-09-14-mcp-milestone-user-stories-toggle.md b/artifacts/activity-log/2026-09-14-mcp-milestone-user-stories-toggle.md new file mode 100644 index 0000000..2d919ba --- /dev/null +++ b/artifacts/activity-log/2026-09-14-mcp-milestone-user-stories-toggle.md @@ -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 diff --git a/artifacts/evaluations/2026-09-14-mcp-milestone-user-stories-toggle.md b/artifacts/evaluations/2026-09-14-mcp-milestone-user-stories-toggle.md new file mode 100644 index 0000000..883ed3c --- /dev/null +++ b/artifacts/evaluations/2026-09-14-mcp-milestone-user-stories-toggle.md @@ -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. diff --git a/docs/mcp.rst b/docs/mcp.rst index e6ea549..f26b204 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 5548a94..568e42c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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[0-9]+) \\.(?P[0-9]+) diff --git a/taiga/__init__.py b/taiga/__init__.py index 770143d..6351f31 100644 --- a/taiga/__init__.py +++ b/taiga/__init__.py @@ -6,7 +6,7 @@ Taiga Python API library """ -__version__ = "2.0.0b2" +__version__ = "2.0.0b3" __author__ = "Nephila" __license__ = "MIT" __all__ = ["TaigaAPI"] diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 6c881dd..2f213b9 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -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() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 04cd464..80b83ad 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -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() @@ -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() @@ -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()