Skip to content

Commit d8268ce

Browse files
committed
feat: add zammad_merge_tickets tool via legacy ticket_merge REST endpoint
Wraps PUT /api/v1/ticket_merge/{source_id}/{target_number} (zammad_py does not expose merge; the newer PUT /tickets/{id}/merge route 404s on some instances). Target can be given by display number or internal ID (number is looked up). Failed merges (HTTP 200 + result='failed') raise ValueError instead of being reported as success. Tests: 6 client + 6 model cases; suite 236 passed.
1 parent 8873b2e commit d8268ce

6 files changed

Lines changed: 283 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ An MCP server that connects AI assistants to Zammad, providing tools for managin
1919
- `zammad_update_ticket` - Update ticket properties
2020
- `zammad_add_article` - Add comments/notes to tickets
2121
- `zammad_add_ticket_tag` / `zammad_remove_ticket_tag` - Manage ticket tags
22+
- `zammad_merge_tickets` - Merge a source ticket into a target ticket (moves all articles; irreversible)
2223
- `zammad_get_ticket_tags` - Get tags assigned to a specific ticket
2324
- `zammad_list_tags` - List all tags defined in the system (requires admin.tag permission)
2425

‎mcp_zammad/client.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,63 @@ def update_ticket(
269269

270270
return dict(self.api.ticket.update(ticket_id, update_data))
271271

272+
def merge_tickets(
273+
self,
274+
source_ticket_id: int,
275+
target_ticket_number: str | None = None,
276+
target_ticket_id: int | None = None,
277+
) -> dict[str, Any]:
278+
"""
279+
Merge a duplicate ticket into a target ticket.
280+
281+
The source ticket's articles are moved into the target ticket and the
282+
source ticket receives the state 'merged'. This operation cannot be undone.
283+
284+
Uses the legacy REST endpoint PUT /api/v1/ticket_merge/{source_id}/{target_number}
285+
via zammad_py's internal session, since the library does not expose ticket
286+
merge and the newer PUT /tickets/{id}/merge route returns 404 on some instances.
287+
288+
Exactly one of target_ticket_number / target_ticket_id must be provided.
289+
When only the target's internal ID is known, its display number is looked up first.
290+
291+
Parameters
292+
----------
293+
source_ticket_id : int
294+
Internal database ID of the duplicate ticket
295+
target_ticket_number : str | None
296+
Display number of the ticket to merge into
297+
target_ticket_id : int | None
298+
Internal database ID of the ticket to merge into
299+
300+
Returns
301+
-------
302+
dict
303+
Merge result payload with 'result', 'target_ticket' and 'source_ticket'
304+
305+
Raises
306+
------
307+
ValueError
308+
If both or neither target identifier is provided, or if the
309+
API reports the merge as failed (the ticket_merge endpoint answers
310+
failures with HTTP 200 and {"result": "failed", "message": ...}).
311+
requests.HTTPError
312+
If the API request itself fails.
313+
314+
"""
315+
if (target_ticket_number is None) == (target_ticket_id is None):
316+
raise ValueError("Provide exactly one of target_ticket_number or target_ticket_id")
317+
318+
if target_ticket_number is None:
319+
target = self.api.ticket.find(target_ticket_id)
320+
target_ticket_number = str(target["number"])
321+
322+
response = self.api.session.put(f"{self.url}/ticket_merge/{source_ticket_id}/{target_ticket_number}")
323+
response.raise_for_status()
324+
payload = response.json()
325+
if payload.get("result") != "success":
326+
raise ValueError(f"Ticket merge failed: {payload.get('message', payload)}")
327+
return dict(payload)
328+
272329
def add_article(
273330
self,
274331
ticket_id: int,

‎mcp_zammad/models.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,45 @@ def sanitize_title(cls, v: str | None) -> str | None:
384384
return html.escape(v) if v else v
385385

386386

387+
class TicketMergeParams(StrictBaseModel):
388+
"""
389+
Merge ticket request parameters.
390+
391+
Exactly one of target_ticket_number / target_ticket_id must be provided.
392+
"""
393+
394+
source_ticket_id: int = Field(
395+
gt=0,
396+
description="Internal database ID of the duplicate ticket (it is merged away and gets state 'merged')",
397+
)
398+
target_ticket_number: str | None = Field(
399+
None,
400+
min_length=1,
401+
max_length=50,
402+
description="Display number of the ticket to merge into (e.g. '65003')",
403+
)
404+
target_ticket_id: int | None = Field(
405+
None,
406+
gt=0,
407+
description="Internal database ID of the ticket to merge into (its number is looked up automatically)",
408+
)
409+
410+
@model_validator(mode="after")
411+
def exactly_one_target(self) -> "TicketMergeParams":
412+
"""Ensure exactly one target identifier is provided."""
413+
if (self.target_ticket_number is None) == (self.target_ticket_id is None):
414+
raise ValueError("Provide exactly one of target_ticket_number or target_ticket_id")
415+
return self
416+
417+
418+
class TicketMergeResult(BaseModel):
419+
"""Result of a ticket merge operation."""
420+
421+
result: str = Field(description="Merge status ('success' on success)")
422+
target_ticket: Ticket = Field(description="The surviving ticket after the merge")
423+
source_ticket: Ticket | None = Field(None, description="The merged (duplicate) ticket, if returned by the API")
424+
425+
387426
class GetArticleAttachmentsParams(StrictBaseModel):
388427
"""Get article attachments request parameters."""
389428

‎mcp_zammad/server.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
Ticket,
4949
TicketCreate,
5050
TicketIdGuidanceError,
51+
TicketMergeParams,
52+
TicketMergeResult,
5153
TicketPriority,
5254
TicketSearchParams,
5355
TicketState,
@@ -1173,6 +1175,62 @@ def zammad_update_ticket(params: TicketUpdateParams) -> Ticket:
11731175
except Exception as e:
11741176
_handle_ticket_not_found_error(params.ticket_id, e)
11751177

1178+
@self.mcp.tool(annotations=_write_annotations("Merge Tickets"))
1179+
def zammad_merge_tickets(params: TicketMergeParams) -> TicketMergeResult:
1180+
"""Merge a duplicate ticket into another ticket (irreversible).
1181+
1182+
The source ticket's articles are moved into the target ticket and the
1183+
source ticket receives the state 'merged'. The target ticket keeps its
1184+
owner, state, and conversation.
1185+
1186+
Args:
1187+
params (TicketMergeParams): Validated merge parameters containing:
1188+
- source_ticket_id (int): Internal database ID of the DUPLICATE ticket
1189+
(required, NOT display number - it is merged away)
1190+
- target_ticket_number (str | None): Display number of the surviving
1191+
ticket (e.g. "65003")
1192+
- target_ticket_id (int | None): Internal database ID of the surviving
1193+
ticket (its number is looked up automatically)
1194+
1195+
Exactly one of target_ticket_number / target_ticket_id must be provided.
1196+
1197+
Returns:
1198+
TicketMergeResult: The merge result with schema:
1199+
1200+
```json
1201+
{
1202+
"result": "success",
1203+
"target_ticket": {"id": 124, "number": "65004", "state_id": 2, "...": "..."},
1204+
"source_ticket": {"id": 123, "number": "65003", "state_id": 5, "...": "..."}
1205+
}
1206+
```
1207+
1208+
Examples:
1209+
- Use when: "Merge duplicate 123 into ticket 65004" -> source_ticket_id=123, target_ticket_number="65004"
1210+
- Use when: "Merge ticket 123 into ticket 124" -> source_ticket_id=123, target_ticket_id=124
1211+
- Don't use when: Only linking related tickets (merge is irreversible)
1212+
- Don't use when: Adding a comment (use zammad_add_article)
1213+
1214+
Error Handling:
1215+
- Returns TicketIdGuidanceError if source ticket not found (suggests using search)
1216+
- Returns "Error: Validation failed" if both or neither target identifier is given
1217+
- Returns "Ticket merge failed: ..." if Zammad rejects the merge (the API
1218+
answers failures with HTTP 200 and result='failed'; surfaced as an error)
1219+
- Returns "Error: Permission denied" if no update permissions
1220+
1221+
Note:
1222+
source_ticket_id / target_ticket_id are internal database IDs, NOT display
1223+
numbers. Use the 'id' field from search results, not the 'number' field.
1224+
Both customers may see the merged conversation - merge only tickets that
1225+
truly belong together (same organization/issue).
1226+
"""
1227+
client = self.get_client()
1228+
try:
1229+
result = client.merge_tickets(**params.model_dump(exclude_none=True))
1230+
return TicketMergeResult(**result)
1231+
except Exception as e:
1232+
_handle_ticket_not_found_error(params.source_ticket_id, e)
1233+
11761234
@self.mcp.tool(annotations=_write_annotations("Add Ticket Article"))
11771235
def zammad_add_article(params: ArticleCreate) -> Article:
11781236
"""Add an article (comment/note/email) to an existing ticket with optional attachments.

‎tests/test_client_methods.py‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,3 +652,93 @@ def test_list_tags_permission_denied(self, mock_zammad_api: Mock) -> None:
652652

653653
with pytest.raises(requests.HTTPError, match="403"):
654654
client.list_tags()
655+
656+
657+
class TestMergeTickets:
658+
"""Test ZammadClient.merge_tickets."""
659+
660+
@pytest.fixture
661+
def mock_zammad_api(self) -> Generator[Mock, None, None]:
662+
"""Mock the underlying zammad_py.ZammadAPI."""
663+
with patch("mcp_zammad.client.ZammadAPI") as mock_api:
664+
yield mock_api
665+
666+
def _make_client(self, mock_zammad_api: Mock) -> tuple[ZammadClient, Mock]:
667+
"""Build a client with a mocked API and return (client, mock_instance)."""
668+
mock_instance = Mock()
669+
mock_zammad_api.return_value = mock_instance
670+
client = ZammadClient(url="https://test.zammad.com/api/v1", http_token="test-token")
671+
return client, mock_instance
672+
673+
def test_merge_with_target_number(self, mock_zammad_api: Mock) -> None:
674+
"""Merge uses legacy ticket_merge route with the target number."""
675+
client, mock_instance = self._make_client(mock_zammad_api)
676+
mock_response = Mock()
677+
mock_response.json.return_value = {
678+
"result": "success",
679+
"target_ticket": {"id": 124, "number": "65004"},
680+
"source_ticket": {"id": 123, "number": "65003", "state_id": 5},
681+
}
682+
mock_response.raise_for_status = Mock()
683+
mock_instance.session.put.return_value = mock_response
684+
685+
result = client.merge_tickets(source_ticket_id=123, target_ticket_number="65004")
686+
687+
assert result["result"] == "success"
688+
assert result["target_ticket"]["id"] == 124
689+
mock_instance.session.put.assert_called_once_with("https://test.zammad.com/api/v1/ticket_merge/123/65004")
690+
mock_instance.ticket.find.assert_not_called()
691+
692+
def test_merge_with_target_id_looks_up_number(self, mock_zammad_api: Mock) -> None:
693+
"""When only the target ID is given, its number is resolved first."""
694+
client, mock_instance = self._make_client(mock_zammad_api)
695+
mock_instance.ticket.find.return_value = {"id": 124, "number": "65004"}
696+
mock_response = Mock()
697+
mock_response.json.return_value = {"result": "success", "target_ticket": {"id": 124, "number": "65004"}}
698+
mock_response.raise_for_status = Mock()
699+
mock_instance.session.put.return_value = mock_response
700+
701+
result = client.merge_tickets(source_ticket_id=123, target_ticket_id=124)
702+
703+
mock_instance.ticket.find.assert_called_once_with(124)
704+
mock_instance.session.put.assert_called_once_with("https://test.zammad.com/api/v1/ticket_merge/123/65004")
705+
assert result["result"] == "success"
706+
707+
def test_merge_rejects_both_targets(self, mock_zammad_api: Mock) -> None:
708+
"""Providing both target identifiers raises ValueError."""
709+
client, mock_instance = self._make_client(mock_zammad_api)
710+
711+
with pytest.raises(ValueError, match="exactly one"):
712+
client.merge_tickets(source_ticket_id=123, target_ticket_number="65004", target_ticket_id=124)
713+
714+
mock_instance.session.put.assert_not_called()
715+
716+
def test_merge_rejects_no_target(self, mock_zammad_api: Mock) -> None:
717+
"""Providing no target identifier raises ValueError."""
718+
client, mock_instance = self._make_client(mock_zammad_api)
719+
720+
with pytest.raises(ValueError, match="exactly one"):
721+
client.merge_tickets(source_ticket_id=123)
722+
723+
mock_instance.session.put.assert_not_called()
724+
725+
def test_merge_http_error_propagates(self, mock_zammad_api: Mock) -> None:
726+
"""HTTP errors from the merge endpoint propagate."""
727+
client, mock_instance = self._make_client(mock_zammad_api)
728+
mock_response = Mock()
729+
mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found")
730+
mock_instance.session.put.return_value = mock_response
731+
732+
with pytest.raises(requests.HTTPError, match="404"):
733+
client.merge_tickets(source_ticket_id=123, target_ticket_number="99999")
734+
735+
def test_merge_failed_result_raises(self, mock_zammad_api: Mock) -> None:
736+
"""Zammad reports merge failures as HTTP 200 with result='failed' - must raise."""
737+
client, mock_instance = self._make_client(mock_zammad_api)
738+
mock_response = Mock()
739+
mock_response.json.return_value = {"result": "failed", "message": "The source ticket could not be found."}
740+
mock_response.raise_for_status = Mock()
741+
mock_instance.session.put.return_value = mock_response
742+
743+
with pytest.raises(ValueError, match="source ticket could not be found"):
744+
client.merge_tickets(source_ticket_id=99999999, target_ticket_number="65004")

‎tests/test_models.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
GetTicketParams,
1212
ResponseFormat,
1313
TicketCreate,
14+
TicketMergeParams,
1415
TicketUpdate,
1516
)
1617

@@ -58,6 +59,43 @@ def test_html_sanitization_in_title(self):
5859
update = TicketUpdate(title="<i>Important</i> Update") # type: ignore[call-arg]
5960
assert update.title == "&lt;i&gt;Important&lt;/i&gt; Update"
6061

62+
63+
class TestTicketMergeParams:
64+
"""Test TicketMergeParams model validation."""
65+
66+
def test_valid_with_target_number(self):
67+
"""Target by display number is accepted."""
68+
params = TicketMergeParams(source_ticket_id=123, target_ticket_number="65004") # type: ignore[call-arg]
69+
assert params.source_ticket_id == 123
70+
assert params.target_ticket_number == "65004"
71+
assert params.target_ticket_id is None
72+
73+
def test_valid_with_target_id(self):
74+
"""Target by internal ID is accepted."""
75+
params = TicketMergeParams(source_ticket_id=123, target_ticket_id=124) # type: ignore[call-arg]
76+
assert params.target_ticket_id == 124
77+
assert params.target_ticket_number is None
78+
79+
def test_rejects_both_targets(self):
80+
"""Providing both target identifiers fails validation."""
81+
with pytest.raises(ValidationError, match="exactly one"):
82+
TicketMergeParams(source_ticket_id=123, target_ticket_number="65004", target_ticket_id=124) # type: ignore[call-arg]
83+
84+
def test_rejects_no_target(self):
85+
"""Providing no target identifier fails validation."""
86+
with pytest.raises(ValidationError, match="exactly one"):
87+
TicketMergeParams(source_ticket_id=123) # type: ignore[call-arg]
88+
89+
def test_rejects_invalid_source_id(self):
90+
"""Source ticket ID must be positive."""
91+
with pytest.raises(ValidationError):
92+
TicketMergeParams(source_ticket_id=0, target_ticket_number="65004") # type: ignore[call-arg]
93+
94+
def test_rejects_empty_target_number(self):
95+
"""Target number must not be empty or whitespace-only."""
96+
with pytest.raises(ValidationError):
97+
TicketMergeParams(source_ticket_id=123, target_ticket_number=" ") # type: ignore[call-arg]
98+
6199
def test_none_title_not_sanitized(self):
62100
"""Test that None title is not processed."""
63101
update = TicketUpdate(state="closed") # type: ignore[call-arg]

0 commit comments

Comments
 (0)