mcp-ticketer 0.2.0__py3-none-any.whl → 2.2.9__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.
Files changed (160) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +3 -3
  3. mcp_ticketer/_version_scm.py +1 -0
  4. mcp_ticketer/adapters/__init__.py +2 -0
  5. mcp_ticketer/adapters/aitrackdown.py +930 -52
  6. mcp_ticketer/adapters/asana/__init__.py +15 -0
  7. mcp_ticketer/adapters/asana/adapter.py +1537 -0
  8. mcp_ticketer/adapters/asana/client.py +292 -0
  9. mcp_ticketer/adapters/asana/mappers.py +348 -0
  10. mcp_ticketer/adapters/asana/types.py +146 -0
  11. mcp_ticketer/adapters/github/__init__.py +26 -0
  12. mcp_ticketer/adapters/github/adapter.py +3229 -0
  13. mcp_ticketer/adapters/github/client.py +335 -0
  14. mcp_ticketer/adapters/github/mappers.py +797 -0
  15. mcp_ticketer/adapters/github/queries.py +692 -0
  16. mcp_ticketer/adapters/github/types.py +460 -0
  17. mcp_ticketer/adapters/hybrid.py +58 -16
  18. mcp_ticketer/adapters/jira/__init__.py +35 -0
  19. mcp_ticketer/adapters/jira/adapter.py +1351 -0
  20. mcp_ticketer/adapters/jira/client.py +271 -0
  21. mcp_ticketer/adapters/jira/mappers.py +246 -0
  22. mcp_ticketer/adapters/jira/queries.py +216 -0
  23. mcp_ticketer/adapters/jira/types.py +304 -0
  24. mcp_ticketer/adapters/linear/__init__.py +1 -1
  25. mcp_ticketer/adapters/linear/adapter.py +3810 -462
  26. mcp_ticketer/adapters/linear/client.py +312 -69
  27. mcp_ticketer/adapters/linear/mappers.py +305 -85
  28. mcp_ticketer/adapters/linear/queries.py +317 -17
  29. mcp_ticketer/adapters/linear/types.py +187 -64
  30. mcp_ticketer/adapters/linear.py +2 -2
  31. mcp_ticketer/analysis/__init__.py +56 -0
  32. mcp_ticketer/analysis/dependency_graph.py +255 -0
  33. mcp_ticketer/analysis/health_assessment.py +304 -0
  34. mcp_ticketer/analysis/orphaned.py +218 -0
  35. mcp_ticketer/analysis/project_status.py +594 -0
  36. mcp_ticketer/analysis/similarity.py +224 -0
  37. mcp_ticketer/analysis/staleness.py +266 -0
  38. mcp_ticketer/automation/__init__.py +11 -0
  39. mcp_ticketer/automation/project_updates.py +378 -0
  40. mcp_ticketer/cache/memory.py +9 -8
  41. mcp_ticketer/cli/adapter_diagnostics.py +421 -0
  42. mcp_ticketer/cli/auggie_configure.py +116 -15
  43. mcp_ticketer/cli/codex_configure.py +274 -82
  44. mcp_ticketer/cli/configure.py +1323 -151
  45. mcp_ticketer/cli/cursor_configure.py +314 -0
  46. mcp_ticketer/cli/diagnostics.py +209 -114
  47. mcp_ticketer/cli/discover.py +297 -26
  48. mcp_ticketer/cli/gemini_configure.py +119 -26
  49. mcp_ticketer/cli/init_command.py +880 -0
  50. mcp_ticketer/cli/install_mcp_server.py +418 -0
  51. mcp_ticketer/cli/instruction_commands.py +435 -0
  52. mcp_ticketer/cli/linear_commands.py +256 -130
  53. mcp_ticketer/cli/main.py +140 -1284
  54. mcp_ticketer/cli/mcp_configure.py +1013 -100
  55. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  56. mcp_ticketer/cli/migrate_config.py +12 -8
  57. mcp_ticketer/cli/platform_commands.py +123 -0
  58. mcp_ticketer/cli/platform_detection.py +477 -0
  59. mcp_ticketer/cli/platform_installer.py +545 -0
  60. mcp_ticketer/cli/project_update_commands.py +350 -0
  61. mcp_ticketer/cli/python_detection.py +126 -0
  62. mcp_ticketer/cli/queue_commands.py +15 -15
  63. mcp_ticketer/cli/setup_command.py +794 -0
  64. mcp_ticketer/cli/simple_health.py +84 -59
  65. mcp_ticketer/cli/ticket_commands.py +1375 -0
  66. mcp_ticketer/cli/update_checker.py +313 -0
  67. mcp_ticketer/cli/utils.py +195 -72
  68. mcp_ticketer/core/__init__.py +64 -1
  69. mcp_ticketer/core/adapter.py +618 -18
  70. mcp_ticketer/core/config.py +77 -68
  71. mcp_ticketer/core/env_discovery.py +75 -16
  72. mcp_ticketer/core/env_loader.py +121 -97
  73. mcp_ticketer/core/exceptions.py +32 -24
  74. mcp_ticketer/core/http_client.py +26 -26
  75. mcp_ticketer/core/instructions.py +405 -0
  76. mcp_ticketer/core/label_manager.py +732 -0
  77. mcp_ticketer/core/mappers.py +42 -30
  78. mcp_ticketer/core/milestone_manager.py +252 -0
  79. mcp_ticketer/core/models.py +566 -19
  80. mcp_ticketer/core/onepassword_secrets.py +379 -0
  81. mcp_ticketer/core/priority_matcher.py +463 -0
  82. mcp_ticketer/core/project_config.py +189 -49
  83. mcp_ticketer/core/project_utils.py +281 -0
  84. mcp_ticketer/core/project_validator.py +376 -0
  85. mcp_ticketer/core/registry.py +3 -3
  86. mcp_ticketer/core/session_state.py +176 -0
  87. mcp_ticketer/core/state_matcher.py +592 -0
  88. mcp_ticketer/core/url_parser.py +425 -0
  89. mcp_ticketer/core/validators.py +69 -0
  90. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  91. mcp_ticketer/mcp/__init__.py +29 -1
  92. mcp_ticketer/mcp/__main__.py +60 -0
  93. mcp_ticketer/mcp/server/__init__.py +25 -0
  94. mcp_ticketer/mcp/server/__main__.py +60 -0
  95. mcp_ticketer/mcp/server/constants.py +58 -0
  96. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  97. mcp_ticketer/mcp/server/dto.py +195 -0
  98. mcp_ticketer/mcp/server/main.py +1343 -0
  99. mcp_ticketer/mcp/server/response_builder.py +206 -0
  100. mcp_ticketer/mcp/server/routing.py +723 -0
  101. mcp_ticketer/mcp/server/server_sdk.py +151 -0
  102. mcp_ticketer/mcp/server/tools/__init__.py +69 -0
  103. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  104. mcp_ticketer/mcp/server/tools/attachment_tools.py +224 -0
  105. mcp_ticketer/mcp/server/tools/bulk_tools.py +330 -0
  106. mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
  107. mcp_ticketer/mcp/server/tools/config_tools.py +1564 -0
  108. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  109. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +942 -0
  110. mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
  111. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  112. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  113. mcp_ticketer/mcp/server/tools/pr_tools.py +150 -0
  114. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  115. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  116. mcp_ticketer/mcp/server/tools/search_tools.py +318 -0
  117. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  118. mcp_ticketer/mcp/server/tools/ticket_tools.py +1413 -0
  119. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
  120. mcp_ticketer/queue/__init__.py +1 -0
  121. mcp_ticketer/queue/health_monitor.py +168 -136
  122. mcp_ticketer/queue/manager.py +78 -63
  123. mcp_ticketer/queue/queue.py +108 -21
  124. mcp_ticketer/queue/run_worker.py +2 -2
  125. mcp_ticketer/queue/ticket_registry.py +213 -155
  126. mcp_ticketer/queue/worker.py +96 -58
  127. mcp_ticketer/utils/__init__.py +5 -0
  128. mcp_ticketer/utils/token_utils.py +246 -0
  129. mcp_ticketer-2.2.9.dist-info/METADATA +1396 -0
  130. mcp_ticketer-2.2.9.dist-info/RECORD +158 -0
  131. mcp_ticketer-2.2.9.dist-info/top_level.txt +2 -0
  132. py_mcp_installer/examples/phase3_demo.py +178 -0
  133. py_mcp_installer/scripts/manage_version.py +54 -0
  134. py_mcp_installer/setup.py +6 -0
  135. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  136. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  137. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  138. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  139. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  140. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  141. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  142. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  143. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  144. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  145. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  146. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  147. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  148. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  149. py_mcp_installer/tests/__init__.py +0 -0
  150. py_mcp_installer/tests/platforms/__init__.py +0 -0
  151. py_mcp_installer/tests/test_platform_detector.py +17 -0
  152. mcp_ticketer/adapters/github.py +0 -1354
  153. mcp_ticketer/adapters/jira.py +0 -1011
  154. mcp_ticketer/mcp/server.py +0 -1895
  155. mcp_ticketer-0.2.0.dist-info/METADATA +0 -414
  156. mcp_ticketer-0.2.0.dist-info/RECORD +0 -58
  157. mcp_ticketer-0.2.0.dist-info/top_level.txt +0 -1
  158. {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
  159. {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
  160. {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/licenses/LICENSE +0 -0
@@ -3,79 +3,89 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from datetime import datetime
6
- from typing import Any, Dict, List, Optional
6
+ from typing import Any
7
7
 
8
- from ...core.models import Comment, Epic, Priority, Task, TicketState
9
- from .types import (
10
- extract_linear_metadata,
11
- get_universal_priority,
12
- get_universal_state,
13
- )
8
+ from ...core.models import Attachment, Comment, Epic, Priority, Task, TicketState
9
+ from .types import extract_linear_metadata, get_universal_priority, get_universal_state
14
10
 
15
11
 
16
- def map_linear_issue_to_task(issue_data: Dict[str, Any]) -> Task:
17
- """Convert Linear issue data to universal Task model.
18
-
12
+ def map_linear_issue_to_task(issue_data: dict[str, Any]) -> Task:
13
+ """Convert Linear issue or sub-issue data to universal Task model.
14
+
15
+ Handles both top-level issues (no parent) and sub-issues (child items
16
+ with a parent issue).
17
+
19
18
  Args:
19
+ ----
20
20
  issue_data: Raw Linear issue data from GraphQL
21
-
21
+
22
22
  Returns:
23
+ -------
23
24
  Universal Task model
25
+
24
26
  """
25
27
  # Extract basic fields
26
28
  task_id = issue_data["identifier"]
27
29
  title = issue_data["title"]
28
30
  description = issue_data.get("description", "")
29
-
31
+
30
32
  # Map priority
31
33
  linear_priority = issue_data.get("priority", 3)
32
34
  priority = get_universal_priority(linear_priority)
33
-
34
- # Map state
35
+
36
+ # Map state with synonym matching (1M-164)
35
37
  state_data = issue_data.get("state", {})
36
38
  state_type = state_data.get("type", "unstarted")
37
- state = get_universal_state(state_type)
38
-
39
+ state_name = state_data.get("name") # Extract state name for synonym matching
40
+ state = get_universal_state(state_type, state_name)
41
+
39
42
  # Extract assignee
40
43
  assignee = None
41
44
  if issue_data.get("assignee"):
42
45
  assignee_data = issue_data["assignee"]
43
46
  assignee = assignee_data.get("email") or assignee_data.get("displayName")
44
-
47
+
45
48
  # Extract creator
46
49
  creator = None
47
50
  if issue_data.get("creator"):
48
51
  creator_data = issue_data["creator"]
49
52
  creator = creator_data.get("email") or creator_data.get("displayName")
50
-
53
+
51
54
  # Extract tags (labels)
52
55
  tags = []
53
56
  if issue_data.get("labels", {}).get("nodes"):
54
57
  tags = [label["name"] for label in issue_data["labels"]["nodes"]]
55
-
58
+
56
59
  # Extract parent epic (project)
57
60
  parent_epic = None
58
61
  if issue_data.get("project"):
59
62
  parent_epic = issue_data["project"]["id"]
60
-
63
+
61
64
  # Extract parent issue
62
65
  parent_issue = None
63
66
  if issue_data.get("parent"):
64
67
  parent_issue = issue_data["parent"]["identifier"]
65
-
68
+
66
69
  # Extract dates
67
70
  created_at = None
68
71
  if issue_data.get("createdAt"):
69
- created_at = datetime.fromisoformat(issue_data["createdAt"].replace("Z", "+00:00"))
70
-
72
+ created_at = datetime.fromisoformat(
73
+ issue_data["createdAt"].replace("Z", "+00:00")
74
+ )
75
+
71
76
  updated_at = None
72
77
  if issue_data.get("updatedAt"):
73
- updated_at = datetime.fromisoformat(issue_data["updatedAt"].replace("Z", "+00:00"))
74
-
78
+ updated_at = datetime.fromisoformat(
79
+ issue_data["updatedAt"].replace("Z", "+00:00")
80
+ )
81
+
82
+ # Extract child issue IDs
83
+ children = extract_child_issue_ids(issue_data)
84
+
75
85
  # Extract Linear-specific metadata
76
86
  linear_metadata = extract_linear_metadata(issue_data)
77
- metadata = {"linear": linear_metadata} if linear_metadata else None
78
-
87
+ metadata = {"linear": linear_metadata} if linear_metadata else {}
88
+
79
89
  return Task(
80
90
  id=task_id,
81
91
  title=title,
@@ -87,26 +97,30 @@ def map_linear_issue_to_task(issue_data: Dict[str, Any]) -> Task:
87
97
  tags=tags,
88
98
  parent_epic=parent_epic,
89
99
  parent_issue=parent_issue,
100
+ children=children,
90
101
  created_at=created_at,
91
102
  updated_at=updated_at,
92
103
  metadata=metadata,
93
104
  )
94
105
 
95
106
 
96
- def map_linear_project_to_epic(project_data: Dict[str, Any]) -> Epic:
107
+ def map_linear_project_to_epic(project_data: dict[str, Any]) -> Epic:
97
108
  """Convert Linear project data to universal Epic model.
98
-
109
+
99
110
  Args:
111
+ ----
100
112
  project_data: Raw Linear project data from GraphQL
101
-
113
+
102
114
  Returns:
115
+ -------
103
116
  Universal Epic model
117
+
104
118
  """
105
119
  # Extract basic fields
106
120
  epic_id = project_data["id"]
107
121
  title = project_data["name"]
108
122
  description = project_data.get("description", "")
109
-
123
+
110
124
  # Map state based on project state
111
125
  project_state = project_data.get("state", "planned")
112
126
  if project_state == "completed":
@@ -117,18 +131,22 @@ def map_linear_project_to_epic(project_data: Dict[str, Any]) -> Epic:
117
131
  state = TicketState.CLOSED
118
132
  else:
119
133
  state = TicketState.OPEN
120
-
134
+
121
135
  # Extract dates
122
136
  created_at = None
123
137
  if project_data.get("createdAt"):
124
- created_at = datetime.fromisoformat(project_data["createdAt"].replace("Z", "+00:00"))
125
-
138
+ created_at = datetime.fromisoformat(
139
+ project_data["createdAt"].replace("Z", "+00:00")
140
+ )
141
+
126
142
  updated_at = None
127
143
  if project_data.get("updatedAt"):
128
- updated_at = datetime.fromisoformat(project_data["updatedAt"].replace("Z", "+00:00"))
129
-
144
+ updated_at = datetime.fromisoformat(
145
+ project_data["updatedAt"].replace("Z", "+00:00")
146
+ )
147
+
130
148
  # Extract Linear-specific metadata
131
- metadata = {"linear": {}}
149
+ metadata: dict[str, Any] = {"linear": {}}
132
150
  if project_data.get("url"):
133
151
  metadata["linear"]["linear_url"] = project_data["url"]
134
152
  if project_data.get("icon"):
@@ -137,7 +155,7 @@ def map_linear_project_to_epic(project_data: Dict[str, Any]) -> Epic:
137
155
  metadata["linear"]["color"] = project_data["color"]
138
156
  if project_data.get("targetDate"):
139
157
  metadata["linear"]["target_date"] = project_data["targetDate"]
140
-
158
+
141
159
  return Epic(
142
160
  id=epic_id,
143
161
  title=title,
@@ -146,92 +164,115 @@ def map_linear_project_to_epic(project_data: Dict[str, Any]) -> Epic:
146
164
  priority=Priority.MEDIUM, # Projects don't have priority in Linear
147
165
  created_at=created_at,
148
166
  updated_at=updated_at,
149
- metadata=metadata if metadata["linear"] else None,
167
+ metadata=metadata if metadata["linear"] else {},
150
168
  )
151
169
 
152
170
 
153
- def map_linear_comment_to_comment(comment_data: Dict[str, Any], ticket_id: str) -> Comment:
171
+ def map_linear_comment_to_comment(
172
+ comment_data: dict[str, Any], ticket_id: str
173
+ ) -> Comment:
154
174
  """Convert Linear comment data to universal Comment model.
155
-
175
+
156
176
  Args:
177
+ ----
157
178
  comment_data: Raw Linear comment data from GraphQL
158
179
  ticket_id: ID of the ticket this comment belongs to
159
-
180
+
160
181
  Returns:
182
+ -------
161
183
  Universal Comment model
184
+
162
185
  """
163
186
  # Extract basic fields
164
187
  comment_id = comment_data["id"]
165
188
  body = comment_data.get("body", "")
166
-
189
+
167
190
  # Extract author
168
191
  author = None
169
192
  if comment_data.get("user"):
170
193
  user_data = comment_data["user"]
171
194
  author = user_data.get("email") or user_data.get("displayName")
172
-
195
+
173
196
  # Extract dates
174
197
  created_at = None
175
198
  if comment_data.get("createdAt"):
176
- created_at = datetime.fromisoformat(comment_data["createdAt"].replace("Z", "+00:00"))
177
-
178
- updated_at = None
199
+ created_at = datetime.fromisoformat(
200
+ comment_data["createdAt"].replace("Z", "+00:00")
201
+ )
202
+
203
+ # Note: Comment model doesn't have updated_at field
204
+ # Store it in metadata if needed
205
+ metadata = {}
179
206
  if comment_data.get("updatedAt"):
180
- updated_at = datetime.fromisoformat(comment_data["updatedAt"].replace("Z", "+00:00"))
181
-
207
+ metadata["updated_at"] = comment_data["updatedAt"]
208
+
182
209
  return Comment(
183
210
  id=comment_id,
184
211
  ticket_id=ticket_id,
185
- body=body,
212
+ content=body,
186
213
  author=author,
187
214
  created_at=created_at,
188
- updated_at=updated_at,
215
+ metadata=metadata,
189
216
  )
190
217
 
191
218
 
192
- def build_linear_issue_input(task: Task, team_id: str) -> Dict[str, Any]:
193
- """Build Linear issue input from universal Task model.
194
-
219
+ def build_linear_issue_input(task: Task, team_id: str) -> dict[str, Any]:
220
+ """Build Linear issue or sub-issue input from universal Task model.
221
+
222
+ Creates input for a top-level issue when task.parent_issue is not set,
223
+ or for a sub-issue when task.parent_issue is provided.
224
+
195
225
  Args:
226
+ ----
196
227
  task: Universal Task model
197
228
  team_id: Linear team ID
198
-
229
+
199
230
  Returns:
231
+ -------
200
232
  Linear issue input dictionary
233
+
201
234
  """
202
235
  from .types import get_linear_priority
203
-
204
- issue_input = {
236
+
237
+ issue_input: dict[str, Any] = {
205
238
  "title": task.title,
206
239
  "teamId": team_id,
207
240
  }
208
-
241
+
209
242
  # Add description if provided
210
243
  if task.description:
211
244
  issue_input["description"] = task.description
212
-
245
+
213
246
  # Add priority
214
247
  if task.priority:
215
248
  issue_input["priority"] = get_linear_priority(task.priority)
216
-
249
+
217
250
  # Add assignee if provided (assumes it's a user ID)
218
251
  if task.assignee:
219
252
  issue_input["assigneeId"] = task.assignee
220
-
253
+
221
254
  # Add parent issue if provided
222
255
  if task.parent_issue:
223
256
  issue_input["parentId"] = task.parent_issue
224
-
257
+
225
258
  # Add project (epic) if provided
226
259
  if task.parent_epic:
227
260
  issue_input["projectId"] = task.parent_epic
228
-
229
- # Add labels (tags) if provided
230
- if task.tags:
231
- # Note: Linear requires label IDs, not names
232
- # This would need to be resolved by the adapter
233
- pass
234
-
261
+
262
+ # DO NOT set labelIds here - adapter will handle label resolution
263
+ # Labels are resolved from names to UUIDs in LinearAdapter._create_task()
264
+ # If we set it here, we create a type mismatch (names vs UUIDs)
265
+ # The adapter's label resolution logic (lines 982-988) handles this properly
266
+ #
267
+ # Bug Fix (v1.1.1): Previously, setting labelIds to tag names here caused
268
+ # "Argument Validation Error" from Linear's GraphQL API. The API requires
269
+ # UUIDs (e.g., "uuid-1"), not names (e.g., "bug"). This was fixed by:
270
+ # 1. Removing labelIds assignment in mapper (this file)
271
+ # 2. Adding UUID validation in adapter._create_task() (adapter.py:1047-1060)
272
+ # 3. Changing GraphQL labelIds type to [String!]! (queries.py)
273
+ #
274
+ # See: docs/TROUBLESHOOTING.md#issue-argument-validation-error-when-creating-issues-with-labels
275
+
235
276
  # Add Linear-specific metadata
236
277
  if task.metadata and "linear" in task.metadata:
237
278
  linear_meta = task.metadata["linear"]
@@ -241,42 +282,49 @@ def build_linear_issue_input(task: Task, team_id: str) -> Dict[str, Any]:
241
282
  issue_input["cycleId"] = linear_meta["cycle_id"]
242
283
  if "estimate" in linear_meta:
243
284
  issue_input["estimate"] = linear_meta["estimate"]
244
-
285
+
245
286
  return issue_input
246
287
 
247
288
 
248
- def build_linear_issue_update_input(updates: Dict[str, Any]) -> Dict[str, Any]:
289
+ def build_linear_issue_update_input(updates: dict[str, Any]) -> dict[str, Any]:
249
290
  """Build Linear issue update input from update dictionary.
250
-
291
+
251
292
  Args:
293
+ ----
252
294
  updates: Dictionary of fields to update
253
-
295
+
254
296
  Returns:
297
+ -------
255
298
  Linear issue update input dictionary
299
+
256
300
  """
257
301
  from .types import get_linear_priority
258
-
302
+
259
303
  update_input = {}
260
-
304
+
261
305
  # Map standard fields
262
306
  if "title" in updates:
263
307
  update_input["title"] = updates["title"]
264
-
308
+
265
309
  if "description" in updates:
266
310
  update_input["description"] = updates["description"]
267
-
311
+
268
312
  if "priority" in updates:
269
- priority = Priority(updates["priority"]) if isinstance(updates["priority"], str) else updates["priority"]
313
+ priority = (
314
+ Priority(updates["priority"])
315
+ if isinstance(updates["priority"], str)
316
+ else updates["priority"]
317
+ )
270
318
  update_input["priority"] = get_linear_priority(priority)
271
-
319
+
272
320
  if "assignee" in updates:
273
321
  update_input["assigneeId"] = updates["assignee"]
274
-
322
+
275
323
  # Handle state transitions (would need workflow state mapping)
276
324
  if "state" in updates:
277
325
  # This would need to be handled by the adapter with proper state mapping
278
326
  pass
279
-
327
+
280
328
  # Handle metadata updates
281
329
  if "metadata" in updates and "linear" in updates["metadata"]:
282
330
  linear_meta = updates["metadata"]["linear"]
@@ -288,20 +336,192 @@ def build_linear_issue_update_input(updates: Dict[str, Any]) -> Dict[str, Any]:
288
336
  update_input["projectId"] = linear_meta["project_id"]
289
337
  if "estimate" in linear_meta:
290
338
  update_input["estimate"] = linear_meta["estimate"]
291
-
339
+
292
340
  return update_input
293
341
 
294
342
 
295
- def extract_child_issue_ids(issue_data: Dict[str, Any]) -> List[str]:
343
+ def extract_child_issue_ids(issue_data: dict[str, Any]) -> list[str]:
296
344
  """Extract child issue IDs from Linear issue data.
297
-
345
+
298
346
  Args:
347
+ ----
299
348
  issue_data: Raw Linear issue data from GraphQL
300
-
349
+
301
350
  Returns:
351
+ -------
302
352
  List of child issue identifiers
353
+
303
354
  """
304
355
  child_ids = []
305
356
  if issue_data.get("children", {}).get("nodes"):
306
357
  child_ids = [child["identifier"] for child in issue_data["children"]["nodes"]]
307
358
  return child_ids
359
+
360
+
361
+ def map_linear_attachment_to_attachment(
362
+ attachment_data: dict[str, Any], ticket_id: str
363
+ ) -> Attachment:
364
+ """Convert Linear attachment data to universal Attachment model.
365
+
366
+ Args:
367
+ ----
368
+ attachment_data: Raw Linear attachment data from GraphQL
369
+ ticket_id: ID of the ticket this attachment belongs to
370
+
371
+ Returns:
372
+ -------
373
+ Universal Attachment model
374
+
375
+ Note:
376
+ ----
377
+ Linear attachment URLs require authentication with API key.
378
+ URLs are in format: https://files.linear.app/workspace/attachment-id/filename
379
+ Authentication header: Authorization: Bearer {api_key}
380
+
381
+ """
382
+ # Extract basic fields
383
+ attachment_id = attachment_data.get("id")
384
+ title = attachment_data.get("title", "Untitled")
385
+ url = attachment_data.get("url")
386
+ subtitle = attachment_data.get("subtitle")
387
+
388
+ # Extract dates
389
+ created_at = None
390
+ if attachment_data.get("createdAt"):
391
+ created_at = datetime.fromisoformat(
392
+ attachment_data["createdAt"].replace("Z", "+00:00")
393
+ )
394
+
395
+ if attachment_data.get("updatedAt"):
396
+ datetime.fromisoformat(attachment_data["updatedAt"].replace("Z", "+00:00"))
397
+
398
+ # Build metadata with Linear-specific fields
399
+ metadata = {
400
+ "linear": {
401
+ "id": attachment_id,
402
+ "title": title,
403
+ "subtitle": subtitle,
404
+ "metadata": attachment_data.get("metadata"),
405
+ "updated_at": attachment_data.get("updatedAt"),
406
+ }
407
+ }
408
+
409
+ return Attachment(
410
+ id=attachment_id,
411
+ ticket_id=ticket_id,
412
+ filename=title, # Linear uses 'title' as filename
413
+ url=url,
414
+ content_type=None, # Linear doesn't provide MIME type in GraphQL
415
+ size_bytes=None, # Linear doesn't provide size in GraphQL
416
+ created_at=created_at,
417
+ created_by=None, # Not included in standard fragment
418
+ description=subtitle,
419
+ metadata=metadata,
420
+ )
421
+
422
+
423
+ def task_to_compact_format(task: Task) -> dict[str, Any]:
424
+ """Convert Task to compact format for efficient token usage.
425
+
426
+ Compact format includes only essential fields, reducing token usage by ~70-80%
427
+ compared to full Task serialization.
428
+
429
+ Args:
430
+ ----
431
+ task: Universal Task model
432
+
433
+ Returns:
434
+ -------
435
+ Compact dictionary with minimal essential fields
436
+
437
+ Design Decision: Compact Format Fields (1M-554)
438
+ -----------------------------------------------
439
+ Rationale: Selected fields based on token efficiency analysis and user needs.
440
+ Full Task serialization averages ~600 tokens per item. Compact format targets
441
+ ~120 tokens per item (80% reduction).
442
+
443
+ Essential fields included:
444
+ - id: Required for all operations (identifier)
445
+ - title: Primary user-facing information
446
+ - state: Critical for workflow understanding
447
+ - priority: Important for triage and filtering
448
+ - assignee: Key for task assignment visibility
449
+
450
+ Fields excluded:
451
+ - description: Often large (100-500 tokens), available via get()
452
+ - creator: Less critical for list views
453
+ - tags: Available in full format, not essential for scanning
454
+ - children: Hierarchy details available via dedicated queries
455
+ - created_at/updated_at: Not essential for list scanning
456
+ - metadata: Platform-specific, not needed in compact view
457
+
458
+ Performance: Reduces typical 50-item list from ~30,000 to ~6,000 tokens.
459
+
460
+ """
461
+ # Handle state - can be TicketState enum or string
462
+ state_value = None
463
+ if task.state:
464
+ state_value = task.state.value if hasattr(task.state, "value") else task.state
465
+
466
+ # Handle priority - can be Priority enum or string
467
+ priority_value = None
468
+ if task.priority:
469
+ priority_value = (
470
+ task.priority.value if hasattr(task.priority, "value") else task.priority
471
+ )
472
+
473
+ return {
474
+ "id": task.id,
475
+ "title": task.title,
476
+ "state": state_value,
477
+ "priority": priority_value,
478
+ "assignee": task.assignee,
479
+ }
480
+
481
+
482
+ def epic_to_compact_format(epic: Epic) -> dict[str, Any]:
483
+ """Convert Epic to compact format for efficient token usage.
484
+
485
+ Compact format includes only essential fields, reducing token usage by ~70-80%
486
+ compared to full Epic serialization.
487
+
488
+ Args:
489
+ ----
490
+ epic: Universal Epic model
491
+
492
+ Returns:
493
+ -------
494
+ Compact dictionary with minimal essential fields
495
+
496
+ Design Decision: Epic Compact Format (1M-554)
497
+ ---------------------------------------------
498
+ Rationale: Epics typically have less metadata than tasks, but descriptions
499
+ can still be large. Compact format focuses on overview information.
500
+
501
+ Essential fields:
502
+ - id: Required for all operations
503
+ - title: Primary identifier
504
+ - state: Project status
505
+ - child_count: Useful for project overview (if available)
506
+
507
+ Performance: Similar token reduction to task compact format.
508
+
509
+ """
510
+ # Handle state - can be TicketState enum or string
511
+ state_value = None
512
+ if epic.state:
513
+ state_value = epic.state.value if hasattr(epic.state, "value") else epic.state
514
+
515
+ compact = {
516
+ "id": epic.id,
517
+ "title": epic.title,
518
+ "state": state_value,
519
+ }
520
+
521
+ # Include child count if available in metadata
522
+ if epic.metadata and "linear" in epic.metadata:
523
+ linear_meta = epic.metadata["linear"]
524
+ if "issue_count" in linear_meta:
525
+ compact["child_count"] = linear_meta["issue_count"]
526
+
527
+ return compact