Skip to content
Open
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
32 changes: 28 additions & 4 deletions plane_mcp/tools/work_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,26 @@
logger = get_logger(__name__)


class _CreateWorkItemCompat(CreateWorkItem):
"""CreateWorkItem that also serializes ``state_id`` for self-hosted CE.

Plane Cloud accepts ``state``; Community Edition accepts ``state_id``. The
wrong key is silently ignored (HTTP 200, state unchanged). Emitting both
keeps Cloud and CE working — see issue #183.
"""

state_id: str | None = None


class _UpdateWorkItemCompat(UpdateWorkItem):
"""UpdateWorkItem that also serializes ``state_id`` for self-hosted CE.

See :class:`_CreateWorkItemCompat` and issue #183.
"""

state_id: str | None = None


def _resolve_description_html(description_html: str | None, description_stripped: str | None) -> str | None:
"""Resolve the description_html to persist.

Expand Down Expand Up @@ -228,7 +248,7 @@ def create_work_item(
external_source: External system source name
external_id: External system identifier
parent: UUID of the parent work item
state: UUID of the state
state: UUID of the state (also sent as state_id for CE compatibility)
estimate_point: Estimate point value
type: Work item type identifier

Expand All @@ -241,7 +261,8 @@ def create_work_item(
priority if priority in get_args(PriorityEnum) else None # type: ignore[assignment]
)

data = CreateWorkItem(
# Mirror state onto state_id so Cloud and self-hosted CE both apply it (#183).
data = _CreateWorkItemCompat(
name=name,
assignees=assignees,
labels=labels,
Expand All @@ -257,6 +278,7 @@ def create_work_item(
external_id=external_id,
parent=parent,
state=state,
state_id=state,
estimate_point=estimate_point,
type=type,
)
Expand Down Expand Up @@ -411,7 +433,7 @@ def update_work_item(
external_source: External system source name
external_id: External system identifier
parent: UUID of the parent work item
state: UUID of the state
state: UUID of the state (also sent as state_id for CE compatibility)
estimate_point: Estimate point value
type: Work item type identifier

Expand All @@ -424,7 +446,8 @@ def update_work_item(
priority if priority in get_args(PriorityEnum) else None # type: ignore[assignment]
)

data = UpdateWorkItem(
# Mirror state onto state_id so Cloud and self-hosted CE both apply it (#183).
data = _UpdateWorkItemCompat(
name=name,
assignees=assignees,
labels=labels,
Expand All @@ -440,6 +463,7 @@ def update_work_item(
external_id=external_id,
parent=parent,
state=state,
state_id=state,
estimate_point=estimate_point,
type=type,
)
Expand Down
78 changes: 78 additions & 0 deletions tests/test_work_item_state_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Unit tests for work item state field Cloud/CE compatibility (#183)."""

import asyncio
from unittest.mock import MagicMock, patch

from fastmcp import FastMCP
from plane.models.work_items import WorkItem

from plane_mcp.tools.work_items import (
_CreateWorkItemCompat,
_UpdateWorkItemCompat,
register_work_item_tools,
)

STATE_UUID = "ba5e07a3-b2a0-4d45-93bf-8b03b0593e13"
PROJECT_ID = "7ac2e2c6-6057-43e9-88af-59cbda15d1cd"
WORK_ITEM_ID = "7d61d6dc-8998-409d-b026-6765fc0a7df8"


def test_update_compat_emits_state_and_state_id():
data = _UpdateWorkItemCompat(state=STATE_UUID, state_id=STATE_UUID)
payload = data.model_dump(exclude_none=True)
assert payload["state"] == STATE_UUID
assert payload["state_id"] == STATE_UUID


def test_create_compat_emits_state_and_state_id():
data = _CreateWorkItemCompat(name="Task", state=STATE_UUID, state_id=STATE_UUID)
payload = data.model_dump(exclude_none=True)
assert payload["name"] == "Task"
assert payload["state"] == STATE_UUID
assert payload["state_id"] == STATE_UUID


def test_update_compat_omits_state_fields_when_unset():
data = _UpdateWorkItemCompat(name="Renamed")
payload = data.model_dump(exclude_none=True)
assert payload == {"name": "Renamed"}
assert "state" not in payload
assert "state_id" not in payload


def _tool_fn(mcp: FastMCP, name: str):
return asyncio.run(mcp.get_tool(name)).fn


def test_update_work_item_tool_passes_dual_state_fields():
mcp = FastMCP("test")
register_work_item_tools(mcp)
update_work_item = _tool_fn(mcp, "update_work_item")

mock_client = MagicMock()
mock_client.work_items.update.return_value = WorkItem(id=WORK_ITEM_ID, name="Task", state=STATE_UUID)

with patch("plane_mcp.tools.work_items.get_plane_client_context", return_value=(mock_client, "homelab-ops")):
update_work_item(project_id=PROJECT_ID, work_item_id=WORK_ITEM_ID, state=STATE_UUID)

kwargs = mock_client.work_items.update.call_args.kwargs
payload = kwargs["data"].model_dump(exclude_none=True)
assert payload["state"] == STATE_UUID
assert payload["state_id"] == STATE_UUID


def test_create_work_item_tool_passes_dual_state_fields():
mcp = FastMCP("test")
register_work_item_tools(mcp)
create_work_item = _tool_fn(mcp, "create_work_item")

mock_client = MagicMock()
mock_client.work_items.create.return_value = WorkItem(id=WORK_ITEM_ID, name="Task", state=STATE_UUID)

with patch("plane_mcp.tools.work_items.get_plane_client_context", return_value=(mock_client, "homelab-ops")):
create_work_item(project_id=PROJECT_ID, name="Task", state=STATE_UUID)

kwargs = mock_client.work_items.create.call_args.kwargs
payload = kwargs["data"].model_dump(exclude_none=True)
assert payload["state"] == STATE_UUID
assert payload["state_id"] == STATE_UUID