mcp-ticketer 0.1.30__py3-none-any.whl → 1.2.11__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.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +796 -46
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1416 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +348 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github.py +879 -129
- mcp_ticketer/adapters/hybrid.py +11 -11
- mcp_ticketer/adapters/jira.py +973 -73
- mcp_ticketer/adapters/linear/__init__.py +24 -0
- mcp_ticketer/adapters/linear/adapter.py +2732 -0
- mcp_ticketer/adapters/linear/client.py +344 -0
- mcp_ticketer/adapters/linear/mappers.py +420 -0
- mcp_ticketer/adapters/linear/queries.py +479 -0
- mcp_ticketer/adapters/linear/types.py +360 -0
- mcp_ticketer/adapters/linear.py +10 -2315
- mcp_ticketer/analysis/__init__.py +23 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/cache/memory.py +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +421 -0
- mcp_ticketer/cli/auggie_configure.py +116 -15
- mcp_ticketer/cli/codex_configure.py +274 -82
- mcp_ticketer/cli/configure.py +888 -151
- mcp_ticketer/cli/diagnostics.py +400 -157
- mcp_ticketer/cli/discover.py +297 -26
- mcp_ticketer/cli/gemini_configure.py +119 -26
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +616 -0
- mcp_ticketer/cli/main.py +203 -1165
- mcp_ticketer/cli/mcp_configure.py +474 -90
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +418 -0
- mcp_ticketer/cli/platform_installer.py +513 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +90 -65
- mcp_ticketer/cli/ticket_commands.py +1013 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +114 -66
- mcp_ticketer/core/__init__.py +24 -1
- mcp_ticketer/core/adapter.py +250 -16
- mcp_ticketer/core/config.py +145 -37
- mcp_ticketer/core/env_discovery.py +101 -22
- mcp_ticketer/core/env_loader.py +349 -0
- mcp_ticketer/core/exceptions.py +160 -0
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +42 -30
- mcp_ticketer/core/models.py +280 -28
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/project_config.py +183 -49
- mcp_ticketer/core/registry.py +3 -3
- mcp_ticketer/core/session_state.py +171 -0
- mcp_ticketer/core/state_matcher.py +592 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +29 -1
- mcp_ticketer/mcp/__main__.py +60 -0
- mcp_ticketer/mcp/server/__init__.py +25 -0
- mcp_ticketer/mcp/server/__main__.py +60 -0
- mcp_ticketer/mcp/server/constants.py +58 -0
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/dto.py +195 -0
- mcp_ticketer/mcp/server/main.py +1343 -0
- mcp_ticketer/mcp/server/response_builder.py +206 -0
- mcp_ticketer/mcp/server/routing.py +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +151 -0
- mcp_ticketer/mcp/server/tools/__init__.py +56 -0
- mcp_ticketer/mcp/server/tools/analysis_tools.py +495 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +1439 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +921 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +300 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +948 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +215 -0
- mcp_ticketer/mcp/server/tools/session_tools.py +170 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1268 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +547 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +168 -136
- mcp_ticketer/queue/manager.py +95 -25
- mcp_ticketer/queue/queue.py +40 -21
- mcp_ticketer/queue/run_worker.py +6 -1
- mcp_ticketer/queue/ticket_registry.py +213 -155
- mcp_ticketer/queue/worker.py +109 -49
- mcp_ticketer-1.2.11.dist-info/METADATA +792 -0
- mcp_ticketer-1.2.11.dist-info/RECORD +110 -0
- mcp_ticketer/mcp/server.py +0 -1895
- mcp_ticketer-0.1.30.dist-info/METADATA +0 -413
- mcp_ticketer-0.1.30.dist-info/RECORD +0 -49
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Data transformation mappers for Linear API responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ...core.models import Attachment, Comment, Epic, Priority, Task, TicketState
|
|
9
|
+
from .types import extract_linear_metadata, get_universal_priority, get_universal_state
|
|
10
|
+
|
|
11
|
+
|
|
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
|
+
|
|
18
|
+
Args:
|
|
19
|
+
----
|
|
20
|
+
issue_data: Raw Linear issue data from GraphQL
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
-------
|
|
24
|
+
Universal Task model
|
|
25
|
+
|
|
26
|
+
"""
|
|
27
|
+
# Extract basic fields
|
|
28
|
+
task_id = issue_data["identifier"]
|
|
29
|
+
title = issue_data["title"]
|
|
30
|
+
description = issue_data.get("description", "")
|
|
31
|
+
|
|
32
|
+
# Map priority
|
|
33
|
+
linear_priority = issue_data.get("priority", 3)
|
|
34
|
+
priority = get_universal_priority(linear_priority)
|
|
35
|
+
|
|
36
|
+
# Map state with synonym matching (1M-164)
|
|
37
|
+
state_data = issue_data.get("state", {})
|
|
38
|
+
state_type = state_data.get("type", "unstarted")
|
|
39
|
+
state_name = state_data.get("name") # Extract state name for synonym matching
|
|
40
|
+
state = get_universal_state(state_type, state_name)
|
|
41
|
+
|
|
42
|
+
# Extract assignee
|
|
43
|
+
assignee = None
|
|
44
|
+
if issue_data.get("assignee"):
|
|
45
|
+
assignee_data = issue_data["assignee"]
|
|
46
|
+
assignee = assignee_data.get("email") or assignee_data.get("displayName")
|
|
47
|
+
|
|
48
|
+
# Extract creator
|
|
49
|
+
creator = None
|
|
50
|
+
if issue_data.get("creator"):
|
|
51
|
+
creator_data = issue_data["creator"]
|
|
52
|
+
creator = creator_data.get("email") or creator_data.get("displayName")
|
|
53
|
+
|
|
54
|
+
# Extract tags (labels)
|
|
55
|
+
tags = []
|
|
56
|
+
if issue_data.get("labels", {}).get("nodes"):
|
|
57
|
+
tags = [label["name"] for label in issue_data["labels"]["nodes"]]
|
|
58
|
+
|
|
59
|
+
# Extract parent epic (project)
|
|
60
|
+
parent_epic = None
|
|
61
|
+
if issue_data.get("project"):
|
|
62
|
+
parent_epic = issue_data["project"]["id"]
|
|
63
|
+
|
|
64
|
+
# Extract parent issue
|
|
65
|
+
parent_issue = None
|
|
66
|
+
if issue_data.get("parent"):
|
|
67
|
+
parent_issue = issue_data["parent"]["identifier"]
|
|
68
|
+
|
|
69
|
+
# Extract dates
|
|
70
|
+
created_at = None
|
|
71
|
+
if issue_data.get("createdAt"):
|
|
72
|
+
created_at = datetime.fromisoformat(
|
|
73
|
+
issue_data["createdAt"].replace("Z", "+00:00")
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
updated_at = None
|
|
77
|
+
if issue_data.get("updatedAt"):
|
|
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
|
+
|
|
85
|
+
# Extract Linear-specific metadata
|
|
86
|
+
linear_metadata = extract_linear_metadata(issue_data)
|
|
87
|
+
metadata = {"linear": linear_metadata} if linear_metadata else {}
|
|
88
|
+
|
|
89
|
+
return Task(
|
|
90
|
+
id=task_id,
|
|
91
|
+
title=title,
|
|
92
|
+
description=description,
|
|
93
|
+
state=state,
|
|
94
|
+
priority=priority,
|
|
95
|
+
assignee=assignee,
|
|
96
|
+
creator=creator,
|
|
97
|
+
tags=tags,
|
|
98
|
+
parent_epic=parent_epic,
|
|
99
|
+
parent_issue=parent_issue,
|
|
100
|
+
children=children,
|
|
101
|
+
created_at=created_at,
|
|
102
|
+
updated_at=updated_at,
|
|
103
|
+
metadata=metadata,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def map_linear_project_to_epic(project_data: dict[str, Any]) -> Epic:
|
|
108
|
+
"""Convert Linear project data to universal Epic model.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
----
|
|
112
|
+
project_data: Raw Linear project data from GraphQL
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
-------
|
|
116
|
+
Universal Epic model
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
# Extract basic fields
|
|
120
|
+
epic_id = project_data["id"]
|
|
121
|
+
title = project_data["name"]
|
|
122
|
+
description = project_data.get("description", "")
|
|
123
|
+
|
|
124
|
+
# Map state based on project state
|
|
125
|
+
project_state = project_data.get("state", "planned")
|
|
126
|
+
if project_state == "completed":
|
|
127
|
+
state = TicketState.DONE
|
|
128
|
+
elif project_state == "started":
|
|
129
|
+
state = TicketState.IN_PROGRESS
|
|
130
|
+
elif project_state == "canceled":
|
|
131
|
+
state = TicketState.CLOSED
|
|
132
|
+
else:
|
|
133
|
+
state = TicketState.OPEN
|
|
134
|
+
|
|
135
|
+
# Extract dates
|
|
136
|
+
created_at = None
|
|
137
|
+
if project_data.get("createdAt"):
|
|
138
|
+
created_at = datetime.fromisoformat(
|
|
139
|
+
project_data["createdAt"].replace("Z", "+00:00")
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
updated_at = None
|
|
143
|
+
if project_data.get("updatedAt"):
|
|
144
|
+
updated_at = datetime.fromisoformat(
|
|
145
|
+
project_data["updatedAt"].replace("Z", "+00:00")
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Extract Linear-specific metadata
|
|
149
|
+
metadata: dict[str, Any] = {"linear": {}}
|
|
150
|
+
if project_data.get("url"):
|
|
151
|
+
metadata["linear"]["linear_url"] = project_data["url"]
|
|
152
|
+
if project_data.get("icon"):
|
|
153
|
+
metadata["linear"]["icon"] = project_data["icon"]
|
|
154
|
+
if project_data.get("color"):
|
|
155
|
+
metadata["linear"]["color"] = project_data["color"]
|
|
156
|
+
if project_data.get("targetDate"):
|
|
157
|
+
metadata["linear"]["target_date"] = project_data["targetDate"]
|
|
158
|
+
|
|
159
|
+
return Epic(
|
|
160
|
+
id=epic_id,
|
|
161
|
+
title=title,
|
|
162
|
+
description=description,
|
|
163
|
+
state=state,
|
|
164
|
+
priority=Priority.MEDIUM, # Projects don't have priority in Linear
|
|
165
|
+
created_at=created_at,
|
|
166
|
+
updated_at=updated_at,
|
|
167
|
+
metadata=metadata if metadata["linear"] else {},
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def map_linear_comment_to_comment(
|
|
172
|
+
comment_data: dict[str, Any], ticket_id: str
|
|
173
|
+
) -> Comment:
|
|
174
|
+
"""Convert Linear comment data to universal Comment model.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
----
|
|
178
|
+
comment_data: Raw Linear comment data from GraphQL
|
|
179
|
+
ticket_id: ID of the ticket this comment belongs to
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
-------
|
|
183
|
+
Universal Comment model
|
|
184
|
+
|
|
185
|
+
"""
|
|
186
|
+
# Extract basic fields
|
|
187
|
+
comment_id = comment_data["id"]
|
|
188
|
+
body = comment_data.get("body", "")
|
|
189
|
+
|
|
190
|
+
# Extract author
|
|
191
|
+
author = None
|
|
192
|
+
if comment_data.get("user"):
|
|
193
|
+
user_data = comment_data["user"]
|
|
194
|
+
author = user_data.get("email") or user_data.get("displayName")
|
|
195
|
+
|
|
196
|
+
# Extract dates
|
|
197
|
+
created_at = None
|
|
198
|
+
if comment_data.get("createdAt"):
|
|
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 = {}
|
|
206
|
+
if comment_data.get("updatedAt"):
|
|
207
|
+
metadata["updated_at"] = comment_data["updatedAt"]
|
|
208
|
+
|
|
209
|
+
return Comment(
|
|
210
|
+
id=comment_id,
|
|
211
|
+
ticket_id=ticket_id,
|
|
212
|
+
content=body,
|
|
213
|
+
author=author,
|
|
214
|
+
created_at=created_at,
|
|
215
|
+
metadata=metadata,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
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
|
+
|
|
225
|
+
Args:
|
|
226
|
+
----
|
|
227
|
+
task: Universal Task model
|
|
228
|
+
team_id: Linear team ID
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
-------
|
|
232
|
+
Linear issue input dictionary
|
|
233
|
+
|
|
234
|
+
"""
|
|
235
|
+
from .types import get_linear_priority
|
|
236
|
+
|
|
237
|
+
issue_input: dict[str, Any] = {
|
|
238
|
+
"title": task.title,
|
|
239
|
+
"teamId": team_id,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
# Add description if provided
|
|
243
|
+
if task.description:
|
|
244
|
+
issue_input["description"] = task.description
|
|
245
|
+
|
|
246
|
+
# Add priority
|
|
247
|
+
if task.priority:
|
|
248
|
+
issue_input["priority"] = get_linear_priority(task.priority)
|
|
249
|
+
|
|
250
|
+
# Add assignee if provided (assumes it's a user ID)
|
|
251
|
+
if task.assignee:
|
|
252
|
+
issue_input["assigneeId"] = task.assignee
|
|
253
|
+
|
|
254
|
+
# Add parent issue if provided
|
|
255
|
+
if task.parent_issue:
|
|
256
|
+
issue_input["parentId"] = task.parent_issue
|
|
257
|
+
|
|
258
|
+
# Add project (epic) if provided
|
|
259
|
+
if task.parent_epic:
|
|
260
|
+
issue_input["projectId"] = task.parent_epic
|
|
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
|
+
|
|
276
|
+
# Add Linear-specific metadata
|
|
277
|
+
if task.metadata and "linear" in task.metadata:
|
|
278
|
+
linear_meta = task.metadata["linear"]
|
|
279
|
+
if "due_date" in linear_meta:
|
|
280
|
+
issue_input["dueDate"] = linear_meta["due_date"]
|
|
281
|
+
if "cycle_id" in linear_meta:
|
|
282
|
+
issue_input["cycleId"] = linear_meta["cycle_id"]
|
|
283
|
+
if "estimate" in linear_meta:
|
|
284
|
+
issue_input["estimate"] = linear_meta["estimate"]
|
|
285
|
+
|
|
286
|
+
return issue_input
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def build_linear_issue_update_input(updates: dict[str, Any]) -> dict[str, Any]:
|
|
290
|
+
"""Build Linear issue update input from update dictionary.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
----
|
|
294
|
+
updates: Dictionary of fields to update
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
-------
|
|
298
|
+
Linear issue update input dictionary
|
|
299
|
+
|
|
300
|
+
"""
|
|
301
|
+
from .types import get_linear_priority
|
|
302
|
+
|
|
303
|
+
update_input = {}
|
|
304
|
+
|
|
305
|
+
# Map standard fields
|
|
306
|
+
if "title" in updates:
|
|
307
|
+
update_input["title"] = updates["title"]
|
|
308
|
+
|
|
309
|
+
if "description" in updates:
|
|
310
|
+
update_input["description"] = updates["description"]
|
|
311
|
+
|
|
312
|
+
if "priority" in updates:
|
|
313
|
+
priority = (
|
|
314
|
+
Priority(updates["priority"])
|
|
315
|
+
if isinstance(updates["priority"], str)
|
|
316
|
+
else updates["priority"]
|
|
317
|
+
)
|
|
318
|
+
update_input["priority"] = get_linear_priority(priority)
|
|
319
|
+
|
|
320
|
+
if "assignee" in updates:
|
|
321
|
+
update_input["assigneeId"] = updates["assignee"]
|
|
322
|
+
|
|
323
|
+
# Handle state transitions (would need workflow state mapping)
|
|
324
|
+
if "state" in updates:
|
|
325
|
+
# This would need to be handled by the adapter with proper state mapping
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
# Handle metadata updates
|
|
329
|
+
if "metadata" in updates and "linear" in updates["metadata"]:
|
|
330
|
+
linear_meta = updates["metadata"]["linear"]
|
|
331
|
+
if "due_date" in linear_meta:
|
|
332
|
+
update_input["dueDate"] = linear_meta["due_date"]
|
|
333
|
+
if "cycle_id" in linear_meta:
|
|
334
|
+
update_input["cycleId"] = linear_meta["cycle_id"]
|
|
335
|
+
if "project_id" in linear_meta:
|
|
336
|
+
update_input["projectId"] = linear_meta["project_id"]
|
|
337
|
+
if "estimate" in linear_meta:
|
|
338
|
+
update_input["estimate"] = linear_meta["estimate"]
|
|
339
|
+
|
|
340
|
+
return update_input
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def extract_child_issue_ids(issue_data: dict[str, Any]) -> list[str]:
|
|
344
|
+
"""Extract child issue IDs from Linear issue data.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
----
|
|
348
|
+
issue_data: Raw Linear issue data from GraphQL
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
-------
|
|
352
|
+
List of child issue identifiers
|
|
353
|
+
|
|
354
|
+
"""
|
|
355
|
+
child_ids = []
|
|
356
|
+
if issue_data.get("children", {}).get("nodes"):
|
|
357
|
+
child_ids = [child["identifier"] for child in issue_data["children"]["nodes"]]
|
|
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
|
+
)
|