mcp-ticketer 0.3.0__py3-none-any.whl → 2.2.9__py3-none-any.whl

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