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