mcp-ticketer 0.3.5__py3-none-any.whl → 0.12.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mcp-ticketer might be problematic. Click here for more details.

Files changed (84) hide show
  1. mcp_ticketer/__version__.py +3 -3
  2. mcp_ticketer/adapters/__init__.py +2 -0
  3. mcp_ticketer/adapters/aitrackdown.py +263 -14
  4. mcp_ticketer/adapters/asana/__init__.py +15 -0
  5. mcp_ticketer/adapters/asana/adapter.py +1308 -0
  6. mcp_ticketer/adapters/asana/client.py +292 -0
  7. mcp_ticketer/adapters/asana/mappers.py +334 -0
  8. mcp_ticketer/adapters/asana/types.py +146 -0
  9. mcp_ticketer/adapters/github.py +326 -109
  10. mcp_ticketer/adapters/hybrid.py +11 -11
  11. mcp_ticketer/adapters/jira.py +271 -25
  12. mcp_ticketer/adapters/linear/adapter.py +693 -39
  13. mcp_ticketer/adapters/linear/client.py +61 -9
  14. mcp_ticketer/adapters/linear/mappers.py +9 -3
  15. mcp_ticketer/adapters/linear/queries.py +9 -7
  16. mcp_ticketer/cache/memory.py +9 -8
  17. mcp_ticketer/cli/adapter_diagnostics.py +1 -1
  18. mcp_ticketer/cli/auggie_configure.py +104 -15
  19. mcp_ticketer/cli/codex_configure.py +188 -32
  20. mcp_ticketer/cli/configure.py +37 -48
  21. mcp_ticketer/cli/diagnostics.py +20 -18
  22. mcp_ticketer/cli/discover.py +292 -26
  23. mcp_ticketer/cli/gemini_configure.py +107 -26
  24. mcp_ticketer/cli/instruction_commands.py +429 -0
  25. mcp_ticketer/cli/linear_commands.py +105 -22
  26. mcp_ticketer/cli/main.py +1830 -435
  27. mcp_ticketer/cli/mcp_configure.py +296 -89
  28. mcp_ticketer/cli/migrate_config.py +12 -8
  29. mcp_ticketer/cli/platform_commands.py +123 -0
  30. mcp_ticketer/cli/platform_detection.py +412 -0
  31. mcp_ticketer/cli/python_detection.py +126 -0
  32. mcp_ticketer/cli/queue_commands.py +15 -15
  33. mcp_ticketer/cli/simple_health.py +1 -1
  34. mcp_ticketer/cli/ticket_commands.py +773 -0
  35. mcp_ticketer/cli/update_checker.py +313 -0
  36. mcp_ticketer/cli/utils.py +67 -62
  37. mcp_ticketer/core/__init__.py +14 -1
  38. mcp_ticketer/core/adapter.py +84 -15
  39. mcp_ticketer/core/config.py +44 -39
  40. mcp_ticketer/core/env_discovery.py +42 -12
  41. mcp_ticketer/core/env_loader.py +15 -14
  42. mcp_ticketer/core/exceptions.py +3 -3
  43. mcp_ticketer/core/http_client.py +26 -26
  44. mcp_ticketer/core/instructions.py +405 -0
  45. mcp_ticketer/core/mappers.py +11 -11
  46. mcp_ticketer/core/models.py +50 -20
  47. mcp_ticketer/core/onepassword_secrets.py +379 -0
  48. mcp_ticketer/core/project_config.py +57 -35
  49. mcp_ticketer/core/registry.py +3 -3
  50. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  51. mcp_ticketer/mcp/__init__.py +29 -1
  52. mcp_ticketer/mcp/__main__.py +60 -0
  53. mcp_ticketer/mcp/server/__init__.py +25 -0
  54. mcp_ticketer/mcp/server/__main__.py +60 -0
  55. mcp_ticketer/mcp/{dto.py → server/dto.py} +32 -32
  56. mcp_ticketer/mcp/{server.py → server/main.py} +127 -74
  57. mcp_ticketer/mcp/{response_builder.py → server/response_builder.py} +2 -2
  58. mcp_ticketer/mcp/server/server_sdk.py +93 -0
  59. mcp_ticketer/mcp/server/tools/__init__.py +47 -0
  60. mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
  61. mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
  62. mcp_ticketer/mcp/server/tools/comment_tools.py +90 -0
  63. mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
  64. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +532 -0
  65. mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
  66. mcp_ticketer/mcp/server/tools/pr_tools.py +154 -0
  67. mcp_ticketer/mcp/server/tools/search_tools.py +206 -0
  68. mcp_ticketer/mcp/server/tools/ticket_tools.py +430 -0
  69. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
  70. mcp_ticketer/queue/__init__.py +1 -0
  71. mcp_ticketer/queue/health_monitor.py +5 -4
  72. mcp_ticketer/queue/manager.py +15 -51
  73. mcp_ticketer/queue/queue.py +19 -19
  74. mcp_ticketer/queue/run_worker.py +1 -1
  75. mcp_ticketer/queue/ticket_registry.py +14 -14
  76. mcp_ticketer/queue/worker.py +16 -14
  77. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +168 -32
  78. mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
  79. mcp_ticketer-0.3.5.dist-info/RECORD +0 -62
  80. /mcp_ticketer/mcp/{constants.py → server/constants.py} +0 -0
  81. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
  82. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
  83. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
  84. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,93 @@
