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