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,1308 @@
1
+ """Main AsanaAdapter class for Asana REST API integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ import logging
7
+ import mimetypes
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from ...core.adapter import BaseAdapter
15
+ from ...core.models import (
16
+ Attachment,
17
+ Comment,
18
+ Epic,
19
+ SearchQuery,
20
+ Task,
21
+ TicketState,
22
+ TicketType,
23
+ )
24
+ from ...core.registry import AdapterRegistry
25
+ from .client import AsanaClient
26
+ from .mappers import (
27
+ map_asana_attachment_to_attachment,
28
+ map_asana_project_to_epic,
29
+ map_asana_story_to_comment,
30
+ map_asana_task_to_task,
31
+ map_epic_to_asana_project,
32
+ map_task_to_asana_task,
33
+ )
34
+ from .types import map_state_to_asana
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class AsanaAdapter(BaseAdapter[Task]):
40
+ """Adapter for Asana task management using REST API v1.0.
41
+
42
+ This adapter provides comprehensive integration with Asana's REST API,
43
+ supporting all major ticket management operations including:
44
+
45
+ - CRUD operations for projects (epics) and tasks
46
+ - Epic/Issue/Task hierarchy support
47
+ - State transitions via completed field
48
+ - User assignment and tag management
49
+ - Comment management (filtering stories by type)
50
+ - Attachment support (using permanent_url)
51
+
52
+ Hierarchy Mapping:
53
+ - Epic → Asana Project
54
+ - Issue → Asana Task (in project, no parent task)
55
+ - Task → Asana Subtask (has parent task)
56
+ """
57
+
58
+ def __init__(self, config: dict[str, Any]):
59
+ """Initialize Asana adapter.
60
+
61
+ Args:
62
+ config: Configuration with:
63
+ - api_key: Asana Personal Access Token (or ASANA_PAT env var)
64
+ - workspace: Asana workspace name (optional, for resolution)
65
+ - workspace_gid: Asana workspace GID (optional, will be auto-resolved)
66
+ - default_project_gid: Default project for tasks (optional)
67
+ - timeout: Request timeout in seconds (default: 30)
68
+ - max_retries: Maximum retry attempts (default: 3)
69
+
70
+ Raises:
71
+ ValueError: If required configuration is missing
72
+
73
+ """
74
+ # Initialize instance variables before super().__init__
75
+ self._workspace_gid: str | None = None
76
+ self._team_gid: str | None = None
77
+ self._default_project_gid: str | None = None
78
+ self._priority_field_gid: str | None = None
79
+ self._project_custom_fields_cache: dict[str, dict[str, dict]] = (
80
+ {}
81
+ ) # Map project_gid -> {field_name: field_data}
82
+ self._initialized = False
83
+
84
+ super().__init__(config)
85
+
86
+ # Extract API key from config or environment
87
+ self.api_key = (
88
+ config.get("api_key")
89
+ or os.getenv("ASANA_PAT")
90
+ or os.getenv("ASANA_API_KEY")
91
+ )
92
+ if not self.api_key:
93
+ raise ValueError("Asana API key is required (api_key or ASANA_PAT env var)")
94
+
95
+ # Clean API key - remove common prefixes
96
+ if isinstance(self.api_key, str):
97
+ # Remove environment variable name prefix (e.g., "ASANA_PAT=")
98
+ if "=" in self.api_key:
99
+ parts = self.api_key.split("=", 1)
100
+ if len(parts) == 2 and parts[0].upper() in (
101
+ "ASANA_PAT",
102
+ "ASANA_API_KEY",
103
+ "API_KEY",
104
+ ):
105
+ self.api_key = parts[1]
106
+
107
+ # Optional configuration
108
+ self.workspace_name = config.get("workspace", "")
109
+ self._workspace_gid = config.get("workspace_gid")
110
+ self._default_project_gid = config.get("default_project_gid")
111
+ timeout = config.get("timeout", 30)
112
+ max_retries = config.get("max_retries", 3)
113
+
114
+ # Initialize client
115
+ self.client = AsanaClient(
116
+ self.api_key, timeout=timeout, max_retries=max_retries
117
+ )
118
+
119
+ def validate_credentials(self) -> tuple[bool, str]:
120
+ """Validate Asana API credentials.
121
+
122
+ Returns:
123
+ Tuple of (is_valid, error_message)
124
+
125
+ """
126
+ if not self.api_key:
127
+ return False, "Asana API key is required"
128
+
129
+ return True, ""
130
+
131
+ async def initialize(self) -> None:
132
+ """Initialize adapter by resolving workspace and loading custom fields."""
133
+ if self._initialized:
134
+ return
135
+
136
+ try:
137
+ # Test connection first
138
+ if not await self.client.test_connection():
139
+ raise ValueError("Failed to connect to Asana API - check credentials")
140
+
141
+ # Resolve workspace GID if not provided
142
+ if not self._workspace_gid:
143
+ await self._resolve_workspace()
144
+
145
+ # Resolve team (required for creating projects)
146
+ await self._resolve_team()
147
+
148
+ # Load custom fields for priority (if exists)
149
+ await self._load_custom_fields()
150
+
151
+ self._initialized = True
152
+ logger.info(
153
+ f"Asana adapter initialized with workspace GID: {self._workspace_gid}, team GID: {self._team_gid}"
154
+ )
155
+
156
+ except Exception as e:
157
+ raise ValueError(f"Failed to initialize Asana adapter: {e}") from e
158
+
159
+ async def _resolve_workspace(self) -> None:
160
+ """Resolve workspace GID from workspace name or get default workspace."""
161
+ try:
162
+ # Get all workspaces for the user
163
+ workspaces = await self.client.get("/workspaces")
164
+
165
+ if not workspaces:
166
+ raise ValueError("No workspaces found for this user")
167
+
168
+ # If workspace name provided, find matching workspace
169
+ if self.workspace_name:
170
+ for ws in workspaces:
171
+ if ws.get("name", "").lower() == self.workspace_name.lower():
172
+ self._workspace_gid = ws["gid"]
173
+ logger.info(
174
+ f"Resolved workspace '{self.workspace_name}' to GID: {self._workspace_gid}"
175
+ )
176
+ return
177
+
178
+ raise ValueError(f"Workspace '{self.workspace_name}' not found")
179
+
180
+ # Use first workspace as default
181
+ self._workspace_gid = workspaces[0]["gid"]
182
+ logger.info(
183
+ f"Using default workspace: {workspaces[0].get('name')} (GID: {self._workspace_gid})"
184
+ )
185
+
186
+ except Exception as e:
187
+ raise ValueError(f"Failed to resolve workspace: {e}") from e
188
+
189
+ async def _resolve_team(self) -> None:
190
+ """Resolve team GID from workspace.
191
+
192
+ Asana requires a team for creating projects. We'll get the first team
193
+ from the workspace or use None for personal workspace.
194
+ """
195
+ if not self._workspace_gid:
196
+ return
197
+
198
+ try:
199
+ # Get teams for workspace
200
+ teams = await self.client.get_paginated(
201
+ f"/organizations/{self._workspace_gid}/teams", limit=1
202
+ )
203
+
204
+ if teams:
205
+ self._team_gid = teams[0]["gid"]
206
+ logger.info(
207
+ f"Resolved team: {teams[0].get('name')} (GID: {self._team_gid})"
208
+ )
209
+ else:
210
+ # No teams - personal workspace (team field optional for personal workspaces)
211
+ logger.info("No teams found - using personal workspace")
212
+ self._team_gid = None
213
+
214
+ except Exception as e:
215
+ # Fallback: team might not be required for personal workspaces
216
+ logger.warning(f"Failed to resolve team (may be personal workspace): {e}")
217
+ self._team_gid = None
218
+
219
+ async def _load_custom_fields(self) -> None:
220
+ """Load custom fields for the workspace (specifically Priority field)."""
221
+ if not self._workspace_gid:
222
+ return
223
+
224
+ try:
225
+ # Get custom fields for workspace
226
+ custom_fields = await self.client.get_paginated(
227
+ f"/workspaces/{self._workspace_gid}/custom_fields"
228
+ )
229
+
230
+ # Find priority field
231
+ for field in custom_fields:
232
+ if field.get("name", "").lower() == "priority":
233
+ self._priority_field_gid = field["gid"]
234
+ logger.info(
235
+ f"Found Priority custom field: {self._priority_field_gid}"
236
+ )
237
+ break
238
+
239
+ except Exception as e:
240
+ logger.warning(f"Failed to load custom fields: {e}")
241
+ # Don't fail initialization - priority will be stored in tags if needed
242
+
243
+ async def _load_project_custom_fields(self, project_gid: str) -> dict[str, dict]:
244
+ """Load custom fields configured for a specific project.
245
+
246
+ Args:
247
+ project_gid: Project GID to load custom fields for
248
+
249
+ Returns:
250
+ Dictionary mapping field name (lowercase) to field data
251
+
252
+ """
253
+ try:
254
+ project = await self.client.get(
255
+ f"/projects/{project_gid}",
256
+ params={"opt_fields": "custom_field_settings.custom_field"},
257
+ )
258
+
259
+ fields = {}
260
+ for setting in project.get("custom_field_settings", []):
261
+ field = setting.get("custom_field", {})
262
+ if field:
263
+ field_name = field.get("name", "").lower()
264
+ fields[field_name] = {
265
+ "gid": field["gid"],
266
+ "name": field["name"],
267
+ "resource_subtype": field.get("resource_subtype"),
268
+ "enum_options": field.get("enum_options", []),
269
+ }
270
+
271
+ return fields
272
+ except Exception as e:
273
+ logger.warning(f"Failed to load project custom fields: {e}")
274
+ return {}
275
+
276
+ async def _get_project_custom_fields(self, project_gid: str) -> dict[str, dict]:
277
+ """Get custom fields for a project, loading if not cached.
278
+
279
+ Args:
280
+ project_gid: Project GID
281
+
282
+ Returns:
283
+ Dictionary mapping field name (lowercase) to field data
284
+
285
+ """
286
+ if project_gid not in self._project_custom_fields_cache:
287
+ self._project_custom_fields_cache[project_gid] = (
288
+ await self._load_project_custom_fields(project_gid)
289
+ )
290
+ return self._project_custom_fields_cache[project_gid]
291
+
292
+ def _map_state_to_status_option(
293
+ self, state: TicketState, status_field: dict
294
+ ) -> dict | None:
295
+ """Map TicketState to Asana Status custom field option.
296
+
297
+ Args:
298
+ state: The TicketState to map
299
+ status_field: The Status custom field data with enum_options
300
+
301
+ Returns:
302
+ Matching enum option or None
303
+
304
+ """
305
+ # Define state mappings
306
+ state_mappings = {
307
+ TicketState.OPEN: ["not started", "to do", "backlog", "open"],
308
+ TicketState.IN_PROGRESS: ["in progress", "working on it", "started"],
309
+ TicketState.READY: ["ready", "ready for review", "completed"],
310
+ TicketState.TESTED: ["tested", "qa complete", "verified"],
311
+ TicketState.DONE: ["done", "complete", "finished"],
312
+ TicketState.CLOSED: ["closed", "archived"],
313
+ TicketState.WAITING: ["waiting", "blocked", "on hold"],
314
+ TicketState.BLOCKED: ["blocked", "stuck", "at risk"],
315
+ }
316
+
317
+ target_keywords = state_mappings.get(state, [])
318
+ state_name = state.value.lower()
319
+
320
+ # Try to find matching option
321
+ for option in status_field.get("enum_options", []):
322
+ option_name = option["name"].lower()
323
+
324
+ # Exact match
325
+ if option_name == state_name:
326
+ return option
327
+
328
+ # Keyword match
329
+ for keyword in target_keywords:
330
+ if keyword in option_name or option_name in keyword:
331
+ return option
332
+
333
+ return None
334
+
335
+ def _get_state_mapping(self) -> dict[TicketState, str]:
336
+ """Get mapping from universal states to Asana states.
337
+
338
+ Asana uses completed boolean, not state strings.
339
+ We return a mapping to "true"/"false" strings for compatibility.
340
+
341
+ Returns:
342
+ Dictionary mapping TicketState to completion status string
343
+
344
+ """
345
+ return {
346
+ TicketState.OPEN: "false",
347
+ TicketState.IN_PROGRESS: "false",
348
+ TicketState.READY: "false",
349
+ TicketState.TESTED: "false",
350
+ TicketState.DONE: "true",
351
+ TicketState.WAITING: "false",
352
+ TicketState.BLOCKED: "false",
353
+ TicketState.CLOSED: "true",
354
+ }
355
+
356
+ async def _resolve_project_gid(self, project_identifier: str) -> str | None:
357
+ """Resolve project identifier (name or GID) to GID.
358
+
359
+ Args:
360
+ project_identifier: Project name or GID
361
+
362
+ Returns:
363
+ Project GID or None if not found
364
+
365
+ """
366
+ if not project_identifier:
367
+ return None
368
+
369
+ # If it looks like a GID (numeric), return it
370
+ if project_identifier.isdigit():
371
+ return project_identifier
372
+
373
+ # Search projects by name in workspace
374
+ try:
375
+ projects = await self.client.get_paginated(
376
+ f"/workspaces/{self._workspace_gid}/projects"
377
+ )
378
+
379
+ # Match by name (case-insensitive)
380
+ project_lower = project_identifier.lower()
381
+ for project in projects:
382
+ if project.get("name", "").lower() == project_lower:
383
+ return project["gid"]
384
+
385
+ return None
386
+
387
+ except Exception as e:
388
+ logger.error(f"Failed to resolve project '{project_identifier}': {e}")
389
+ return None
390
+
391
+ async def _resolve_user_gid(self, user_identifier: str) -> str | None:
392
+ """Resolve user identifier (email, name, or GID) to GID.
393
+
394
+ Args:
395
+ user_identifier: User email, name, or GID
396
+
397
+ Returns:
398
+ User GID or None if not found
399
+
400
+ """
401
+ if not user_identifier:
402
+ return None
403
+
404
+ # If it looks like a GID (numeric), return it
405
+ if user_identifier.isdigit():
406
+ return user_identifier
407
+
408
+ # Search users in workspace
409
+ try:
410
+ users = await self.client.get_paginated(
411
+ f"/workspaces/{self._workspace_gid}/users"
412
+ )
413
+
414
+ # Match by email or name (case-insensitive)
415
+ identifier_lower = user_identifier.lower()
416
+ for user in users:
417
+ email = user.get("email", "").lower()
418
+ name = user.get("name", "").lower()
419
+
420
+ if email == identifier_lower or name == identifier_lower:
421
+ return user["gid"]
422
+
423
+ return None
424
+
425
+ except Exception as e:
426
+ logger.error(f"Failed to resolve user '{user_identifier}': {e}")
427
+ return None
428
+
429
+ # CRUD Operations
430
+
431
+ async def create(self, ticket: Epic | Task) -> Epic | Task:
432
+ """Create a new Asana project or task.
433
+
434
+ Args:
435
+ ticket: Epic or Task to create
436
+
437
+ Returns:
438
+ Created ticket with ID populated
439
+
440
+ Raises:
441
+ ValueError: If creation fails
442
+
443
+ """
444
+ # Validate credentials
445
+ is_valid, error_message = self.validate_credentials()
446
+ if not is_valid:
447
+ raise ValueError(error_message)
448
+
449
+ # Ensure adapter is initialized
450
+ await self.initialize()
451
+
452
+ # Handle Epic creation (Asana Projects)
453
+ if isinstance(ticket, Epic):
454
+ return await self._create_epic(ticket)
455
+
456
+ # Handle Task creation (Asana Tasks or Subtasks)
457
+ return await self._create_task(ticket)
458
+
459
+ async def _create_epic(self, epic: Epic) -> Epic:
460
+ """Create an Asana project from an Epic.
461
+
462
+ Args:
463
+ epic: Epic to create
464
+
465
+ Returns:
466
+ Created epic with Asana metadata
467
+
468
+ """
469
+ if not self._workspace_gid:
470
+ raise ValueError("Workspace not initialized")
471
+
472
+ # Build project data (including team if available)
473
+ project_data = map_epic_to_asana_project(
474
+ epic, self._workspace_gid, self._team_gid
475
+ )
476
+
477
+ try:
478
+ # Create project
479
+ created_project = await self.client.post("/projects", project_data)
480
+
481
+ # Map back to Epic
482
+ return map_asana_project_to_epic(created_project)
483
+
484
+ except Exception as e:
485
+ raise ValueError(f"Failed to create Asana project: {e}") from e
486
+
487
+ async def _create_task(self, task: Task) -> Task:
488
+ """Create an Asana task or subtask from a Task.
489
+
490
+ Creates a top-level task when task.parent_issue is not set, or a
491
+ subtask (child of another task) when task.parent_issue is provided.
492
+
493
+ Args:
494
+ task: Task to create
495
+
496
+ Returns:
497
+ Created task with Asana metadata
498
+
499
+ """
500
+ if not self._workspace_gid:
501
+ raise ValueError("Workspace not initialized")
502
+
503
+ # Determine project assignment
504
+ project_gids = []
505
+ if task.parent_epic:
506
+ # Resolve project GID
507
+ project_gid = await self._resolve_project_gid(task.parent_epic)
508
+ if project_gid:
509
+ project_gids = [project_gid]
510
+ else:
511
+ logger.warning(f"Could not resolve project '{task.parent_epic}'")
512
+ elif self._default_project_gid:
513
+ # Use default project if no epic specified and this is an issue
514
+ if task.ticket_type == TicketType.ISSUE:
515
+ project_gids = [self._default_project_gid]
516
+
517
+ # Resolve parent task GID if subtask
518
+ if task.parent_issue:
519
+ parent_gid = task.parent_issue
520
+ # If not numeric, try to resolve it
521
+ if not parent_gid.isdigit():
522
+ logger.warning(f"Parent issue '{parent_gid}' should be a GID")
523
+
524
+ # Build task data
525
+ task_data = map_task_to_asana_task(task, self._workspace_gid, project_gids)
526
+
527
+ # Resolve assignee if provided
528
+ if task.assignee:
529
+ assignee_gid = await self._resolve_user_gid(task.assignee)
530
+ if assignee_gid:
531
+ task_data["assignee"] = assignee_gid
532
+ else:
533
+ logger.warning(f"Could not resolve assignee '{task.assignee}'")
534
+ task_data.pop("assignee", None)
535
+
536
+ # Add tags if provided
537
+ if task.tags:
538
+ # Tags will be added after creation (Asana doesn't support tags in create)
539
+ pass
540
+
541
+ try:
542
+ # Create task
543
+ created_task = await self.client.post("/tasks", task_data)
544
+
545
+ # Add tags if provided (requires separate API call)
546
+ if task.tags:
547
+ await self._add_tags_to_task(created_task["gid"], task.tags)
548
+
549
+ # Fetch full task details
550
+ full_task = await self.client.get(
551
+ f"/tasks/{created_task['gid']}",
552
+ params={
553
+ "opt_fields": "gid,name,notes,completed,created_at,modified_at,assignee,tags,projects,parent,workspace,permalink_url,due_on,due_at,num_subtasks,custom_fields"
554
+ },
555
+ )
556
+
557
+ # Map back to Task
558
+ return map_asana_task_to_task(full_task)
559
+
560
+ except Exception as e:
561
+ raise ValueError(f"Failed to create Asana task: {e}") from e
562
+
563
+ async def _add_tags_to_task(self, task_gid: str, tags: list[str]) -> None:
564
+ """Add tags to an Asana task.
565
+
566
+ Args:
567
+ task_gid: Task GID
568
+ tags: List of tag names to add
569
+
570
+ """
571
+ if not tags:
572
+ return
573
+
574
+ try:
575
+ # Get or create tags in workspace
576
+ for tag_name in tags:
577
+ # Find tag by name
578
+ workspace_tags = await self.client.get_paginated(
579
+ f"/workspaces/{self._workspace_gid}/tags"
580
+ )
581
+
582
+ tag_gid = None
583
+ for tag in workspace_tags:
584
+ if tag.get("name", "").lower() == tag_name.lower():
585
+ tag_gid = tag["gid"]
586
+ break
587
+
588
+ # Create tag if it doesn't exist
589
+ if not tag_gid:
590
+ created_tag = await self.client.post(
591
+ "/tags", {"name": tag_name, "workspace": self._workspace_gid}
592
+ )
593
+ tag_gid = created_tag["gid"]
594
+
595
+ # Add tag to task
596
+ await self.client.post(f"/tasks/{task_gid}/addTag", {"tag": tag_gid})
597
+
598
+ except Exception as e:
599
+ logger.warning(f"Failed to add tags to task: {e}")
600
+
601
+ async def read(self, ticket_id: str) -> Task | None:
602
+ """Read an Asana task by GID.
603
+
604
+ Args:
605
+ ticket_id: Asana task GID
606
+
607
+ Returns:
608
+ Task if found, None otherwise
609
+
610
+ """
611
+ # Validate credentials
612
+ is_valid, error_message = self.validate_credentials()
613
+ if not is_valid:
614
+ raise ValueError(error_message)
615
+
616
+ try:
617
+ # Get task with expanded fields
618
+ task = await self.client.get(
619
+ f"/tasks/{ticket_id}",
620
+ params={
621
+ "opt_fields": "gid,name,notes,completed,created_at,modified_at,assignee,tags,projects,parent,workspace,permalink_url,due_on,due_at,num_subtasks,custom_fields"
622
+ },
623
+ )
624
+
625
+ return map_asana_task_to_task(task)
626
+
627
+ except Exception as e:
628
+ logger.error(f"Failed to read task {ticket_id}: {e}")
629
+ return None
630
+
631
+ async def update(self, ticket_id: str, updates: dict[str, Any]) -> Task | None:
632
+ """Update an Asana task.
633
+
634
+ Args:
635
+ ticket_id: Task GID
636
+ updates: Dictionary of fields to update
637
+
638
+ Returns:
639
+ Updated task or None if not found
640
+
641
+ """
642
+ # Validate credentials
643
+ is_valid, error_message = self.validate_credentials()
644
+ if not is_valid:
645
+ raise ValueError(error_message)
646
+
647
+ try:
648
+ # Get current task to find its project
649
+ current = await self.client.get(
650
+ f"/tasks/{ticket_id}", params={"opt_fields": "projects,custom_fields"}
651
+ )
652
+
653
+ # Build update data
654
+ update_data: dict[str, Any] = {}
655
+ custom_fields_update: dict[str, str] = {}
656
+
657
+ if "title" in updates:
658
+ update_data["name"] = updates["title"]
659
+
660
+ if "description" in updates:
661
+ update_data["notes"] = updates["description"]
662
+
663
+ if "assignee" in updates and updates["assignee"]:
664
+ assignee_gid = await self._resolve_user_gid(updates["assignee"])
665
+ if assignee_gid:
666
+ update_data["assignee"] = assignee_gid
667
+
668
+ if "due_on" in updates:
669
+ update_data["due_on"] = updates["due_on"]
670
+
671
+ if "due_at" in updates:
672
+ update_data["due_at"] = updates["due_at"]
673
+
674
+ # Handle priority update (Bug Fix #2)
675
+ if "priority" in updates:
676
+ # Get project custom fields
677
+ projects = current.get("projects", [])
678
+ if projects:
679
+ project_gid = projects[0]["gid"]
680
+ project_fields = await self._get_project_custom_fields(project_gid)
681
+
682
+ # Find Priority field
683
+ priority_field = project_fields.get("priority")
684
+ if priority_field:
685
+ # Map priority value to enum option
686
+ priority_value = updates["priority"]
687
+ if isinstance(priority_value, str):
688
+ priority_value = priority_value.lower()
689
+ else:
690
+ # Handle Priority enum
691
+ priority_value = priority_value.value.lower()
692
+
693
+ priority_option = None
694
+
695
+ for option in priority_field.get("enum_options", []):
696
+ if option["name"].lower() == priority_value:
697
+ priority_option = option
698
+ break
699
+
700
+ if priority_option:
701
+ custom_fields_update[priority_field["gid"]] = (
702
+ priority_option["gid"]
703
+ )
704
+ else:
705
+ logger.warning(
706
+ f"Priority option '{priority_value}' not found in field options"
707
+ )
708
+ else:
709
+ logger.warning("Priority custom field not found in project")
710
+
711
+ # Handle state updates (Bug Fix #3 - improved state management)
712
+ if "state" in updates:
713
+ state = updates["state"]
714
+ if isinstance(state, str):
715
+ state = TicketState(state)
716
+
717
+ # Check if project has Status custom field
718
+ projects = current.get("projects", [])
719
+ if projects:
720
+ project_gid = projects[0]["gid"]
721
+ project_fields = await self._get_project_custom_fields(project_gid)
722
+
723
+ status_field = project_fields.get("status")
724
+ if status_field:
725
+ # Map state to status option (Bug #3 fix)
726
+ status_option = self._map_state_to_status_option(
727
+ state, status_field
728
+ )
729
+ if status_option:
730
+ custom_fields_update[status_field["gid"]] = status_option[
731
+ "gid"
732
+ ]
733
+
734
+ # Always set completed boolean for DONE/CLOSED
735
+ if state in [TicketState.DONE, TicketState.CLOSED]:
736
+ update_data["completed"] = True
737
+ else:
738
+ update_data["completed"] = False
739
+
740
+ # Apply custom fields if any
741
+ if custom_fields_update:
742
+ update_data["custom_fields"] = custom_fields_update
743
+
744
+ # Update task
745
+ if update_data:
746
+ await self.client.put(f"/tasks/{ticket_id}", update_data)
747
+
748
+ # Handle tags update separately if provided
749
+ if "tags" in updates:
750
+ # Remove all existing tags first
751
+ current_task = await self.client.get(f"/tasks/{ticket_id}")
752
+ for tag in current_task.get("tags", []):
753
+ await self.client.post(
754
+ f"/tasks/{ticket_id}/removeTag", {"tag": tag["gid"]}
755
+ )
756
+
757
+ # Add new tags
758
+ if updates["tags"]:
759
+ await self._add_tags_to_task(ticket_id, updates["tags"])
760
+
761
+ # Fetch updated task with full details
762
+ full_task = await self.client.get(
763
+ f"/tasks/{ticket_id}",
764
+ params={
765
+ "opt_fields": "gid,name,notes,completed,created_at,modified_at,assignee,tags,projects,parent,workspace,permalink_url,due_on,due_at,num_subtasks,custom_fields"
766
+ },
767
+ )
768
+
769
+ return map_asana_task_to_task(full_task)
770
+
771
+ except Exception as e:
772
+ logger.error(f"Failed to update task {ticket_id}: {e}")
773
+ return None
774
+
775
+ async def delete(self, ticket_id: str) -> bool:
776
+ """Delete an Asana task.
777
+
778
+ Args:
779
+ ticket_id: Task GID
780
+
781
+ Returns:
782
+ True if successfully deleted
783
+
784
+ """
785
+ try:
786
+ await self.client.delete(f"/tasks/{ticket_id}")
787
+ return True
788
+ except Exception as e:
789
+ logger.error(f"Failed to delete task {ticket_id}: {e}")
790
+ return False
791
+
792
+ async def list(
793
+ self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
794
+ ) -> builtins.list[Task]:
795
+ """List Asana tasks with optional filtering.
796
+
797
+ Args:
798
+ limit: Maximum number of tasks to return
799
+ offset: Number of tasks to skip (Note: Asana uses offset tokens)
800
+ filters: Optional filters (state, assignee, project, etc.)
801
+
802
+ Returns:
803
+ List of tasks matching the criteria
804
+
805
+ """
806
+ # Validate credentials
807
+ is_valid, error_message = self.validate_credentials()
808
+ if not is_valid:
809
+ raise ValueError(error_message)
810
+
811
+ await self.initialize()
812
+
813
+ # Build query parameters
814
+ params: dict[str, Any] = {
815
+ "opt_fields": "gid,name,notes,completed,created_at,modified_at,assignee,tags,projects,parent,workspace,permalink_url,due_on,due_at,num_subtasks,custom_fields",
816
+ "limit": min(limit, 100), # Asana max is 100
817
+ }
818
+
819
+ # Determine endpoint based on filters
820
+ endpoint = None
821
+
822
+ if filters:
823
+ # Filter by project
824
+ if "parent_epic" in filters or "project" in filters:
825
+ project_id = filters.get("parent_epic") or filters.get("project")
826
+ project_gid = await self._resolve_project_gid(project_id)
827
+ if project_gid:
828
+ endpoint = f"/projects/{project_gid}/tasks"
829
+
830
+ # Filter by assignee
831
+ elif "assignee" in filters:
832
+ assignee_gid = await self._resolve_user_gid(filters["assignee"])
833
+ if assignee_gid:
834
+ params["assignee"] = assignee_gid
835
+ endpoint = "/tasks"
836
+
837
+ # Default: get current user's tasks
838
+ # Asana requires either project, assignee, or user task list
839
+ if not endpoint:
840
+ # Get current user's tasks instead of all workspace tasks
841
+ try:
842
+ me = await self.client.get("/users/me")
843
+ params["assignee"] = me["gid"]
844
+ params["workspace"] = self._workspace_gid
845
+ endpoint = "/tasks"
846
+ except Exception:
847
+ # Fallback: try to get first project's tasks
848
+ projects = await self.client.get_paginated(
849
+ f"/workspaces/{self._workspace_gid}/projects", limit=1
850
+ )
851
+ if projects:
852
+ endpoint = f"/projects/{projects[0]['gid']}/tasks"
853
+ else:
854
+ # No projects found - return empty list
855
+ return []
856
+
857
+ try:
858
+ # Get tasks (limited to specified limit)
859
+ all_tasks = await self.client.get_paginated(
860
+ endpoint, params=params, limit=limit
861
+ )
862
+
863
+ # Map to Task objects
864
+ tasks = []
865
+ for task_data in all_tasks[:limit]: # Ensure we don't exceed limit
866
+ tasks.append(map_asana_task_to_task(task_data))
867
+
868
+ # Apply additional filters
869
+ if filters:
870
+ # Filter by state
871
+ if "state" in filters:
872
+ state = filters["state"]
873
+ if isinstance(state, str):
874
+ from ...core.models import TicketState
875
+
876
+ state = TicketState(state)
877
+ completed = map_state_to_asana(state)
878
+ tasks = [
879
+ t
880
+ for t in tasks
881
+ if t.metadata.get("asana_completed") == completed
882
+ ]
883
+
884
+ # Filter by ticket type
885
+ if "ticket_type" in filters:
886
+ ticket_type = filters["ticket_type"]
887
+ tasks = [t for t in tasks if t.ticket_type == ticket_type]
888
+
889
+ return tasks
890
+
891
+ except Exception as e:
892
+ logger.error(f"Failed to list tasks: {e}")
893
+ return []
894
+
895
+ async def search(self, query: SearchQuery) -> builtins.list[Task]:
896
+ """Search Asana tasks using filters.
897
+
898
+ Args:
899
+ query: Search query with filters
900
+
901
+ Returns:
902
+ List of tasks matching the search criteria
903
+
904
+ """
905
+ # Build filters from query
906
+ filters: dict[str, Any] = {}
907
+
908
+ if query.assignee:
909
+ filters["assignee"] = query.assignee
910
+
911
+ if query.state:
912
+ filters["state"] = query.state
913
+
914
+ # Use list() with filters
915
+ tasks = await self.list(limit=query.limit, offset=query.offset, filters=filters)
916
+
917
+ # Apply text search if provided (client-side filtering)
918
+ if query.query:
919
+ query_lower = query.query.lower()
920
+ tasks = [
921
+ t
922
+ for t in tasks
923
+ if query_lower in t.title.lower()
924
+ or (t.description and query_lower in t.description.lower())
925
+ ]
926
+
927
+ # Apply tag filter if provided
928
+ if query.tags:
929
+ tasks = [t for t in tasks if any(tag in t.tags for tag in query.tags)]
930
+
931
+ return tasks[: query.limit]
932
+
933
+ async def transition_state(
934
+ self, ticket_id: str, target_state: TicketState
935
+ ) -> Task | None:
936
+ """Transition task to new state.
937
+
938
+ Args:
939
+ ticket_id: Task GID
940
+ target_state: Target state
941
+
942
+ Returns:
943
+ Updated task or None if failed
944
+
945
+ """
946
+ return await self.update(ticket_id, {"state": target_state})
947
+
948
+ async def add_comment(self, comment: Comment) -> Comment:
949
+ """Add a comment to an Asana task (as a story).
950
+
951
+ Args:
952
+ comment: Comment to add
953
+
954
+ Returns:
955
+ Created comment with ID
956
+
957
+ Raises:
958
+ ValueError: If comment creation fails
959
+
960
+ """
961
+ try:
962
+ # Create story on task
963
+ story_data = {
964
+ "text": comment.content,
965
+ }
966
+
967
+ created_story = await self.client.post(
968
+ f"/tasks/{comment.ticket_id}/stories", story_data
969
+ )
970
+
971
+ # Map to Comment
972
+ return (
973
+ map_asana_story_to_comment(created_story, comment.ticket_id) or comment
974
+ )
975
+
976
+ except Exception as e:
977
+ raise ValueError(f"Failed to add comment: {e}") from e
978
+
979
+ async def get_comments(
980
+ self, ticket_id: str, limit: int = 10, offset: int = 0
981
+ ) -> builtins.list[Comment]:
982
+ """Get comments for an Asana task.
983
+
984
+ Filters stories to only return comment type (not system events).
985
+
986
+ Args:
987
+ ticket_id: Task GID
988
+ limit: Maximum number of comments to return
989
+ offset: Number of comments to skip
990
+
991
+ Returns:
992
+ List of comments for the task
993
+
994
+ """
995
+ try:
996
+ # Get stories for task
997
+ stories = await self.client.get_paginated(
998
+ f"/tasks/{ticket_id}/stories", limit=limit
999
+ )
1000
+
1001
+ # Filter and map to Comments (only comment type stories)
1002
+ comments = []
1003
+ for story in stories:
1004
+ mapped_comment = map_asana_story_to_comment(story, ticket_id)
1005
+ if mapped_comment: # Only actual comments, not system stories
1006
+ comments.append(mapped_comment)
1007
+
1008
+ return comments[:limit]
1009
+
1010
+ except Exception as e:
1011
+ logger.error(f"Failed to get comments for task {ticket_id}: {e}")
1012
+ return []
1013
+
1014
+ # Epic/Issue/Task Hierarchy Methods
1015
+
1016
+ async def create_epic(
1017
+ self, title: str, description: str | None = None, **kwargs: Any
1018
+ ) -> Epic | None:
1019
+ """Create an Asana project (Epic).
1020
+
1021
+ Args:
1022
+ title: Epic title
1023
+ description: Epic description
1024
+ **kwargs: Additional fields
1025
+
1026
+ Returns:
1027
+ Created epic or None if failed
1028
+
1029
+ """
1030
+ epic = Epic(
1031
+ title=title,
1032
+ description=description,
1033
+ **{k: v for k, v in kwargs.items() if k in Epic.__fields__},
1034
+ )
1035
+ result = await self.create(epic)
1036
+ if isinstance(result, Epic):
1037
+ return result
1038
+ return None
1039
+
1040
+ async def get_epic(self, epic_id: str) -> Epic | None:
1041
+ """Get an Asana project (Epic) by GID.
1042
+
1043
+ Args:
1044
+ epic_id: Project GID
1045
+
1046
+ Returns:
1047
+ Epic if found, None otherwise
1048
+
1049
+ """
1050
+ try:
1051
+ project = await self.client.get(
1052
+ f"/projects/{epic_id}",
1053
+ params={
1054
+ "opt_fields": "gid,name,notes,archived,created_at,modified_at,workspace,team,color,permalink_url,public,custom_fields"
1055
+ },
1056
+ )
1057
+
1058
+ return map_asana_project_to_epic(project)
1059
+
1060
+ except Exception as e:
1061
+ logger.error(f"Failed to get project {epic_id}: {e}")
1062
+ return None
1063
+
1064
+ async def update_epic(self, epic_id: str, updates: dict[str, Any]) -> Epic | None:
1065
+ """Update an Asana project (Epic).
1066
+
1067
+ Args:
1068
+ epic_id: Project GID
1069
+ updates: Dictionary of fields to update
1070
+
1071
+ Returns:
1072
+ Updated epic or None if failed
1073
+
1074
+ """
1075
+ # Build update data
1076
+ update_data: dict[str, Any] = {}
1077
+
1078
+ if "title" in updates:
1079
+ update_data["name"] = updates["title"]
1080
+
1081
+ if "description" in updates:
1082
+ update_data["notes"] = updates["description"]
1083
+
1084
+ if "state" in updates:
1085
+ state = updates["state"]
1086
+ if isinstance(state, str):
1087
+ from ...core.models import TicketState
1088
+
1089
+ state = TicketState(state)
1090
+ # Map CLOSED/DONE to archived
1091
+ if state in (TicketState.CLOSED, TicketState.DONE):
1092
+ update_data["archived"] = True
1093
+ else:
1094
+ update_data["archived"] = False
1095
+
1096
+ try:
1097
+ # Update project
1098
+ await self.client.put(f"/projects/{epic_id}", update_data)
1099
+
1100
+ # Fetch updated project
1101
+ return await self.get_epic(epic_id)
1102
+
1103
+ except Exception as e:
1104
+ logger.error(f"Failed to update project {epic_id}: {e}")
1105
+ return None
1106
+
1107
+ async def list_epics(self, **kwargs: Any) -> builtins.list[Epic]:
1108
+ """List all Asana projects (Epics).
1109
+
1110
+ Args:
1111
+ **kwargs: Optional filter parameters
1112
+
1113
+ Returns:
1114
+ List of epics
1115
+
1116
+ """
1117
+ await self.initialize()
1118
+
1119
+ try:
1120
+ # Get projects for workspace
1121
+ projects = await self.client.get_paginated(
1122
+ f"/workspaces/{self._workspace_gid}/projects",
1123
+ params={
1124
+ "opt_fields": "gid,name,notes,archived,created_at,modified_at,workspace,team,color,permalink_url,public,custom_fields"
1125
+ },
1126
+ )
1127
+
1128
+ # Map to Epic objects
1129
+ epics = []
1130
+ for project_data in projects:
1131
+ epics.append(map_asana_project_to_epic(project_data))
1132
+
1133
+ # Filter by archived state if specified
1134
+ if "archived" in kwargs:
1135
+ archived = kwargs["archived"]
1136
+ epics = [
1137
+ e for e in epics if e.metadata.get("asana_archived") == archived
1138
+ ]
1139
+
1140
+ return epics
1141
+
1142
+ except Exception as e:
1143
+ logger.error(f"Failed to list projects: {e}")
1144
+ return []
1145
+
1146
+ async def list_issues_by_epic(self, epic_id: str) -> builtins.list[Task]:
1147
+ """List all tasks in a project (Epic).
1148
+
1149
+ Args:
1150
+ epic_id: Project GID
1151
+
1152
+ Returns:
1153
+ List of tasks in the project
1154
+
1155
+ """
1156
+ return await self.list(
1157
+ filters={"parent_epic": epic_id, "ticket_type": TicketType.ISSUE}
1158
+ )
1159
+
1160
+ async def list_tasks_by_issue(self, issue_id: str) -> builtins.list[Task]:
1161
+ """List all subtasks of a task (Issue).
1162
+
1163
+ Args:
1164
+ issue_id: Parent task GID
1165
+
1166
+ Returns:
1167
+ List of subtasks
1168
+
1169
+ """
1170
+ try:
1171
+ # Get subtasks for task
1172
+ subtasks = await self.client.get_paginated(
1173
+ f"/tasks/{issue_id}/subtasks",
1174
+ params={
1175
+ "opt_fields": "gid,name,notes,completed,created_at,modified_at,assignee,tags,projects,parent,workspace,permalink_url,due_on,due_at,num_subtasks,custom_fields"
1176
+ },
1177
+ )
1178
+
1179
+ # Map to Task objects
1180
+ tasks = []
1181
+ for task_data in subtasks:
1182
+ tasks.append(map_asana_task_to_task(task_data))
1183
+
1184
+ return tasks
1185
+
1186
+ except Exception as e:
1187
+ logger.error(f"Failed to list subtasks for task {issue_id}: {e}")
1188
+ return []
1189
+
1190
+ # Attachment Methods
1191
+
1192
+ async def add_attachment(
1193
+ self,
1194
+ ticket_id: str,
1195
+ file_path: str,
1196
+ description: str | None = None,
1197
+ ) -> Attachment:
1198
+ """Attach a file to an Asana task.
1199
+
1200
+ Args:
1201
+ ticket_id: Task GID
1202
+ file_path: Local file path to upload
1203
+ description: Optional attachment description (not used by Asana)
1204
+
1205
+ Returns:
1206
+ Created Attachment with metadata
1207
+
1208
+ Raises:
1209
+ FileNotFoundError: If file doesn't exist
1210
+ ValueError: If upload fails
1211
+
1212
+ """
1213
+ # Validate file exists
1214
+ file_path_obj = Path(file_path)
1215
+ if not file_path_obj.exists():
1216
+ raise FileNotFoundError(f"File not found: {file_path}")
1217
+ if not file_path_obj.is_file():
1218
+ raise ValueError(f"Path is not a file: {file_path}")
1219
+
1220
+ # Get file info
1221
+ filename = file_path_obj.name
1222
+ mime_type, _ = mimetypes.guess_type(file_path)
1223
+ if mime_type is None:
1224
+ mime_type = "application/octet-stream"
1225
+
1226
+ try:
1227
+ # Upload file using multipart/form-data
1228
+ # Note: Asana doesn't use {"data": {...}} wrapping for multipart uploads
1229
+ async with httpx.AsyncClient(timeout=60.0) as upload_client:
1230
+ with open(file_path, "rb") as f:
1231
+ files = {"file": (filename, f, mime_type)}
1232
+ headers = {"Authorization": f"Bearer {self.api_key}"}
1233
+
1234
+ response = await upload_client.post(
1235
+ f"{AsanaClient.BASE_URL}/tasks/{ticket_id}/attachments",
1236
+ files=files,
1237
+ headers=headers,
1238
+ )
1239
+
1240
+ if response.status_code >= 400:
1241
+ raise ValueError(
1242
+ f"Failed to upload attachment. Status: {response.status_code}, Response: {response.text}"
1243
+ )
1244
+
1245
+ response_data = response.json()
1246
+ attachment_data = response_data.get("data", response_data)
1247
+
1248
+ # Map to Attachment model
1249
+ return map_asana_attachment_to_attachment(attachment_data, ticket_id)
1250
+
1251
+ except Exception as e:
1252
+ raise ValueError(f"Failed to upload attachment '{filename}': {e}") from e
1253
+
1254
+ async def get_attachments(self, ticket_id: str) -> list[Attachment]:
1255
+ """Get all attachments for an Asana task.
1256
+
1257
+ Args:
1258
+ ticket_id: Task GID
1259
+
1260
+ Returns:
1261
+ List of attachments
1262
+
1263
+ """
1264
+ try:
1265
+ # Get attachments for task
1266
+ attachments = await self.client.get_paginated(
1267
+ f"/tasks/{ticket_id}/attachments"
1268
+ )
1269
+
1270
+ # Map to Attachment objects
1271
+ return [
1272
+ map_asana_attachment_to_attachment(att, ticket_id)
1273
+ for att in attachments
1274
+ ]
1275
+
1276
+ except Exception as e:
1277
+ logger.error(f"Failed to get attachments for task {ticket_id}: {e}")
1278
+ return []
1279
+
1280
+ async def delete_attachment(
1281
+ self,
1282
+ ticket_id: str,
1283
+ attachment_id: str,
1284
+ ) -> bool:
1285
+ """Delete an attachment from an Asana task.
1286
+
1287
+ Args:
1288
+ ticket_id: Task GID (not used, kept for interface compatibility)
1289
+ attachment_id: Attachment GID
1290
+
1291
+ Returns:
1292
+ True if deleted successfully
1293
+
1294
+ """
1295
+ try:
1296
+ await self.client.delete(f"/attachments/{attachment_id}")
1297
+ return True
1298
+ except Exception as e:
1299
+ logger.error(f"Failed to delete attachment {attachment_id}: {e}")
1300
+ return False
1301
+
1302
+ async def close(self) -> None:
1303
+ """Close adapter and cleanup resources."""
1304
+ await self.client.close()
1305
+
1306
+
1307
+ # Register the adapter
1308
+ AdapterRegistry.register("asana", AsanaAdapter)