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.
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +263 -14
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1308 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +334 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github.py +326 -109
- mcp_ticketer/adapters/hybrid.py +11 -11
- mcp_ticketer/adapters/jira.py +271 -25
- mcp_ticketer/adapters/linear/adapter.py +693 -39
- mcp_ticketer/adapters/linear/client.py +61 -9
- mcp_ticketer/adapters/linear/mappers.py +9 -3
- mcp_ticketer/adapters/linear/queries.py +9 -7
- mcp_ticketer/cache/memory.py +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +1 -1
- mcp_ticketer/cli/auggie_configure.py +104 -15
- mcp_ticketer/cli/codex_configure.py +188 -32
- mcp_ticketer/cli/configure.py +37 -48
- mcp_ticketer/cli/diagnostics.py +20 -18
- mcp_ticketer/cli/discover.py +292 -26
- mcp_ticketer/cli/gemini_configure.py +107 -26
- mcp_ticketer/cli/instruction_commands.py +429 -0
- mcp_ticketer/cli/linear_commands.py +105 -22
- mcp_ticketer/cli/main.py +1830 -435
- mcp_ticketer/cli/mcp_configure.py +296 -89
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +412 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/simple_health.py +1 -1
- mcp_ticketer/cli/ticket_commands.py +773 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +67 -62
- mcp_ticketer/core/__init__.py +14 -1
- mcp_ticketer/core/adapter.py +84 -15
- mcp_ticketer/core/config.py +44 -39
- mcp_ticketer/core/env_discovery.py +42 -12
- mcp_ticketer/core/env_loader.py +15 -14
- mcp_ticketer/core/exceptions.py +3 -3
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/mappers.py +11 -11
- mcp_ticketer/core/models.py +50 -20
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/project_config.py +57 -35
- mcp_ticketer/core/registry.py +3 -3
- 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/{dto.py → server/dto.py} +32 -32
- mcp_ticketer/mcp/{server.py → server/main.py} +127 -74
- mcp_ticketer/mcp/{response_builder.py → server/response_builder.py} +2 -2
- mcp_ticketer/mcp/server/server_sdk.py +93 -0
- mcp_ticketer/mcp/server/tools/__init__.py +47 -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 +90 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +532 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +154 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +206 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +430 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +5 -4
- mcp_ticketer/queue/manager.py +15 -51
- mcp_ticketer/queue/queue.py +19 -19
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +14 -14
- mcp_ticketer/queue/worker.py +16 -14
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +168 -32
- mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
- mcp_ticketer-0.3.5.dist-info/RECORD +0 -62
- /mcp_ticketer/mcp/{constants.py → server/constants.py} +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/top_level.txt +0 -0
|
@@ -2,15 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import mimetypes
|
|
5
8
|
import os
|
|
9
|
+
from pathlib import Path
|
|
6
10
|
from typing import Any
|
|
7
11
|
|
|
8
12
|
try:
|
|
13
|
+
import httpx
|
|
9
14
|
from gql import gql
|
|
10
15
|
from gql.transport.exceptions import TransportQueryError
|
|
11
16
|
except ImportError:
|
|
12
17
|
gql = None
|
|
13
18
|
TransportQueryError = Exception
|
|
19
|
+
httpx = None
|
|
14
20
|
|
|
15
21
|
import builtins
|
|
16
22
|
|
|
@@ -92,10 +98,28 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
92
98
|
"Linear API key is required (api_key or LINEAR_API_KEY env var)"
|
|
93
99
|
)
|
|
94
100
|
|
|
95
|
-
# Clean API key - remove
|
|
96
|
-
# (The client will add
|
|
97
|
-
if self.api_key
|
|
98
|
-
|
|
101
|
+
# Clean API key - remove common prefixes if accidentally included in config
|
|
102
|
+
# (The client will add Bearer back when making requests)
|
|
103
|
+
if isinstance(self.api_key, str):
|
|
104
|
+
# Remove Bearer prefix
|
|
105
|
+
if self.api_key.startswith("Bearer "):
|
|
106
|
+
self.api_key = self.api_key.replace("Bearer ", "")
|
|
107
|
+
# Remove environment variable name prefix (e.g., "LINEAR_API_KEY=")
|
|
108
|
+
if "=" in self.api_key:
|
|
109
|
+
parts = self.api_key.split("=", 1)
|
|
110
|
+
if len(parts) == 2 and parts[0].upper() in (
|
|
111
|
+
"LINEAR_API_KEY",
|
|
112
|
+
"API_KEY",
|
|
113
|
+
):
|
|
114
|
+
self.api_key = parts[1]
|
|
115
|
+
|
|
116
|
+
# Validate API key format (Linear keys start with "lin_api_")
|
|
117
|
+
if not self.api_key.startswith("lin_api_"):
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"Invalid Linear API key format. Expected key starting with 'lin_api_', "
|
|
120
|
+
f"got: {self.api_key[:15]}... "
|
|
121
|
+
f"Please check your configuration and ensure the API key is correct."
|
|
122
|
+
)
|
|
99
123
|
|
|
100
124
|
self.workspace = config.get("workspace", "")
|
|
101
125
|
self.team_key = config.get("team_key")
|
|
@@ -144,7 +168,7 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
144
168
|
self._initialized = True
|
|
145
169
|
|
|
146
170
|
except Exception as e:
|
|
147
|
-
raise ValueError(f"Failed to initialize Linear adapter: {e}")
|
|
171
|
+
raise ValueError(f"Failed to initialize Linear adapter: {e}") from e
|
|
148
172
|
|
|
149
173
|
async def _ensure_team_id(self) -> str:
|
|
150
174
|
"""Ensure we have a team ID, resolving from team_key if needed.
|
|
@@ -190,7 +214,150 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
190
214
|
return self.team_id
|
|
191
215
|
|
|
192
216
|
except Exception as e:
|
|
193
|
-
raise ValueError(f"Failed to resolve team '{self.team_key}': {e}")
|
|
217
|
+
raise ValueError(f"Failed to resolve team '{self.team_key}': {e}") from e
|
|
218
|
+
|
|
219
|
+
async def _resolve_project_id(self, project_identifier: str) -> str | None:
|
|
220
|
+
"""Resolve project identifier (slug, name, short ID, or URL) to full UUID.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
project_identifier: Project slug, name, short ID, or URL
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Full Linear project UUID, or None if not found
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
ValueError: If project lookup fails
|
|
230
|
+
|
|
231
|
+
Examples:
|
|
232
|
+
- "crm-smart-monitoring-system" (slug)
|
|
233
|
+
- "CRM Smart Monitoring System" (name)
|
|
234
|
+
- "f59a41a96c52" (short ID from URL)
|
|
235
|
+
- "https://linear.app/travel-bta/project/crm-smart-monitoring-system-f59a41a96c52/overview" (full URL)
|
|
236
|
+
|
|
237
|
+
"""
|
|
238
|
+
if not project_identifier:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
# Extract slug/ID from URL if full URL provided
|
|
242
|
+
if project_identifier.startswith("http"):
|
|
243
|
+
# Extract slug-shortid from URL like:
|
|
244
|
+
# https://linear.app/travel-bta/project/crm-smart-monitoring-system-f59a41a96c52/overview
|
|
245
|
+
parts = project_identifier.split("/project/")
|
|
246
|
+
if len(parts) > 1:
|
|
247
|
+
slug_with_id = parts[1].split("/")[
|
|
248
|
+
0
|
|
249
|
+
] # Get "crm-smart-monitoring-system-f59a41a96c52"
|
|
250
|
+
project_identifier = slug_with_id
|
|
251
|
+
else:
|
|
252
|
+
raise ValueError(f"Invalid Linear project URL: {project_identifier}")
|
|
253
|
+
|
|
254
|
+
# If it looks like a full UUID already (exactly 36 chars with exactly 4 dashes), return it
|
|
255
|
+
# UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
|
256
|
+
if len(project_identifier) == 36 and project_identifier.count("-") == 4:
|
|
257
|
+
return project_identifier
|
|
258
|
+
|
|
259
|
+
# Query all projects and search for matching slug, name, or slugId
|
|
260
|
+
query = """
|
|
261
|
+
query GetProjects {
|
|
262
|
+
projects(first: 100) {
|
|
263
|
+
nodes {
|
|
264
|
+
id
|
|
265
|
+
name
|
|
266
|
+
slugId
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
result = await self.client.execute_query(query, {})
|
|
274
|
+
projects = result.get("projects", {}).get("nodes", [])
|
|
275
|
+
|
|
276
|
+
# Search for match by slug, slugId, name (case-insensitive)
|
|
277
|
+
project_lower = project_identifier.lower()
|
|
278
|
+
for project in projects:
|
|
279
|
+
# Check if identifier matches slug pattern (extracted from slugId)
|
|
280
|
+
slug_id = project.get("slugId", "")
|
|
281
|
+
if slug_id:
|
|
282
|
+
# slugId format: "crm-smart-monitoring-system-f59a41a96c52"
|
|
283
|
+
# Extract both the slug part and short ID
|
|
284
|
+
if "-" in slug_id:
|
|
285
|
+
parts = slug_id.rsplit(
|
|
286
|
+
"-", 1
|
|
287
|
+
) # Split from right to get last part
|
|
288
|
+
slug_part = parts[0] # "crm-smart-monitoring-system"
|
|
289
|
+
short_id = parts[1] if len(parts) > 1 else "" # "f59a41a96c52"
|
|
290
|
+
|
|
291
|
+
# Match full slugId, slug part, or short ID
|
|
292
|
+
if (
|
|
293
|
+
slug_id.lower() == project_lower
|
|
294
|
+
or slug_part.lower() == project_lower
|
|
295
|
+
or short_id.lower() == project_lower
|
|
296
|
+
):
|
|
297
|
+
return project["id"]
|
|
298
|
+
|
|
299
|
+
# Also check exact name match (case-insensitive)
|
|
300
|
+
if project["name"].lower() == project_lower:
|
|
301
|
+
return project["id"]
|
|
302
|
+
|
|
303
|
+
# No match found
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
except Exception as e:
|
|
307
|
+
raise ValueError(
|
|
308
|
+
f"Failed to resolve project '{project_identifier}': {e}"
|
|
309
|
+
) from e
|
|
310
|
+
|
|
311
|
+
async def _resolve_issue_id(self, issue_identifier: str) -> str | None:
|
|
312
|
+
"""Resolve issue identifier (like "ENG-842") to full UUID.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
issue_identifier: Issue identifier (e.g., "ENG-842") or UUID
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
Full Linear issue UUID, or None if not found
|
|
319
|
+
|
|
320
|
+
Raises:
|
|
321
|
+
ValueError: If issue lookup fails
|
|
322
|
+
|
|
323
|
+
Examples:
|
|
324
|
+
- "ENG-842" (issue identifier)
|
|
325
|
+
- "BTA-123" (issue identifier)
|
|
326
|
+
- "a1b2c3d4-e5f6-7890-abcd-ef1234567890" (already a UUID)
|
|
327
|
+
|
|
328
|
+
"""
|
|
329
|
+
if not issue_identifier:
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
# If it looks like a full UUID already (exactly 36 chars with exactly 4 dashes), return it
|
|
333
|
+
# UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
|
334
|
+
if len(issue_identifier) == 36 and issue_identifier.count("-") == 4:
|
|
335
|
+
return issue_identifier
|
|
336
|
+
|
|
337
|
+
# Query issue by identifier to get its UUID
|
|
338
|
+
query = """
|
|
339
|
+
query GetIssueId($identifier: String!) {
|
|
340
|
+
issue(id: $identifier) {
|
|
341
|
+
id
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
"""
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
result = await self.client.execute_query(
|
|
348
|
+
query, {"identifier": issue_identifier}
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if result.get("issue"):
|
|
352
|
+
return result["issue"]["id"]
|
|
353
|
+
|
|
354
|
+
# No match found
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
except Exception as e:
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f"Failed to resolve issue '{issue_identifier}': {e}"
|
|
360
|
+
) from e
|
|
194
361
|
|
|
195
362
|
async def _load_workflow_states(self, team_id: str) -> None:
|
|
196
363
|
"""Load and cache workflow states for the team.
|
|
@@ -205,7 +372,7 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
205
372
|
)
|
|
206
373
|
|
|
207
374
|
workflow_states = {}
|
|
208
|
-
for state in result["
|
|
375
|
+
for state in result["team"]["states"]["nodes"]:
|
|
209
376
|
state_type = state["type"].lower()
|
|
210
377
|
if state_type not in workflow_states:
|
|
211
378
|
workflow_states[state_type] = state
|
|
@@ -215,15 +382,17 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
215
382
|
self._workflow_states = workflow_states
|
|
216
383
|
|
|
217
384
|
except Exception as e:
|
|
218
|
-
raise ValueError(f"Failed to load workflow states: {e}")
|
|
385
|
+
raise ValueError(f"Failed to load workflow states: {e}") from e
|
|
219
386
|
|
|
220
387
|
async def _load_team_labels(self, team_id: str) -> None:
|
|
221
|
-
"""Load and cache labels for the team.
|
|
388
|
+
"""Load and cache labels for the team with retry logic.
|
|
222
389
|
|
|
223
390
|
Args:
|
|
224
391
|
team_id: Linear team ID
|
|
225
392
|
|
|
226
393
|
"""
|
|
394
|
+
logger = logging.getLogger(__name__)
|
|
395
|
+
|
|
227
396
|
query = """
|
|
228
397
|
query GetTeamLabels($teamId: String!) {
|
|
229
398
|
team(id: $teamId) {
|
|
@@ -239,15 +408,32 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
239
408
|
}
|
|
240
409
|
"""
|
|
241
410
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
411
|
+
max_retries = 3
|
|
412
|
+
for attempt in range(max_retries):
|
|
413
|
+
try:
|
|
414
|
+
result = await self.client.execute_query(query, {"teamId": team_id})
|
|
415
|
+
labels = result.get("team", {}).get("labels", {}).get("nodes", [])
|
|
416
|
+
self._labels_cache = labels
|
|
417
|
+
logger.info(f"Loaded {len(labels)} labels for team {team_id}")
|
|
418
|
+
return # Success
|
|
419
|
+
|
|
420
|
+
except Exception as e:
|
|
421
|
+
if attempt < max_retries - 1:
|
|
422
|
+
wait_time = 2**attempt
|
|
423
|
+
logger.warning(
|
|
424
|
+
f"Failed to load labels (attempt {attempt + 1}/{max_retries}): {e}. "
|
|
425
|
+
f"Retrying in {wait_time}s..."
|
|
426
|
+
)
|
|
427
|
+
await asyncio.sleep(wait_time)
|
|
428
|
+
else:
|
|
429
|
+
logger.error(
|
|
430
|
+
f"Failed to load team labels after {max_retries} attempts: {e}",
|
|
431
|
+
exc_info=True,
|
|
432
|
+
)
|
|
433
|
+
self._labels_cache = [] # Explicitly empty on failure
|
|
248
434
|
|
|
249
435
|
async def _resolve_label_ids(self, label_names: list[str]) -> list[str]:
|
|
250
|
-
"""Resolve label names to Linear label IDs.
|
|
436
|
+
"""Resolve label names to Linear label IDs with proper None vs empty list handling.
|
|
251
437
|
|
|
252
438
|
Args:
|
|
253
439
|
label_names: List of label names
|
|
@@ -256,16 +442,25 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
256
442
|
List of Linear label IDs that exist
|
|
257
443
|
|
|
258
444
|
"""
|
|
259
|
-
import logging
|
|
260
|
-
|
|
261
445
|
logger = logging.getLogger(__name__)
|
|
262
446
|
|
|
263
|
-
|
|
447
|
+
# None = not loaded yet, [] = loaded but empty or failed
|
|
448
|
+
if self._labels_cache is None:
|
|
264
449
|
team_id = await self._ensure_team_id()
|
|
265
450
|
await self._load_team_labels(team_id)
|
|
266
451
|
|
|
452
|
+
if self._labels_cache is None:
|
|
453
|
+
# Still None after load attempt - should not happen
|
|
454
|
+
logger.error(
|
|
455
|
+
"Label cache is None after load attempt. Tags will be skipped."
|
|
456
|
+
)
|
|
457
|
+
return []
|
|
458
|
+
|
|
267
459
|
if not self._labels_cache:
|
|
268
|
-
|
|
460
|
+
# Empty list - either no labels in team or load failed
|
|
461
|
+
logger.warning(
|
|
462
|
+
f"Team has no labels available. Cannot resolve tags: {label_names}"
|
|
463
|
+
)
|
|
269
464
|
return []
|
|
270
465
|
|
|
271
466
|
# Create name -> ID mapping (case-insensitive)
|
|
@@ -328,23 +523,48 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
328
523
|
return mapping
|
|
329
524
|
|
|
330
525
|
async def _get_user_id(self, user_identifier: str) -> str | None:
|
|
331
|
-
"""Get Linear user ID from email or
|
|
526
|
+
"""Get Linear user ID from email, display name, or user ID.
|
|
332
527
|
|
|
333
528
|
Args:
|
|
334
|
-
user_identifier: Email
|
|
529
|
+
user_identifier: Email, display name, or user ID
|
|
335
530
|
|
|
336
531
|
Returns:
|
|
337
532
|
Linear user ID or None if not found
|
|
338
533
|
|
|
339
534
|
"""
|
|
340
|
-
|
|
535
|
+
if not user_identifier:
|
|
536
|
+
return None
|
|
537
|
+
|
|
538
|
+
# Try email lookup first (most specific)
|
|
341
539
|
user = await self.client.get_user_by_email(user_identifier)
|
|
342
540
|
if user:
|
|
343
541
|
return user["id"]
|
|
344
542
|
|
|
345
|
-
#
|
|
346
|
-
|
|
347
|
-
|
|
543
|
+
# Try name search (displayName or full name)
|
|
544
|
+
users = await self.client.get_users_by_name(user_identifier)
|
|
545
|
+
if users:
|
|
546
|
+
if len(users) == 1:
|
|
547
|
+
# Exact match found
|
|
548
|
+
return users[0]["id"]
|
|
549
|
+
else:
|
|
550
|
+
# Multiple matches - try exact match
|
|
551
|
+
for u in users:
|
|
552
|
+
if (
|
|
553
|
+
u.get("displayName", "").lower() == user_identifier.lower()
|
|
554
|
+
or u.get("name", "").lower() == user_identifier.lower()
|
|
555
|
+
):
|
|
556
|
+
return u["id"]
|
|
557
|
+
|
|
558
|
+
# No exact match - log ambiguity and return first
|
|
559
|
+
logging.getLogger(__name__).warning(
|
|
560
|
+
f"Multiple users match '{user_identifier}': "
|
|
561
|
+
f"{[u.get('displayName', u.get('name')) for u in users]}. "
|
|
562
|
+
f"Using first match: {users[0].get('displayName')}"
|
|
563
|
+
)
|
|
564
|
+
return users[0]["id"]
|
|
565
|
+
|
|
566
|
+
# Assume it's already a user ID
|
|
567
|
+
return user_identifier
|
|
348
568
|
|
|
349
569
|
# CRUD Operations
|
|
350
570
|
|
|
@@ -377,7 +597,13 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
377
597
|
return await self._create_task(ticket)
|
|
378
598
|
|
|
379
599
|
async def _create_task(self, task: Task) -> Task:
|
|
380
|
-
"""Create a Linear issue from a Task.
|
|
600
|
+
"""Create a Linear issue or sub-issue from a Task.
|
|
601
|
+
|
|
602
|
+
Creates a top-level issue when task.parent_issue is not set, or a
|
|
603
|
+
sub-issue (child of another issue) when task.parent_issue is provided.
|
|
604
|
+
In Linear terminology:
|
|
605
|
+
- Issue: Top-level work item (no parent)
|
|
606
|
+
- Sub-issue: Child work item (has parent issue)
|
|
381
607
|
|
|
382
608
|
Args:
|
|
383
609
|
task: Task to create
|
|
@@ -413,19 +639,50 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
413
639
|
# Remove labelIds if no labels resolved
|
|
414
640
|
issue_input.pop("labelIds", None)
|
|
415
641
|
|
|
642
|
+
# Resolve project ID if parent_epic is provided (supports slug, name, short ID, or URL)
|
|
643
|
+
if task.parent_epic:
|
|
644
|
+
project_id = await self._resolve_project_id(task.parent_epic)
|
|
645
|
+
if project_id:
|
|
646
|
+
issue_input["projectId"] = project_id
|
|
647
|
+
else:
|
|
648
|
+
# Log warning but don't fail - user may have provided invalid project
|
|
649
|
+
logging.getLogger(__name__).warning(
|
|
650
|
+
f"Could not resolve project identifier '{task.parent_epic}' to UUID. "
|
|
651
|
+
"Issue will be created without project assignment."
|
|
652
|
+
)
|
|
653
|
+
# Remove projectId if we couldn't resolve it
|
|
654
|
+
issue_input.pop("projectId", None)
|
|
655
|
+
|
|
656
|
+
# Resolve parent issue ID if provided (creates a sub-issue when parent is set)
|
|
657
|
+
# Supports identifiers like "ENG-842" or UUIDs
|
|
658
|
+
if task.parent_issue:
|
|
659
|
+
issue_id = await self._resolve_issue_id(task.parent_issue)
|
|
660
|
+
if issue_id:
|
|
661
|
+
issue_input["parentId"] = issue_id
|
|
662
|
+
else:
|
|
663
|
+
# Log warning but don't fail - user may have provided invalid issue
|
|
664
|
+
logging.getLogger(__name__).warning(
|
|
665
|
+
f"Could not resolve issue identifier '{task.parent_issue}' to UUID. "
|
|
666
|
+
"Sub-issue will be created without parent assignment."
|
|
667
|
+
)
|
|
668
|
+
# Remove parentId if we couldn't resolve it
|
|
669
|
+
issue_input.pop("parentId", None)
|
|
670
|
+
|
|
416
671
|
try:
|
|
417
672
|
result = await self.client.execute_mutation(
|
|
418
673
|
CREATE_ISSUE_MUTATION, {"input": issue_input}
|
|
419
674
|
)
|
|
420
675
|
|
|
421
676
|
if not result["issueCreate"]["success"]:
|
|
422
|
-
|
|
677
|
+
item_type = "sub-issue" if task.parent_issue else "issue"
|
|
678
|
+
raise ValueError(f"Failed to create Linear {item_type}")
|
|
423
679
|
|
|
424
680
|
created_issue = result["issueCreate"]["issue"]
|
|
425
681
|
return map_linear_issue_to_task(created_issue)
|
|
426
682
|
|
|
427
683
|
except Exception as e:
|
|
428
|
-
|
|
684
|
+
item_type = "sub-issue" if task.parent_issue else "issue"
|
|
685
|
+
raise ValueError(f"Failed to create Linear {item_type}: {e}") from e
|
|
429
686
|
|
|
430
687
|
async def _create_epic(self, epic: Epic) -> Epic:
|
|
431
688
|
"""Create a Linear project from an Epic.
|
|
@@ -490,7 +747,98 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
490
747
|
return map_linear_project_to_epic(created_project)
|
|
491
748
|
|
|
492
749
|
except Exception as e:
|
|
493
|
-
raise ValueError(f"Failed to create Linear project: {e}")
|
|
750
|
+
raise ValueError(f"Failed to create Linear project: {e}") from e
|
|
751
|
+
|
|
752
|
+
async def update_epic(self, epic_id: str, updates: dict[str, Any]) -> Epic | None:
|
|
753
|
+
"""Update a Linear project (Epic) with specified fields.
|
|
754
|
+
|
|
755
|
+
Args:
|
|
756
|
+
epic_id: Linear project UUID or slug-shortid
|
|
757
|
+
updates: Dictionary of fields to update. Supported fields:
|
|
758
|
+
- title: Project name
|
|
759
|
+
- description: Project description
|
|
760
|
+
- state: Project state (e.g., "planned", "started", "completed", "canceled")
|
|
761
|
+
- target_date: Target completion date (ISO format YYYY-MM-DD)
|
|
762
|
+
- color: Project color
|
|
763
|
+
- icon: Project icon
|
|
764
|
+
|
|
765
|
+
Returns:
|
|
766
|
+
Updated Epic object or None if not found
|
|
767
|
+
|
|
768
|
+
Raises:
|
|
769
|
+
ValueError: If update fails or project not found
|
|
770
|
+
|
|
771
|
+
"""
|
|
772
|
+
# Validate credentials before attempting operation
|
|
773
|
+
is_valid, error_message = self.validate_credentials()
|
|
774
|
+
if not is_valid:
|
|
775
|
+
raise ValueError(error_message)
|
|
776
|
+
|
|
777
|
+
# Resolve project identifier to UUID if needed
|
|
778
|
+
project_uuid = await self._resolve_project_id(epic_id)
|
|
779
|
+
if not project_uuid:
|
|
780
|
+
raise ValueError(f"Project '{epic_id}' not found")
|
|
781
|
+
|
|
782
|
+
# Build update input from updates dict
|
|
783
|
+
update_input = {}
|
|
784
|
+
|
|
785
|
+
if "title" in updates:
|
|
786
|
+
update_input["name"] = updates["title"]
|
|
787
|
+
if "description" in updates:
|
|
788
|
+
update_input["description"] = updates["description"]
|
|
789
|
+
if "state" in updates:
|
|
790
|
+
update_input["state"] = updates["state"]
|
|
791
|
+
if "target_date" in updates:
|
|
792
|
+
update_input["targetDate"] = updates["target_date"]
|
|
793
|
+
if "color" in updates:
|
|
794
|
+
update_input["color"] = updates["color"]
|
|
795
|
+
if "icon" in updates:
|
|
796
|
+
update_input["icon"] = updates["icon"]
|
|
797
|
+
|
|
798
|
+
# ProjectUpdate mutation
|
|
799
|
+
update_query = """
|
|
800
|
+
mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) {
|
|
801
|
+
projectUpdate(id: $id, input: $input) {
|
|
802
|
+
success
|
|
803
|
+
project {
|
|
804
|
+
id
|
|
805
|
+
name
|
|
806
|
+
description
|
|
807
|
+
state
|
|
808
|
+
createdAt
|
|
809
|
+
updatedAt
|
|
810
|
+
url
|
|
811
|
+
icon
|
|
812
|
+
color
|
|
813
|
+
targetDate
|
|
814
|
+
startedAt
|
|
815
|
+
completedAt
|
|
816
|
+
teams {
|
|
817
|
+
nodes {
|
|
818
|
+
id
|
|
819
|
+
name
|
|
820
|
+
key
|
|
821
|
+
description
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
"""
|
|
828
|
+
|
|
829
|
+
try:
|
|
830
|
+
result = await self.client.execute_mutation(
|
|
831
|
+
update_query, {"id": project_uuid, "input": update_input}
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
if not result["projectUpdate"]["success"]:
|
|
835
|
+
raise ValueError(f"Failed to update Linear project '{epic_id}'")
|
|
836
|
+
|
|
837
|
+
updated_project = result["projectUpdate"]["project"]
|
|
838
|
+
return map_linear_project_to_epic(updated_project)
|
|
839
|
+
|
|
840
|
+
except Exception as e:
|
|
841
|
+
raise ValueError(f"Failed to update Linear project: {e}") from e
|
|
494
842
|
|
|
495
843
|
async def read(self, ticket_id: str) -> Task | None:
|
|
496
844
|
"""Read a Linear issue by identifier with full details.
|
|
@@ -586,10 +934,23 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
586
934
|
update_input["assigneeId"] = user_id
|
|
587
935
|
|
|
588
936
|
# Resolve label names to IDs if provided
|
|
589
|
-
if "tags" in updates
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
937
|
+
if "tags" in updates:
|
|
938
|
+
if updates["tags"]: # Non-empty list
|
|
939
|
+
label_ids = await self._resolve_label_ids(updates["tags"])
|
|
940
|
+
if label_ids:
|
|
941
|
+
update_input["labelIds"] = label_ids
|
|
942
|
+
else: # Empty list = remove all labels
|
|
943
|
+
update_input["labelIds"] = []
|
|
944
|
+
|
|
945
|
+
# Resolve project ID if parent_epic is provided (supports slug, name, short ID, or URL)
|
|
946
|
+
if "parent_epic" in updates and updates["parent_epic"]:
|
|
947
|
+
project_id = await self._resolve_project_id(updates["parent_epic"])
|
|
948
|
+
if project_id:
|
|
949
|
+
update_input["projectId"] = project_id
|
|
950
|
+
else:
|
|
951
|
+
logging.getLogger(__name__).warning(
|
|
952
|
+
f"Could not resolve project identifier '{updates['parent_epic']}'"
|
|
953
|
+
)
|
|
593
954
|
|
|
594
955
|
# Execute update
|
|
595
956
|
result = await self.client.execute_mutation(
|
|
@@ -603,7 +964,7 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
603
964
|
return map_linear_issue_to_task(updated_issue)
|
|
604
965
|
|
|
605
966
|
except Exception as e:
|
|
606
|
-
raise ValueError(f"Failed to update Linear issue: {e}")
|
|
967
|
+
raise ValueError(f"Failed to update Linear issue: {e}") from e
|
|
607
968
|
|
|
608
969
|
async def delete(self, ticket_id: str) -> bool:
|
|
609
970
|
"""Delete a Linear issue (archive it).
|
|
@@ -680,7 +1041,7 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
680
1041
|
return tasks
|
|
681
1042
|
|
|
682
1043
|
except Exception as e:
|
|
683
|
-
raise ValueError(f"Failed to list Linear issues: {e}")
|
|
1044
|
+
raise ValueError(f"Failed to list Linear issues: {e}") from e
|
|
684
1045
|
|
|
685
1046
|
async def search(self, query: SearchQuery) -> builtins.list[Task]:
|
|
686
1047
|
"""Search Linear issues using comprehensive filters.
|
|
@@ -744,7 +1105,7 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
744
1105
|
return tasks
|
|
745
1106
|
|
|
746
1107
|
except Exception as e:
|
|
747
|
-
raise ValueError(f"Failed to search Linear issues: {e}")
|
|
1108
|
+
raise ValueError(f"Failed to search Linear issues: {e}") from e
|
|
748
1109
|
|
|
749
1110
|
async def transition_state(
|
|
750
1111
|
self, ticket_id: str, target_state: TicketState
|
|
@@ -835,7 +1196,7 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
835
1196
|
|
|
836
1197
|
comment_input = {
|
|
837
1198
|
"issueId": linear_id,
|
|
838
|
-
"body": comment.
|
|
1199
|
+
"body": comment.content,
|
|
839
1200
|
}
|
|
840
1201
|
|
|
841
1202
|
result = await self.client.execute_mutation(
|
|
@@ -849,7 +1210,7 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
849
1210
|
return map_linear_comment_to_comment(created_comment, comment.ticket_id)
|
|
850
1211
|
|
|
851
1212
|
except Exception as e:
|
|
852
|
-
raise ValueError(f"Failed to add comment: {e}")
|
|
1213
|
+
raise ValueError(f"Failed to add comment: {e}") from e
|
|
853
1214
|
|
|
854
1215
|
async def get_comments(
|
|
855
1216
|
self, ticket_id: str, limit: int = 10, offset: int = 0
|
|
@@ -907,6 +1268,299 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
907
1268
|
except Exception:
|
|
908
1269
|
return []
|
|
909
1270
|
|
|
1271
|
+
async def list_labels(self) -> builtins.list[dict[str, Any]]:
|
|
1272
|
+
"""List all labels available in the Linear team.
|
|
1273
|
+
|
|
1274
|
+
Returns:
|
|
1275
|
+
List of label dictionaries with 'id', 'name', and 'color' fields
|
|
1276
|
+
|
|
1277
|
+
"""
|
|
1278
|
+
# Ensure labels are loaded
|
|
1279
|
+
if self._labels_cache is None:
|
|
1280
|
+
team_id = await self._ensure_team_id()
|
|
1281
|
+
await self._load_team_labels(team_id)
|
|
1282
|
+
|
|
1283
|
+
# Return cached labels or empty list if not available
|
|
1284
|
+
if not self._labels_cache:
|
|
1285
|
+
return []
|
|
1286
|
+
|
|
1287
|
+
# Transform to standardized format
|
|
1288
|
+
return [
|
|
1289
|
+
{
|
|
1290
|
+
"id": label["id"],
|
|
1291
|
+
"name": label["name"],
|
|
1292
|
+
"color": label.get("color", ""),
|
|
1293
|
+
}
|
|
1294
|
+
for label in self._labels_cache
|
|
1295
|
+
]
|
|
1296
|
+
|
|
1297
|
+
async def upload_file(self, file_path: str, mime_type: str | None = None) -> str:
|
|
1298
|
+
"""Upload a file to Linear's storage and return the asset URL.
|
|
1299
|
+
|
|
1300
|
+
This method implements Linear's three-step file upload process:
|
|
1301
|
+
1. Request a pre-signed upload URL via fileUpload mutation
|
|
1302
|
+
2. Upload the file to S3 using the pre-signed URL
|
|
1303
|
+
3. Return the asset URL for use in attachments
|
|
1304
|
+
|
|
1305
|
+
Args:
|
|
1306
|
+
file_path: Path to the file to upload
|
|
1307
|
+
mime_type: MIME type of the file. If None, will be auto-detected.
|
|
1308
|
+
|
|
1309
|
+
Returns:
|
|
1310
|
+
Asset URL that can be used with attachmentCreate mutation
|
|
1311
|
+
|
|
1312
|
+
Raises:
|
|
1313
|
+
ValueError: If file doesn't exist, upload fails, or httpx not available
|
|
1314
|
+
FileNotFoundError: If the specified file doesn't exist
|
|
1315
|
+
|
|
1316
|
+
"""
|
|
1317
|
+
if httpx is None:
|
|
1318
|
+
raise ValueError(
|
|
1319
|
+
"httpx library not installed. Install with: pip install httpx"
|
|
1320
|
+
)
|
|
1321
|
+
|
|
1322
|
+
# Validate file exists
|
|
1323
|
+
file_path_obj = Path(file_path)
|
|
1324
|
+
if not file_path_obj.exists():
|
|
1325
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
1326
|
+
if not file_path_obj.is_file():
|
|
1327
|
+
raise ValueError(f"Path is not a file: {file_path}")
|
|
1328
|
+
|
|
1329
|
+
# Get file info
|
|
1330
|
+
file_size = file_path_obj.stat().st_size
|
|
1331
|
+
filename = file_path_obj.name
|
|
1332
|
+
|
|
1333
|
+
# Auto-detect MIME type if not provided
|
|
1334
|
+
if mime_type is None:
|
|
1335
|
+
mime_type, _ = mimetypes.guess_type(file_path)
|
|
1336
|
+
if mime_type is None:
|
|
1337
|
+
# Default to binary if can't detect
|
|
1338
|
+
mime_type = "application/octet-stream"
|
|
1339
|
+
|
|
1340
|
+
# Step 1: Request pre-signed upload URL
|
|
1341
|
+
upload_mutation = """
|
|
1342
|
+
mutation FileUpload($contentType: String!, $filename: String!, $size: Int!) {
|
|
1343
|
+
fileUpload(contentType: $contentType, filename: $filename, size: $size) {
|
|
1344
|
+
success
|
|
1345
|
+
uploadFile {
|
|
1346
|
+
uploadUrl
|
|
1347
|
+
assetUrl
|
|
1348
|
+
headers {
|
|
1349
|
+
key
|
|
1350
|
+
value
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
"""
|
|
1356
|
+
|
|
1357
|
+
try:
|
|
1358
|
+
result = await self.client.execute_mutation(
|
|
1359
|
+
upload_mutation,
|
|
1360
|
+
{
|
|
1361
|
+
"contentType": mime_type,
|
|
1362
|
+
"filename": filename,
|
|
1363
|
+
"size": file_size,
|
|
1364
|
+
},
|
|
1365
|
+
)
|
|
1366
|
+
|
|
1367
|
+
if not result["fileUpload"]["success"]:
|
|
1368
|
+
raise ValueError("Failed to get upload URL from Linear API")
|
|
1369
|
+
|
|
1370
|
+
upload_file_data = result["fileUpload"]["uploadFile"]
|
|
1371
|
+
upload_url = upload_file_data["uploadUrl"]
|
|
1372
|
+
asset_url = upload_file_data["assetUrl"]
|
|
1373
|
+
headers_list = upload_file_data.get("headers", [])
|
|
1374
|
+
|
|
1375
|
+
# Convert headers list to dict
|
|
1376
|
+
upload_headers = {h["key"]: h["value"] for h in headers_list}
|
|
1377
|
+
# Add Content-Type header
|
|
1378
|
+
upload_headers["Content-Type"] = mime_type
|
|
1379
|
+
|
|
1380
|
+
# Step 2: Upload file to S3 using pre-signed URL
|
|
1381
|
+
async with httpx.AsyncClient() as http_client:
|
|
1382
|
+
with open(file_path, "rb") as f:
|
|
1383
|
+
file_content = f.read()
|
|
1384
|
+
|
|
1385
|
+
response = await http_client.put(
|
|
1386
|
+
upload_url,
|
|
1387
|
+
content=file_content,
|
|
1388
|
+
headers=upload_headers,
|
|
1389
|
+
timeout=60.0, # 60 second timeout for large files
|
|
1390
|
+
)
|
|
1391
|
+
|
|
1392
|
+
if response.status_code not in (200, 201, 204):
|
|
1393
|
+
raise ValueError(
|
|
1394
|
+
f"Failed to upload file to S3. Status: {response.status_code}, "
|
|
1395
|
+
f"Response: {response.text}"
|
|
1396
|
+
)
|
|
1397
|
+
|
|
1398
|
+
# Step 3: Return asset URL
|
|
1399
|
+
logging.getLogger(__name__).info(
|
|
1400
|
+
f"Successfully uploaded file '{filename}' ({file_size} bytes) to Linear"
|
|
1401
|
+
)
|
|
1402
|
+
return asset_url
|
|
1403
|
+
|
|
1404
|
+
except Exception as e:
|
|
1405
|
+
raise ValueError(f"Failed to upload file '{filename}': {e}") from e
|
|
1406
|
+
|
|
1407
|
+
async def attach_file_to_issue(
|
|
1408
|
+
self,
|
|
1409
|
+
issue_id: str,
|
|
1410
|
+
file_url: str,
|
|
1411
|
+
title: str,
|
|
1412
|
+
subtitle: str | None = None,
|
|
1413
|
+
comment_body: str | None = None,
|
|
1414
|
+
) -> dict[str, Any]:
|
|
1415
|
+
"""Attach a file to a Linear issue.
|
|
1416
|
+
|
|
1417
|
+
The file must already be uploaded using upload_file() or be a publicly
|
|
1418
|
+
accessible URL.
|
|
1419
|
+
|
|
1420
|
+
Args:
|
|
1421
|
+
issue_id: Linear issue identifier (e.g., "ENG-842") or UUID
|
|
1422
|
+
file_url: URL of the file (from upload_file() or external URL)
|
|
1423
|
+
title: Title for the attachment
|
|
1424
|
+
subtitle: Optional subtitle for the attachment
|
|
1425
|
+
comment_body: Optional comment text to include with the attachment
|
|
1426
|
+
|
|
1427
|
+
Returns:
|
|
1428
|
+
Dictionary with attachment details including id, title, url, etc.
|
|
1429
|
+
|
|
1430
|
+
Raises:
|
|
1431
|
+
ValueError: If attachment creation fails or issue not found
|
|
1432
|
+
|
|
1433
|
+
"""
|
|
1434
|
+
# Resolve issue identifier to UUID
|
|
1435
|
+
issue_uuid = await self._resolve_issue_id(issue_id)
|
|
1436
|
+
if not issue_uuid:
|
|
1437
|
+
raise ValueError(f"Issue '{issue_id}' not found")
|
|
1438
|
+
|
|
1439
|
+
# Build attachment input
|
|
1440
|
+
attachment_input: dict[str, Any] = {
|
|
1441
|
+
"issueId": issue_uuid,
|
|
1442
|
+
"title": title,
|
|
1443
|
+
"url": file_url,
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
if subtitle:
|
|
1447
|
+
attachment_input["subtitle"] = subtitle
|
|
1448
|
+
|
|
1449
|
+
if comment_body:
|
|
1450
|
+
attachment_input["commentBody"] = comment_body
|
|
1451
|
+
|
|
1452
|
+
# Create attachment mutation
|
|
1453
|
+
attachment_mutation = """
|
|
1454
|
+
mutation AttachmentCreate($input: AttachmentCreateInput!) {
|
|
1455
|
+
attachmentCreate(input: $input) {
|
|
1456
|
+
success
|
|
1457
|
+
attachment {
|
|
1458
|
+
id
|
|
1459
|
+
title
|
|
1460
|
+
url
|
|
1461
|
+
subtitle
|
|
1462
|
+
metadata
|
|
1463
|
+
createdAt
|
|
1464
|
+
updatedAt
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
"""
|
|
1469
|
+
|
|
1470
|
+
try:
|
|
1471
|
+
result = await self.client.execute_mutation(
|
|
1472
|
+
attachment_mutation, {"input": attachment_input}
|
|
1473
|
+
)
|
|
1474
|
+
|
|
1475
|
+
if not result["attachmentCreate"]["success"]:
|
|
1476
|
+
raise ValueError(f"Failed to attach file to issue '{issue_id}'")
|
|
1477
|
+
|
|
1478
|
+
attachment = result["attachmentCreate"]["attachment"]
|
|
1479
|
+
logging.getLogger(__name__).info(
|
|
1480
|
+
f"Successfully attached file '{title}' to issue '{issue_id}'"
|
|
1481
|
+
)
|
|
1482
|
+
return attachment
|
|
1483
|
+
|
|
1484
|
+
except Exception as e:
|
|
1485
|
+
raise ValueError(f"Failed to attach file to issue '{issue_id}': {e}") from e
|
|
1486
|
+
|
|
1487
|
+
async def attach_file_to_epic(
|
|
1488
|
+
self,
|
|
1489
|
+
epic_id: str,
|
|
1490
|
+
file_url: str,
|
|
1491
|
+
title: str,
|
|
1492
|
+
subtitle: str | None = None,
|
|
1493
|
+
) -> dict[str, Any]:
|
|
1494
|
+
"""Attach a file to a Linear project (Epic).
|
|
1495
|
+
|
|
1496
|
+
The file must already be uploaded using upload_file() or be a publicly
|
|
1497
|
+
accessible URL.
|
|
1498
|
+
|
|
1499
|
+
Args:
|
|
1500
|
+
epic_id: Linear project UUID or slug-shortid
|
|
1501
|
+
file_url: URL of the file (from upload_file() or external URL)
|
|
1502
|
+
title: Title for the attachment
|
|
1503
|
+
subtitle: Optional subtitle for the attachment
|
|
1504
|
+
|
|
1505
|
+
Returns:
|
|
1506
|
+
Dictionary with attachment details including id, title, url, etc.
|
|
1507
|
+
|
|
1508
|
+
Raises:
|
|
1509
|
+
ValueError: If attachment creation fails or project not found
|
|
1510
|
+
|
|
1511
|
+
"""
|
|
1512
|
+
# Resolve project identifier to UUID
|
|
1513
|
+
project_uuid = await self._resolve_project_id(epic_id)
|
|
1514
|
+
if not project_uuid:
|
|
1515
|
+
raise ValueError(f"Project '{epic_id}' not found")
|
|
1516
|
+
|
|
1517
|
+
# Build attachment input (use projectId instead of issueId)
|
|
1518
|
+
attachment_input: dict[str, Any] = {
|
|
1519
|
+
"projectId": project_uuid,
|
|
1520
|
+
"title": title,
|
|
1521
|
+
"url": file_url,
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
if subtitle:
|
|
1525
|
+
attachment_input["subtitle"] = subtitle
|
|
1526
|
+
|
|
1527
|
+
# Create attachment mutation (same as for issues)
|
|
1528
|
+
attachment_mutation = """
|
|
1529
|
+
mutation AttachmentCreate($input: AttachmentCreateInput!) {
|
|
1530
|
+
attachmentCreate(input: $input) {
|
|
1531
|
+
success
|
|
1532
|
+
attachment {
|
|
1533
|
+
id
|
|
1534
|
+
title
|
|
1535
|
+
url
|
|
1536
|
+
subtitle
|
|
1537
|
+
metadata
|
|
1538
|
+
createdAt
|
|
1539
|
+
updatedAt
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
"""
|
|
1544
|
+
|
|
1545
|
+
try:
|
|
1546
|
+
result = await self.client.execute_mutation(
|
|
1547
|
+
attachment_mutation, {"input": attachment_input}
|
|
1548
|
+
)
|
|
1549
|
+
|
|
1550
|
+
if not result["attachmentCreate"]["success"]:
|
|
1551
|
+
raise ValueError(f"Failed to attach file to project '{epic_id}'")
|
|
1552
|
+
|
|
1553
|
+
attachment = result["attachmentCreate"]["attachment"]
|
|
1554
|
+
logging.getLogger(__name__).info(
|
|
1555
|
+
f"Successfully attached file '{title}' to project '{epic_id}'"
|
|
1556
|
+
)
|
|
1557
|
+
return attachment
|
|
1558
|
+
|
|
1559
|
+
except Exception as e:
|
|
1560
|
+
raise ValueError(
|
|
1561
|
+
f"Failed to attach file to project '{epic_id}': {e}"
|
|
1562
|
+
) from e
|
|
1563
|
+
|
|
910
1564
|
async def close(self) -> None:
|
|
911
1565
|
"""Close the adapter and clean up resources."""
|
|
912
1566
|
await self.client.close()
|