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,293 @@
1
+ """Ticket instructions management tools.
2
+
3
+ This module implements MCP tools for managing ticket writing instructions,
4
+ allowing AI agents to query and customize the guidelines that help create
5
+ well-structured, consistent tickets.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from ....core.instructions import (
12
+ InstructionsError,
13
+ InstructionsValidationError,
14
+ TicketInstructionsManager,
15
+ )
16
+ from ..server_sdk import mcp
17
+
18
+
19
+ @mcp.tool()
20
+ async def instructions_get() -> dict[str, Any]:
21
+ """Get current ticket writing instructions.
22
+
23
+ Retrieves the active instructions for the current project, which may be
24
+ custom project-specific instructions or the default embedded instructions.
25
+
26
+ Returns:
27
+ A dictionary containing:
28
+ - status: "completed" or "error"
29
+ - instructions: The full instruction text (if successful)
30
+ - source: "custom" or "default" indicating which instructions are active
31
+ - path: Path to custom instructions file (if exists)
32
+ - error: Error message (if failed)
33
+
34
+ Example response:
35
+ {
36
+ "status": "completed",
37
+ "instructions": "# Ticket Writing Guidelines...",
38
+ "source": "custom",
39
+ "path": "/path/to/project/.mcp-ticketer/instructions.md"
40
+ }
41
+
42
+ """
43
+ try:
44
+ # Use current working directory as project directory
45
+ manager = TicketInstructionsManager(project_dir=Path.cwd())
46
+
47
+ # Get instructions
48
+ instructions = manager.get_instructions()
49
+
50
+ # Determine source
51
+ source = "custom" if manager.has_custom_instructions() else "default"
52
+
53
+ # Build response
54
+ response: dict[str, Any] = {
55
+ "status": "completed",
56
+ "instructions": instructions,
57
+ "source": source,
58
+ }
59
+
60
+ # Add path if custom instructions exist
61
+ if source == "custom":
62
+ response["path"] = str(manager.get_instructions_path())
63
+
64
+ return response
65
+
66
+ except InstructionsError as e:
67
+ return {
68
+ "status": "error",
69
+ "error": f"Failed to get instructions: {str(e)}",
70
+ }
71
+ except Exception as e:
72
+ return {
73
+ "status": "error",
74
+ "error": f"Unexpected error: {str(e)}",
75
+ }
76
+
77
+
78
+ @mcp.tool()
79
+ async def instructions_set(content: str, source: str = "inline") -> dict[str, Any]:
80
+ r"""Set custom ticket writing instructions for the project.
81
+
82
+ Creates or overwrites custom instructions with the provided content.
83
+ The content is validated before saving.
84
+
85
+ Args:
86
+ content: The custom instructions content (markdown text)
87
+ source: Source type - "inline" for direct content or "file" for file path
88
+ (currently only "inline" is supported by MCP tools)
89
+
90
+ Returns:
91
+ A dictionary containing:
92
+ - status: "completed" or "error"
93
+ - message: Success or error message
94
+ - path: Path where instructions were saved (if successful)
95
+ - error: Detailed error message (if failed)
96
+
97
+ Example:
98
+ To set custom instructions:
99
+ instructions_set(
100
+ content="# Our Team's Ticket Guidelines\\n\\n...",
101
+ source="inline"
102
+ )
103
+
104
+ """
105
+ try:
106
+ # Validate source parameter
107
+ if source not in ["inline", "file"]:
108
+ return {
109
+ "status": "error",
110
+ "error": f"Invalid source '{source}'. Must be 'inline' or 'file'",
111
+ }
112
+
113
+ # Use current working directory as project directory
114
+ manager = TicketInstructionsManager(project_dir=Path.cwd())
115
+
116
+ # Set instructions
117
+ manager.set_instructions(content)
118
+
119
+ # Get path where instructions were saved
120
+ inst_path = manager.get_instructions_path()
121
+
122
+ return {
123
+ "status": "completed",
124
+ "message": "Custom instructions saved successfully",
125
+ "path": str(inst_path),
126
+ }
127
+
128
+ except InstructionsValidationError as e:
129
+ return {
130
+ "status": "error",
131
+ "error": f"Validation failed: {str(e)}",
132
+ "message": "Instructions content did not pass validation checks",
133
+ }
134
+ except InstructionsError as e:
135
+ return {
136
+ "status": "error",
137
+ "error": f"Failed to set instructions: {str(e)}",
138
+ }
139
+ except Exception as e:
140
+ return {
141
+ "status": "error",
142
+ "error": f"Unexpected error: {str(e)}",
143
+ }
144
+
145
+
146
+ @mcp.tool()
147
+ async def instructions_reset() -> dict[str, Any]:
148
+ """Reset to default instructions by deleting custom instructions.
149
+
150
+ Removes any custom project-specific instructions, causing the system
151
+ to revert to using the default embedded instructions.
152
+
153
+ Returns:
154
+ A dictionary containing:
155
+ - status: "completed" or "error"
156
+ - message: Description of what happened
157
+ - error: Error message (if failed)
158
+
159
+ Example response (when custom instructions existed):
160
+ {
161
+ "status": "completed",
162
+ "message": "Custom instructions deleted. Now using defaults."
163
+ }
164
+
165
+ Example response (when no custom instructions):
166
+ {
167
+ "status": "completed",
168
+ "message": "No custom instructions to delete. Already using defaults."
169
+ }
170
+
171
+ """
172
+ try:
173
+ # Use current working directory as project directory
174
+ manager = TicketInstructionsManager(project_dir=Path.cwd())
175
+
176
+ # Check if custom instructions exist
177
+ if not manager.has_custom_instructions():
178
+ return {
179
+ "status": "completed",
180
+ "message": "No custom instructions to delete. Already using defaults.",
181
+ }
182
+
183
+ # Delete custom instructions
184
+ deleted = manager.delete_instructions()
185
+
186
+ if deleted:
187
+ return {
188
+ "status": "completed",
189
+ "message": "Custom instructions deleted. Now using defaults.",
190
+ }
191
+ else:
192
+ return {
193
+ "status": "completed",
194
+ "message": "No custom instructions found to delete.",
195
+ }
196
+
197
+ except InstructionsError as e:
198
+ return {
199
+ "status": "error",
200
+ "error": f"Failed to reset instructions: {str(e)}",
201
+ }
202
+ except Exception as e:
203
+ return {
204
+ "status": "error",
205
+ "error": f"Unexpected error: {str(e)}",
206
+ }
207
+
208
+
209
+ @mcp.tool()
210
+ async def instructions_validate(content: str) -> dict[str, Any]:
211
+ """Validate ticket instructions content without saving.
212
+
213
+ Checks if the provided content meets validation requirements:
214
+ - Not empty
215
+ - Minimum length (100 characters)
216
+ - Contains markdown headers (warning only)
217
+
218
+ This allows AI agents to validate content before attempting to save it.
219
+
220
+ Args:
221
+ content: The instructions content to validate (markdown text)
222
+
223
+ Returns:
224
+ A dictionary containing:
225
+ - status: "valid" or "invalid"
226
+ - warnings: List of non-critical issues (e.g., missing headers)
227
+ - errors: List of critical validation failures
228
+ - message: Summary message
229
+
230
+ Example response (valid):
231
+ {
232
+ "status": "valid",
233
+ "warnings": ["No markdown headers found"],
234
+ "errors": [],
235
+ "message": "Content is valid but has 1 warning"
236
+ }
237
+
238
+ Example response (invalid):
239
+ {
240
+ "status": "invalid",
241
+ "warnings": [],
242
+ "errors": ["Content too short (50 characters). Minimum 100 required."],
243
+ "message": "Content validation failed"
244
+ }
245
+
246
+ """
247
+ warnings: list[str] = []
248
+ errors: list[str] = []
249
+
250
+ try:
251
+ # Check for empty content
252
+ if not content or not content.strip():
253
+ errors.append("Instructions content cannot be empty")
254
+ else:
255
+ # Check minimum length
256
+ if len(content.strip()) < 100:
257
+ errors.append(
258
+ f"Content too short ({len(content)} characters). "
259
+ "Minimum 100 characters required for meaningful guidelines."
260
+ )
261
+
262
+ # Check for markdown headers (warning only)
263
+ if not any(line.strip().startswith("#") for line in content.split("\n")):
264
+ warnings.append(
265
+ "No markdown headers found. "
266
+ "Consider using headers for better structure."
267
+ )
268
+
269
+ # Determine status
270
+ if errors:
271
+ status = "invalid"
272
+ message = "Content validation failed"
273
+ elif warnings:
274
+ status = "valid"
275
+ message = f"Content is valid but has {len(warnings)} warning(s)"
276
+ else:
277
+ status = "valid"
278
+ message = "Content is valid with no issues"
279
+
280
+ return {
281
+ "status": status,
282
+ "warnings": warnings,
283
+ "errors": errors,
284
+ "message": message,
285
+ }
286
+
287
+ except Exception as e:
288
+ return {
289
+ "status": "error",
290
+ "warnings": [],
291
+ "errors": [f"Validation error: {str(e)}"],
292
+ "message": "Validation process failed",
293
+ }
@@ -0,0 +1,154 @@
1
+ """Pull request integration tools for tickets.
2
+
3
+ This module implements tools for linking tickets with pull requests and
4
+ creating PRs from tickets. Note that PR functionality may not be available
5
+ in all adapters.
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ from ..server_sdk import get_adapter, mcp
11
+
12
+
13
+ @mcp.tool()
14
+ async def ticket_create_pr(
15
+ ticket_id: str,
16
+ title: str,
17
+ description: str = "",
18
+ source_branch: str | None = None,
19
+ target_branch: str = "main",
20
+ ) -> dict[str, Any]:
21
+ """Create a pull request linked to a ticket.
22
+
23
+ Creates a new pull request and automatically links it to the specified
24
+ ticket. This functionality may not be available in all adapters.
25
+
26
+ Args:
27
+ ticket_id: Unique identifier of the ticket to link the PR to
28
+ title: Pull request title
29
+ description: Pull request description
30
+ source_branch: Source branch for the PR (if not specified, may use ticket ID)
31
+ target_branch: Target branch for the PR (default: main)
32
+
33
+ Returns:
34
+ Created PR details and link information, or error information
35
+
36
+ """
37
+ try:
38
+ adapter = get_adapter()
39
+
40
+ # Check if adapter supports PR operations
41
+ if not hasattr(adapter, "create_pull_request"):
42
+ return {
43
+ "status": "error",
44
+ "error": f"Pull request creation not supported by {type(adapter).__name__} adapter",
45
+ "ticket_id": ticket_id,
46
+ }
47
+
48
+ # Read ticket to validate it exists
49
+ ticket = await adapter.read(ticket_id)
50
+ if ticket is None:
51
+ return {
52
+ "status": "error",
53
+ "error": f"Ticket {ticket_id} not found",
54
+ }
55
+
56
+ # Use ticket ID as source branch if not specified
57
+ if source_branch is None:
58
+ source_branch = f"feature/{ticket_id}"
59
+
60
+ # Create PR via adapter
61
+ pr_data = await adapter.create_pull_request( # type: ignore
62
+ ticket_id=ticket_id,
63
+ title=title,
64
+ description=description,
65
+ source_branch=source_branch,
66
+ target_branch=target_branch,
67
+ )
68
+
69
+ return {
70
+ "status": "completed",
71
+ "ticket_id": ticket_id,
72
+ "pull_request": pr_data,
73
+ }
74
+
75
+ except AttributeError:
76
+ return {
77
+ "status": "error",
78
+ "error": "Pull request creation not supported by this adapter",
79
+ "ticket_id": ticket_id,
80
+ }
81
+ except Exception as e:
82
+ return {
83
+ "status": "error",
84
+ "error": f"Failed to create pull request: {str(e)}",
85
+ "ticket_id": ticket_id,
86
+ }
87
+
88
+
89
+ @mcp.tool()
90
+ async def ticket_link_pr(
91
+ ticket_id: str,
92
+ pr_url: str,
93
+ ) -> dict[str, Any]:
94
+ """Link an existing pull request to a ticket.
95
+
96
+ Associates an existing pull request (identified by URL) with a ticket.
97
+ This is typically done by adding the PR URL to the ticket's metadata
98
+ or as a comment.
99
+
100
+ Args:
101
+ ticket_id: Unique identifier of the ticket
102
+ pr_url: URL of the pull request to link
103
+
104
+ Returns:
105
+ Link confirmation and updated ticket details, or error information
106
+
107
+ """
108
+ try:
109
+ adapter = get_adapter()
110
+
111
+ # Read ticket to validate it exists
112
+ ticket = await adapter.read(ticket_id)
113
+ if ticket is None:
114
+ return {
115
+ "status": "error",
116
+ "error": f"Ticket {ticket_id} not found",
117
+ }
118
+
119
+ # Check if adapter has specialized PR linking
120
+ if hasattr(adapter, "link_pull_request"):
121
+ result = await adapter.link_pull_request( # type: ignore
122
+ ticket_id=ticket_id, pr_url=pr_url
123
+ )
124
+ return {
125
+ "status": "completed",
126
+ "ticket_id": ticket_id,
127
+ "pr_url": pr_url,
128
+ "result": result,
129
+ }
130
+
131
+ # Fallback: Add PR link as comment
132
+ from ....core.models import Comment
133
+
134
+ comment = Comment(
135
+ ticket_id=ticket_id,
136
+ content=f"Pull Request: {pr_url}",
137
+ )
138
+
139
+ created_comment = await adapter.add_comment(comment)
140
+
141
+ return {
142
+ "status": "completed",
143
+ "ticket_id": ticket_id,
144
+ "pr_url": pr_url,
145
+ "method": "comment",
146
+ "comment": created_comment.model_dump(),
147
+ }
148
+
149
+ except Exception as e:
150
+ return {
151
+ "status": "error",
152
+ "error": f"Failed to link pull request: {str(e)}",
153
+ "ticket_id": ticket_id,
154
+ }
@@ -0,0 +1,206 @@
1
+ """Search and query tools for finding tickets.
2
+
3
+ This module implements advanced search capabilities for tickets using
4
+ various filters and criteria.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from ....core.models import Priority, SearchQuery, TicketState
10
+ from ..server_sdk import get_adapter, mcp
11
+
12
+
13
+ @mcp.tool()
14
+ async def ticket_search(
15
+ query: str | None = None,
16
+ state: str | None = None,
17
+ priority: str | None = None,
18
+ tags: list[str] | None = None,
19
+ assignee: str | None = None,
20
+ limit: int = 10,
21
+ ) -> dict[str, Any]:
22
+ """Search tickets using advanced filters.
23
+
24
+ Searches for tickets matching the specified criteria. All filters are
25
+ optional and can be combined.
26
+
27
+ Args:
28
+ query: Text search query to match against title and description
29
+ state: Filter by state - must be one of: open, in_progress, ready, tested, done, closed, waiting, blocked
30
+ priority: Filter by priority - must be one of: low, medium, high, critical
31
+ tags: Filter by tags - tickets must have all specified tags
32
+ assignee: Filter by assigned user ID or email
33
+ limit: Maximum number of results to return (default: 10, max: 100)
34
+
35
+ Returns:
36
+ List of tickets matching search criteria, or error information
37
+
38
+ """
39
+ try:
40
+ adapter = get_adapter()
41
+
42
+ # Validate and build search query
43
+ state_enum = None
44
+ if state is not None:
45
+ try:
46
+ state_enum = TicketState(state.lower())
47
+ except ValueError:
48
+ return {
49
+ "status": "error",
50
+ "error": f"Invalid state '{state}'. Must be one of: open, in_progress, ready, tested, done, closed, waiting, blocked",
51
+ }
52
+
53
+ priority_enum = None
54
+ if priority is not None:
55
+ try:
56
+ priority_enum = Priority(priority.lower())
57
+ except ValueError:
58
+ return {
59
+ "status": "error",
60
+ "error": f"Invalid priority '{priority}'. Must be one of: low, medium, high, critical",
61
+ }
62
+
63
+ # Create search query
64
+ search_query = SearchQuery(
65
+ query=query,
66
+ state=state_enum,
67
+ priority=priority_enum,
68
+ tags=tags,
69
+ assignee=assignee,
70
+ limit=min(limit, 100), # Enforce max limit
71
+ )
72
+
73
+ # Execute search via adapter
74
+ results = await adapter.search(search_query)
75
+
76
+ return {
77
+ "status": "completed",
78
+ "tickets": [ticket.model_dump() for ticket in results],
79
+ "count": len(results),
80
+ "query": {
81
+ "text": query,
82
+ "state": state,
83
+ "priority": priority,
84
+ "tags": tags,
85
+ "assignee": assignee,
86
+ },
87
+ }
88
+ except Exception as e:
89
+ return {
90
+ "status": "error",
91
+ "error": f"Failed to search tickets: {str(e)}",
92
+ }
93
+
94
+
95
+ @mcp.tool()
96
+ async def ticket_search_hierarchy(
97
+ query: str,
98
+ include_children: bool = True,
99
+ max_depth: int = 3,
100
+ ) -> dict[str, Any]:
101
+ """Search tickets and include their hierarchy.
102
+
103
+ Performs a text search and returns matching tickets along with their
104
+ hierarchical context (parent epics/issues and child issues/tasks).
105
+
106
+ Args:
107
+ query: Text search query to match against title and description
108
+ include_children: Whether to include child tickets in results
109
+ max_depth: Maximum hierarchy depth to include (1-3, default: 3)
110
+
111
+ Returns:
112
+ List of tickets with hierarchy information, or error information
113
+
114
+ """
115
+ try:
116
+ adapter = get_adapter()
117
+
118
+ # Validate max_depth
119
+ if max_depth < 1 or max_depth > 3:
120
+ return {
121
+ "status": "error",
122
+ "error": "max_depth must be between 1 and 3",
123
+ }
124
+
125
+ # Create search query
126
+ search_query = SearchQuery(
127
+ query=query,
128
+ limit=50, # Reasonable limit for hierarchical search
129
+ )
130
+
131
+ # Execute search via adapter
132
+ results = await adapter.search(search_query)
133
+
134
+ # Build hierarchical results
135
+ hierarchical_results = []
136
+ for ticket in results:
137
+ ticket_data = {
138
+ "ticket": ticket.model_dump(),
139
+ "hierarchy": {},
140
+ }
141
+
142
+ # Get parent epic if applicable
143
+ parent_epic_id = getattr(ticket, "parent_epic", None)
144
+ if parent_epic_id and max_depth >= 2:
145
+ try:
146
+ parent_epic = await adapter.read(parent_epic_id)
147
+ if parent_epic:
148
+ ticket_data["hierarchy"][
149
+ "parent_epic"
150
+ ] = parent_epic.model_dump()
151
+ except Exception:
152
+ pass # Parent not found, continue
153
+
154
+ # Get parent issue if applicable (for tasks)
155
+ parent_issue_id = getattr(ticket, "parent_issue", None)
156
+ if parent_issue_id and max_depth >= 2:
157
+ try:
158
+ parent_issue = await adapter.read(parent_issue_id)
159
+ if parent_issue:
160
+ ticket_data["hierarchy"][
161
+ "parent_issue"
162
+ ] = parent_issue.model_dump()
163
+ except Exception:
164
+ pass # Parent not found, continue
165
+
166
+ # Get children if requested
167
+ if include_children and max_depth >= 2:
168
+ children = []
169
+
170
+ # Get child issues (for epics)
171
+ child_issue_ids = getattr(ticket, "child_issues", [])
172
+ for child_id in child_issue_ids:
173
+ try:
174
+ child = await adapter.read(child_id)
175
+ if child:
176
+ children.append(child.model_dump())
177
+ except Exception:
178
+ pass # Child not found, continue
179
+
180
+ # Get child tasks (for issues)
181
+ child_task_ids = getattr(ticket, "children", [])
182
+ for child_id in child_task_ids:
183
+ try:
184
+ child = await adapter.read(child_id)
185
+ if child:
186
+ children.append(child.model_dump())
187
+ except Exception:
188
+ pass # Child not found, continue
189
+
190
+ if children:
191
+ ticket_data["hierarchy"]["children"] = children
192
+
193
+ hierarchical_results.append(ticket_data)
194
+
195
+ return {
196
+ "status": "completed",
197
+ "results": hierarchical_results,
198
+ "count": len(hierarchical_results),
199
+ "query": query,
200
+ "max_depth": max_depth,
201
+ }
202
+ except Exception as e:
203
+ return {
204
+ "status": "error",
205
+ "error": f"Failed to search with hierarchy: {str(e)}",
206
+ }