1
+ """FastMCP-based MCP server implementation.
2
+
3
+ This module implements the MCP server using the official FastMCP SDK,
4
+ replacing the custom JSON-RPC implementation. It provides a cleaner,
5
+ more maintainable approach with automatic schema generation and
6
+ better error handling.
7
+
8
+ The server manages a global adapter instance that is configured at
9
+ startup and used by all tool implementations.
10
+ """
11
+
12
+ import logging
13
+ from typing import Any
14
+
15
+ from mcp.server.fastmcp import FastMCP
16
+
17
+ from ...core.adapter import BaseAdapter
18
+ from ...core.registry import AdapterRegistry
19
+
20
+ # Initialize FastMCP server
21
+ mcp = FastMCP("mcp-ticketer")
22
+
23
+ # Global adapter instance
24
+ _adapter: BaseAdapter | None = None
25
+
26
+ # Configure logging
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def configure_adapter(adapter_type: str, config: dict[str, Any]) -> None:
31
+ """Configure the global adapter instance.
32
+
33
+ This must be called before starting the server to initialize the
34
+ adapter that will handle all ticket operations.
35
+
36
+ Args:
37
+ adapter_type: Type of adapter to create (e.g., "linear", "jira", "github")
38
+ config: Configuration dictionary for the adapter
39
+
40
+ Raises:
41
+ ValueError: If adapter type is not registered
42
+ RuntimeError: If adapter configuration fails
43
+
44
+ """
45
+ global _adapter
46
+
47
+ try:
48
+ # Get adapter from registry
49
+ _adapter = AdapterRegistry.get_adapter(adapter_type, config)
50
+ logger.info(f"Configured {adapter_type} adapter for MCP server")
51
+ except Exception as e:
52
+ logger.error(f"Failed to configure adapter: {e}")
53
+ raise RuntimeError(f"Adapter configuration failed: {e}") from e
54
+
55
+
56
+ def get_adapter() -> BaseAdapter:
57
+ """Get the configured adapter instance.
58
+
59
+ Returns:
60
+ The global adapter instance
61
+
62
+ Raises:
63
+ RuntimeError: If adapter has not been configured
64
+
65
+ """
66
+ if _adapter is None:
67
+ raise RuntimeError(
68
+ "Adapter not configured. Call configure_adapter() before starting server."
69
+ )
70
+ return _adapter
71
+
72
+
73
+ # Import all tool modules to register them with FastMCP
74
+ # These imports must come after mcp is initialized but before main()
75
+ from . import tools # noqa: E402, F401
76
+
77
+
78
+ def main() -> None:
79
+ """Run the FastMCP server.
80
+
81
+ This function starts the server using stdio transport for
82
+ JSON-RPC communication with Claude Desktop/Code.
83
+
84
+ The adapter must be configured via configure_adapter() before
85
+ calling this function.
86
+
87
+ """
88
+ # Run the server with stdio transport
89
+ mcp.run(transport="stdio")
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
@@ -0,0 +1,47 @@
1
+ """MCP tool modules for ticket operations.
2
+
3
+ This package contains all FastMCP tool implementations organized by
4
+ functional area. Tools are automatically registered with the FastMCP
5
+ server when imported.
6
+
7
+ Modules:
8
+ ticket_tools: Basic CRUD operations for tickets
9
+ hierarchy_tools: Epic/Issue/Task hierarchy management
10
+ search_tools: Search and query operations
11
+ bulk_tools: Bulk create and update operations
12
+ comment_tools: Comment management
13
+ pr_tools: Pull request integration
14
+ attachment_tools: File attachment handling
15
+ instruction_tools: Ticket instructions management
16
+ config_tools: Configuration management (adapter, project, user settings)
17
+ user_ticket_tools: User-specific ticket operations (my tickets, transitions)
18
+
19
+ """
20
+
21
+ # Import all tool modules to register them with FastMCP
22
+ # Order matters - import core functionality first
23
+ from . import (
24
+ attachment_tools, # noqa: F401
25
+ bulk_tools, # noqa: F401
26
+ comment_tools, # noqa: F401
27
+ config_tools, # noqa: F401
28
+ hierarchy_tools, # noqa: F401
29
+ instruction_tools, # noqa: F401
30
+ pr_tools, # noqa: F401
31
+ search_tools, # noqa: F401
32
+ ticket_tools, # noqa: F401
33
+ user_ticket_tools, # noqa: F401
34
+ )
35
+
36
+ __all__ = [
37
+ "ticket_tools",
38
+ "hierarchy_tools",
39
+ "search_tools",
40
+ "bulk_tools",
41
+ "comment_tools",
42
+ "pr_tools",
43
+ "attachment_tools",
44
+ "instruction_tools",
45
+ "config_tools",
46
+ "user_ticket_tools",
47
+ ]
@@ -0,0 +1,226 @@
1
+ """Attachment management tools for tickets.
2
+
3
+ This module implements tools for attaching files to tickets and retrieving
4
+ attachment information. Note that file attachment functionality may not be
5
+ available in all adapters.
6
+ """
7
+
8
+ import mimetypes
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from ....core.models import Comment, TicketType
13
+ from ..server_sdk import get_adapter, mcp
14
+
15
+
16
+ @mcp.tool()
17
+ async def ticket_attach(
18
+ ticket_id: str,
19
+ file_path: str,
20
+ description: str = "",
21
+ ) -> dict[str, Any]: # Keep as dict for MCP compatibility
22
+ """Attach a file to a ticket.
23
+
24
+ Uploads a file and associates it with the specified ticket. This
25
+ functionality may not be available in all adapters.
26
+
27
+ Args:
28
+ ticket_id: Unique identifier of the ticket
29
+ file_path: Path to the file to attach
30
+ description: Optional description of the attachment
31
+
32
+ Returns:
33
+ Attachment details including URL or ID, or error information
34
+
35
+ """
36
+ try:
37
+ adapter = get_adapter()
38
+
39
+ # Read ticket to validate it exists and determine type
40
+ ticket = await adapter.read(ticket_id)
41
+ if ticket is None:
42
+ return {
43
+ "status": "error",
44
+ "error": f"Ticket {ticket_id} not found",
45
+ }
46
+
47
+ # Check if file exists
48
+ file_path_obj = Path(file_path)
49
+ if not file_path_obj.exists():
50
+ return {
51
+ "status": "error",
52
+ "error": f"File not found: {file_path}",
53
+ "ticket_id": ticket_id,
54
+ }
55
+
56
+ # Try Linear-specific upload methods first (most advanced)
57
+ if hasattr(adapter, "upload_file") and hasattr(adapter, "attach_file_to_issue"):
58
+ try:
59
+ # Determine MIME type
60
+ mime_type = mimetypes.guess_type(file_path)[0]
61
+
62
+ # Upload file to Linear's storage
63
+ file_url = await adapter.upload_file(file_path, mime_type) # type: ignore
64
+
65
+ # Determine ticket type and attach accordingly
66
+ ticket_type = getattr(ticket, "ticket_type", None)
67
+ filename = file_path_obj.name
68
+
69
+ if ticket_type == TicketType.EPIC and hasattr(
70
+ adapter, "attach_file_to_epic"
71
+ ):
72
+ # Attach to epic (project)
73
+ result = await adapter.attach_file_to_epic( # type: ignore
74
+ epic_id=ticket_id,
75
+ file_url=file_url,
76
+ title=description or filename,
77
+ subtitle=f"Uploaded file: {filename}",
78
+ )
79
+ else:
80
+ # Attach to issue/task
81
+ result = await adapter.attach_file_to_issue( # type: ignore
82
+ issue_id=ticket_id,
83
+ file_url=file_url,
84
+ title=description or filename,
85
+ subtitle=f"Uploaded file: {filename}",
86
+ comment_body=description if description else None,
87
+ )
88
+
89
+ return {
90
+ "status": "completed",
91
+ "ticket_id": ticket_id,
92
+ "method": "linear_native_upload",
93
+ "file_url": file_url,
94
+ "attachment": result,
95
+ }
96
+ except Exception:
97
+ # Fall through to legacy method if Linear-specific upload fails
98
+ pass
99
+
100
+ # Try legacy add_attachment method
101
+ if hasattr(adapter, "add_attachment"):
102
+ attachment = await adapter.add_attachment( # type: ignore
103
+ ticket_id=ticket_id, file_path=file_path, description=description
104
+ )
105
+
106
+ return {
107
+ "status": "completed",
108
+ "ticket_id": ticket_id,
109
+ "method": "adapter_native",
110
+ "attachment": attachment,
111
+ }
112
+
113
+ # Fallback: Add file reference as comment
114
+ comment_text = f"Attachment: {file_path}"
115
+ if description:
116
+ comment_text += f"\nDescription: {description}"
117
+
118
+ comment = Comment(
119
+ ticket_id=ticket_id,
120
+ content=comment_text,
121
+ )
122
+
123
+ created_comment = await adapter.add_comment(comment)
124
+
125
+ return {
126
+ "status": "completed",
127
+ "ticket_id": ticket_id,
128
+ "method": "comment_reference",
129
+ "file_path": file_path,
130
+ "comment": created_comment.model_dump(),
131
+ "note": "Adapter does not support direct file uploads. File reference added as comment.",
132
+ }
133
+
134
+ except FileNotFoundError:
135
+ return {
136
+ "status": "error",
137
+ "error": f"File not found: {file_path}",
138
+ "ticket_id": ticket_id,
139
+ }
140
+ except Exception as e:
141
+ return {
142
+ "status": "error",
143
+ "error": f"Failed to attach file: {str(e)}",
144
+ "ticket_id": ticket_id,
145
+ }
146
+
147
+
148
+ @mcp.tool()
149
+ async def ticket_attachments(
150
+ ticket_id: str,
151
+ ) -> dict[str, Any]: # Keep as dict for MCP compatibility
152
+ """Get all attachments for a ticket.
153
+
154
+ Retrieves a list of all files attached to the specified ticket.
155
+ This functionality may not be available in all adapters.
156
+
157
+ Args:
158
+ ticket_id: Unique identifier of the ticket
159
+
160
+ Returns:
161
+ List of attachments with metadata, or error information
162
+
163
+ """
164
+ try:
165
+ adapter = get_adapter()
166
+
167
+ # Read ticket to validate it exists
168
+ ticket = await adapter.read(ticket_id)
169
+ if ticket is None:
170
+ return {
171
+ "status": "error",
172
+ "error": f"Ticket {ticket_id} not found",
173
+ }
174
+
175
+ # Check if adapter supports attachments
176
+ if not hasattr(adapter, "get_attachments"):
177
+ return {
178
+ "status": "error",
179
+ "error": f"Attachment retrieval not supported by {type(adapter).__name__} adapter",
180
+ "ticket_id": ticket_id,
181
+ "note": "Check ticket comments for file references",
182
+ }
183
+
184
+ # Get attachments via adapter
185
+ attachments = await adapter.get_attachments(ticket_id) # type: ignore
186
+
187
+ return {
188
+ "status": "completed",
189
+ "ticket_id": ticket_id,
190
+ "attachments": attachments,
191
+ "count": len(attachments) if isinstance(attachments, list) else 0,
192
+ }
193
+
194
+ except AttributeError:
195
+ # Fallback: Check comments for attachment references
196
+ comments = await adapter.get_comments(ticket_id=ticket_id, limit=100)
197
+
198
+ # Look for comments that reference files
199
+ attachment_refs = []
200
+ for comment in comments:
201
+ content = comment.content or ""
202
+ if content.startswith("Attachment:") or "file://" in content:
203
+ attachment_refs.append(
204
+ {
205
+ "type": "comment_reference",
206
+ "comment_id": comment.id,
207
+ "content": content,
208
+ "created_at": comment.created_at,
209
+ }
210
+ )
211
+
212
+ return {
213
+ "status": "completed",
214
+ "ticket_id": ticket_id,
215
+ "method": "comment_references",
216
+ "attachments": attachment_refs,
217
+ "count": len(attachment_refs),
218
+ "note": "Adapter does not support direct attachments. Showing file references from comments.",
219
+ }
220
+
221
+ except Exception as e:
222
+ return {
223
+ "status": "error",
224
+ "error": f"Failed to get attachments: {str(e)}",
225
+ "ticket_id": ticket_id,
226
+ }
@@ -0,0 +1,273 @@
1
+ """Bulk operations for creating and updating multiple tickets.
2
+
3
+ This module implements tools for batch operations on tickets to improve
4
+ efficiency when working with multiple items.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from ....core.models import Priority, Task, TicketState, TicketType
10
+ from ..server_sdk import get_adapter, mcp
11
+
12
+
13
+ @mcp.tool()
14
+ async def ticket_bulk_create(
15
+ tickets: list[dict[str, Any]],
16
+ ) -> dict[str, Any]:
17
+ """Create multiple tickets in a single operation.
18
+
19
+ Each ticket dict should contain at minimum a 'title' field, with optional
20
+ fields: description, priority, tags, assignee, ticket_type, parent_epic, parent_issue.
21
+
22
+ Args:
23
+ tickets: List of ticket dictionaries to create
24
+
25
+ Returns:
26
+ Results of bulk creation including successes and failures
27
+
28
+ """
29
+ try:
30
+ adapter = get_adapter()
31
+
32
+ if not tickets:
33
+ return {
34
+ "status": "error",
35
+ "error": "No tickets provided for bulk creation",
36
+ }
37
+
38
+ results = {
39
+ "created": [],
40
+ "failed": [],
41
+ }
42
+
43
+ for i, ticket_data in enumerate(tickets):
44
+ try:
45
+ # Validate required fields
46
+ if "title" not in ticket_data:
47
+ results["failed"].append(
48
+ {
49
+ "index": i,
50
+ "error": "Missing required field: title",
51
+ "data": ticket_data,
52
+ }
53
+ )
54
+ continue
55
+
56
+ # Parse priority if provided
57
+ priority = Priority.MEDIUM # Default
58
+ if "priority" in ticket_data:
59
+ try:
60
+ priority = Priority(ticket_data["priority"].lower())
61
+ except ValueError:
62
+ results["failed"].append(
63
+ {
64
+ "index": i,
65
+ "error": f"Invalid priority: {ticket_data['priority']}",
66
+ "data": ticket_data,
67
+ }
68
+ )
69
+ continue
70
+
71
+ # Parse ticket type if provided
72
+ ticket_type = TicketType.ISSUE # Default
73
+ if "ticket_type" in ticket_data:
74
+ try:
75
+ ticket_type = TicketType(ticket_data["ticket_type"].lower())
76
+ except ValueError:
77
+ results["failed"].append(
78
+ {
79
+ "index": i,
80
+ "error": f"Invalid ticket_type: {ticket_data['ticket_type']}",
81
+ "data": ticket_data,
82
+ }
83
+ )
84
+ continue
85
+
86
+ # Create task object
87
+ task = Task(
88
+ title=ticket_data["title"],
89
+ description=ticket_data.get("description", ""),
90
+ priority=priority,
91
+ ticket_type=ticket_type,
92
+ tags=ticket_data.get("tags", []),
93
+ assignee=ticket_data.get("assignee"),
94
+ parent_epic=ticket_data.get("parent_epic"),
95
+ parent_issue=ticket_data.get("parent_issue"),
96
+ )
97
+
98
+ # Create via adapter
99
+ created = await adapter.create(task)
100
+ results["created"].append(
101
+ {
102
+ "index": i,
103
+ "ticket": created.model_dump(),
104
+ }
105
+ )
106
+
107
+ except Exception as e:
108
+ results["failed"].append(
109
+ {
110
+ "index": i,
111
+ "error": str(e),
112
+ "data": ticket_data,
113
+ }
114
+ )
115
+
116
+ return {
117
+ "status": "completed",
118
+ "summary": {
119
+ "total": len(tickets),
120
+ "created": len(results["created"]),
121
+ "failed": len(results["failed"]),
122
+ },
123
+ "results": results,
124
+ }
125
+
126
+ except Exception as e:
127
+ return {
128
+ "status": "error",
129
+ "error": f"Bulk creation failed: {str(e)}",
130
+ }
131
+
132
+
133
+ @mcp.tool()
134
+ async def ticket_bulk_update(
135
+ updates: list[dict[str, Any]],
136
+ ) -> dict[str, Any]:
137
+ """Update multiple tickets in a single operation.
138
+
139
+ Each update dict must contain 'ticket_id' and at least one field to update.
140
+ Valid update fields: title, description, priority, state, assignee, tags.
141
+
142
+ Args:
143
+ updates: List of update operation dictionaries
144
+
145
+ Returns:
146
+ Results of bulk update including successes and failures
147
+
148
+ """
149
+ try:
150
+ adapter = get_adapter()
151
+
152
+ if not updates:
153
+ return {
154
+ "status": "error",
155
+ "error": "No updates provided for bulk operation",
156
+ }
157
+
158
+ results = {
159
+ "updated": [],
160
+ "failed": [],
161
+ }
162
+
163
+ for i, update_data in enumerate(updates):
164
+ try:
165
+ # Validate required fields
166
+ if "ticket_id" not in update_data:
167
+ results["failed"].append(
168
+ {
169
+ "index": i,
170
+ "error": "Missing required field: ticket_id",
171
+ "data": update_data,
172
+ }
173
+ )
174
+ continue
175
+
176
+ ticket_id = update_data["ticket_id"]
177
+
178
+ # Build update dict
179
+ update_fields: dict[str, Any] = {}
180
+
181
+ if "title" in update_data:
182
+ update_fields["title"] = update_data["title"]
183
+ if "description" in update_data:
184
+ update_fields["description"] = update_data["description"]
185
+ if "assignee" in update_data:
186
+ update_fields["assignee"] = update_data["assignee"]
187
+ if "tags" in update_data:
188
+ update_fields["tags"] = update_data["tags"]
189
+
190
+ # Parse priority if provided
191
+ if "priority" in update_data:
192
+ try:
193
+ update_fields["priority"] = Priority(
194
+ update_data["priority"].lower()
195
+ )
196
+ except ValueError:
197
+ results["failed"].append(
198
+ {
199
+ "index": i,
200
+ "error": f"Invalid priority: {update_data['priority']}",
201
+ "data": update_data,
202
+ }
203
+ )
204
+ continue
205
+
206
+ # Parse state if provided
207
+ if "state" in update_data:
208
+ try:
209
+ update_fields["state"] = TicketState(
210
+ update_data["state"].lower()
211
+ )
212
+ except ValueError:
213
+ results["failed"].append(
214
+ {
215
+ "index": i,
216
+ "error": f"Invalid state: {update_data['state']}",
217
+ "data": update_data,
218
+ }
219
+ )
220
+ continue
221
+
222
+ if not update_fields:
223
+ results["failed"].append(
224
+ {
225
+ "index": i,
226
+ "error": "No valid update fields provided",
227
+ "data": update_data,
228
+ }
229
+ )
230
+ continue
231
+
232
+ # Update via adapter
233
+ updated = await adapter.update(ticket_id, update_fields)
234
+ if updated is None:
235
+ results["failed"].append(
236
+ {
237
+ "index": i,
238
+ "error": f"Ticket {ticket_id} not found or update failed",
239
+ "data": update_data,
240
+ }
241
+ )
242
+ else:
243
+ results["updated"].append(
244
+ {
245
+ "index": i,
246
+ "ticket": updated.model_dump(),
247
+ }
248
+ )
249
+
250
+ except Exception as e:
251
+ results["failed"].append(
252
+ {
253
+ "index": i,
254
+ "error": str(e),
255
+ "data": update_data,
256
+ }
257
+ )
258
+
259
+ return {
260
+ "status": "completed",
261
+ "summary": {
262
+ "total": len(updates),
263
+ "updated": len(results["updated"]),
264
+ "failed": len(results["failed"]),
265
+ },
266
+ "results": results,
267
+ }
268
+
269
+ except Exception as e:
270
+ return {
271
+ "status": "error",
272
+ "error": f"Bulk update failed: {str(e)}",
273
+ }