mcp-ticketer 0.3.5__py3-none-any.whl → 0.12.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mcp-ticketer might be problematic. Click here for more details.

Files changed (84) hide show
  1. mcp_ticketer/__version__.py +3 -3
  2. mcp_ticketer/adapters/__init__.py +2 -0
  3. mcp_ticketer/adapters/aitrackdown.py +263 -14
  4. mcp_ticketer/adapters/asana/__init__.py +15 -0
  5. mcp_ticketer/adapters/asana/adapter.py +1308 -0
  6. mcp_ticketer/adapters/asana/client.py +292 -0
  7. mcp_ticketer/adapters/asana/mappers.py +334 -0
  8. mcp_ticketer/adapters/asana/types.py +146 -0
  9. mcp_ticketer/adapters/github.py +326 -109
  10. mcp_ticketer/adapters/hybrid.py +11 -11
  11. mcp_ticketer/adapters/jira.py +271 -25
  12. mcp_ticketer/adapters/linear/adapter.py +693 -39
  13. mcp_ticketer/adapters/linear/client.py +61 -9
  14. mcp_ticketer/adapters/linear/mappers.py +9 -3
  15. mcp_ticketer/adapters/linear/queries.py +9 -7
  16. mcp_ticketer/cache/memory.py +9 -8
  17. mcp_ticketer/cli/adapter_diagnostics.py +1 -1
  18. mcp_ticketer/cli/auggie_configure.py +104 -15
  19. mcp_ticketer/cli/codex_configure.py +188 -32
  20. mcp_ticketer/cli/configure.py +37 -48
  21. mcp_ticketer/cli/diagnostics.py +20 -18
  22. mcp_ticketer/cli/discover.py +292 -26
  23. mcp_ticketer/cli/gemini_configure.py +107 -26
  24. mcp_ticketer/cli/instruction_commands.py +429 -0
  25. mcp_ticketer/cli/linear_commands.py +105 -22
  26. mcp_ticketer/cli/main.py +1830 -435
  27. mcp_ticketer/cli/mcp_configure.py +296 -89
  28. mcp_ticketer/cli/migrate_config.py +12 -8
  29. mcp_ticketer/cli/platform_commands.py +123 -0
  30. mcp_ticketer/cli/platform_detection.py +412 -0
  31. mcp_ticketer/cli/python_detection.py +126 -0
  32. mcp_ticketer/cli/queue_commands.py +15 -15
  33. mcp_ticketer/cli/simple_health.py +1 -1
  34. mcp_ticketer/cli/ticket_commands.py +773 -0
  35. mcp_ticketer/cli/update_checker.py +313 -0
  36. mcp_ticketer/cli/utils.py +67 -62
  37. mcp_ticketer/core/__init__.py +14 -1
  38. mcp_ticketer/core/adapter.py +84 -15
  39. mcp_ticketer/core/config.py +44 -39
  40. mcp_ticketer/core/env_discovery.py +42 -12
  41. mcp_ticketer/core/env_loader.py +15 -14
  42. mcp_ticketer/core/exceptions.py +3 -3
  43. mcp_ticketer/core/http_client.py +26 -26
  44. mcp_ticketer/core/instructions.py +405 -0
  45. mcp_ticketer/core/mappers.py +11 -11
  46. mcp_ticketer/core/models.py +50 -20
  47. mcp_ticketer/core/onepassword_secrets.py +379 -0
  48. mcp_ticketer/core/project_config.py +57 -35
  49. mcp_ticketer/core/registry.py +3 -3
  50. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  51. mcp_ticketer/mcp/__init__.py +29 -1
  52. mcp_ticketer/mcp/__main__.py +60 -0
  53. mcp_ticketer/mcp/server/__init__.py +25 -0
  54. mcp_ticketer/mcp/server/__main__.py +60 -0
  55. mcp_ticketer/mcp/{dto.py → server/dto.py} +32 -32
  56. mcp_ticketer/mcp/{server.py → server/main.py} +127 -74
  57. mcp_ticketer/mcp/{response_builder.py → server/response_builder.py} +2 -2
  58. mcp_ticketer/mcp/server/server_sdk.py +93 -0
  59. mcp_ticketer/mcp/server/tools/__init__.py +47 -0
  60. mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
  61. mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
  62. mcp_ticketer/mcp/server/tools/comment_tools.py +90 -0
  63. mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
  64. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +532 -0
  65. mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
  66. mcp_ticketer/mcp/server/tools/pr_tools.py +154 -0
  67. mcp_ticketer/mcp/server/tools/search_tools.py +206 -0
  68. mcp_ticketer/mcp/server/tools/ticket_tools.py +430 -0
  69. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
  70. mcp_ticketer/queue/__init__.py +1 -0
  71. mcp_ticketer/queue/health_monitor.py +5 -4
  72. mcp_ticketer/queue/manager.py +15 -51
  73. mcp_ticketer/queue/queue.py +19 -19
  74. mcp_ticketer/queue/run_worker.py +1 -1
  75. mcp_ticketer/queue/ticket_registry.py +14 -14
  76. mcp_ticketer/queue/worker.py +16 -14
  77. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +168 -32
  78. mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
  79. mcp_ticketer-0.3.5.dist-info/RECORD +0 -62
  80. /mcp_ticketer/mcp/{constants.py → server/constants.py} +0 -0
  81. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
  82. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
  83. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
  84. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,292 @@
1
+ """Asana HTTP client for REST API v1.0."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class AsanaClient:
13
+ """HTTP client for Asana REST API v1.0.
14
+
15
+ Handles:
16
+ - Bearer token authentication
17
+ - Rate limiting (429 with Retry-After)
18
+ - Pagination (offset tokens)
19
+ - Error handling for all status codes
20
+ - Request/Response wrapping ({"data": {...}})
21
+ """
22
+
23
+ BASE_URL = "https://app.asana.com/api/1.0"
24
+
25
+ def __init__(self, api_key: str, timeout: int = 30, max_retries: int = 3):
26
+ """Initialize Asana client.
27
+
28
+ Args:
29
+ api_key: Asana Personal Access Token (PAT)
30
+ timeout: Request timeout in seconds
31
+ max_retries: Maximum retry attempts for rate limiting
32
+
33
+ """
34
+ self.api_key = api_key
35
+ self.timeout = timeout
36
+ self.max_retries = max_retries
37
+
38
+ # Setup headers
39
+ self.headers = {
40
+ "Authorization": f"Bearer {api_key}",
41
+ "Accept": "application/json",
42
+ "Content-Type": "application/json",
43
+ }
44
+
45
+ # HTTP client (will be created on first use)
46
+ self._client: httpx.AsyncClient | None = None
47
+
48
+ async def _get_client(self) -> httpx.AsyncClient:
49
+ """Get or create async HTTP client.
50
+
51
+ Returns:
52
+ Configured async HTTP client
53
+
54
+ """
55
+ if self._client is None:
56
+ self._client = httpx.AsyncClient(
57
+ headers=self.headers,
58
+ timeout=self.timeout,
59
+ follow_redirects=True,
60
+ )
61
+ return self._client
62
+
63
+ async def _handle_rate_limit(self, response: httpx.Response, attempt: int) -> None:
64
+ """Handle rate limiting with exponential backoff.
65
+
66
+ Args:
67
+ response: HTTP response with 429 status
68
+ attempt: Current retry attempt number
69
+
70
+ Raises:
71
+ ValueError: If max retries exceeded
72
+
73
+ """
74
+ if attempt >= self.max_retries:
75
+ raise ValueError(
76
+ f"Max retries ({self.max_retries}) exceeded for rate limiting"
77
+ )
78
+
79
+ # Get retry-after header (in seconds)
80
+ retry_after = int(response.headers.get("Retry-After", 60))
81
+ logger.warning(
82
+ f"Rate limited (429). Waiting {retry_after}s before retry {attempt + 1}/{self.max_retries}"
83
+ )
84
+ await asyncio.sleep(retry_after)
85
+
86
+ async def _request(
87
+ self,
88
+ method: str,
89
+ endpoint: str,
90
+ params: dict[str, Any] | None = None,
91
+ json: dict[str, Any] | None = None,
92
+ ) -> dict[str, Any]:
93
+ """Make HTTP request with retry logic for rate limiting.
94
+
95
+ Args:
96
+ method: HTTP method (GET, POST, PUT, DELETE)
97
+ endpoint: API endpoint (without base URL)
98
+ params: URL query parameters
99
+ json: Request body JSON data
100
+
101
+ Returns:
102
+ Response data (unwrapped from {"data": {...}})
103
+
104
+ Raises:
105
+ ValueError: If request fails or max retries exceeded
106
+
107
+ """
108
+ client = await self._get_client()
109
+ url = f"{self.BASE_URL}/{endpoint.lstrip('/')}"
110
+
111
+ # Wrap request body in {"data": {...}} for POST/PUT
112
+ if json is not None and method in ("POST", "PUT"):
113
+ json = {"data": json}
114
+
115
+ for attempt in range(self.max_retries + 1):
116
+ try:
117
+ response = await client.request(
118
+ method=method,
119
+ url=url,
120
+ params=params,
121
+ json=json,
122
+ )
123
+
124
+ # Handle rate limiting
125
+ if response.status_code == 429:
126
+ await self._handle_rate_limit(response, attempt)
127
+ continue
128
+
129
+ # Handle errors
130
+ if response.status_code >= 400:
131
+ error_detail = response.text
132
+ try:
133
+ error_json = response.json()
134
+ error_detail = error_json.get("errors", [{}])[0].get(
135
+ "message", error_detail
136
+ )
137
+ except Exception:
138
+ pass
139
+
140
+ raise ValueError(
141
+ f"Asana API error ({response.status_code}): {error_detail}"
142
+ )
143
+
144
+ # Success - unwrap response
145
+ response_data = response.json()
146
+
147
+ # Asana wraps responses in {"data": {...}}
148
+ if isinstance(response_data, dict) and "data" in response_data:
149
+ return response_data["data"]
150
+
151
+ return response_data
152
+
153
+ except httpx.TimeoutException as e:
154
+ logger.error(f"Request timeout for {method} {url}: {e}")
155
+ if attempt < self.max_retries:
156
+ wait_time = 2**attempt # Exponential backoff
157
+ logger.info(f"Retrying in {wait_time}s...")
158
+ await asyncio.sleep(wait_time)
159
+ else:
160
+ raise ValueError(
161
+ f"Request timeout after {self.max_retries} retries"
162
+ ) from e
163
+
164
+ except httpx.HTTPError as e:
165
+ logger.error(f"HTTP error for {method} {url}: {e}")
166
+ raise ValueError(f"HTTP error: {e}") from e
167
+
168
+ raise ValueError("Request failed after all retry attempts")
169
+
170
+ async def get(
171
+ self, endpoint: str, params: dict[str, Any] | None = None
172
+ ) -> dict[str, Any]:
173
+ """Make GET request.
174
+
175
+ Args:
176
+ endpoint: API endpoint
177
+ params: Query parameters
178
+
179
+ Returns:
180
+ Response data
181
+
182
+ """
183
+ return await self._request("GET", endpoint, params=params)
184
+
185
+ async def post(self, endpoint: str, data: dict[str, Any]) -> dict[str, Any]:
186
+ """Make POST request.
187
+
188
+ Args:
189
+ endpoint: API endpoint
190
+ data: Request body data
191
+
192
+ Returns:
193
+ Response data
194
+
195
+ """
196
+ return await self._request("POST", endpoint, json=data)
197
+
198
+ async def put(self, endpoint: str, data: dict[str, Any]) -> dict[str, Any]:
199
+ """Make PUT request.
200
+
201
+ Args:
202
+ endpoint: API endpoint
203
+ data: Request body data
204
+
205
+ Returns:
206
+ Response data
207
+
208
+ """
209
+ return await self._request("PUT", endpoint, json=data)
210
+
211
+ async def delete(self, endpoint: str) -> dict[str, Any]:
212
+ """Make DELETE request.
213
+
214
+ Args:
215
+ endpoint: API endpoint
216
+
217
+ Returns:
218
+ Response data
219
+
220
+ """
221
+ return await self._request("DELETE", endpoint)
222
+
223
+ async def get_paginated(
224
+ self,
225
+ endpoint: str,
226
+ params: dict[str, Any] | None = None,
227
+ limit: int = 100,
228
+ ) -> list[dict[str, Any]]:
229
+ """Get all pages of results using offset-based pagination.
230
+
231
+ Args:
232
+ endpoint: API endpoint
233
+ params: Query parameters
234
+ limit: Items per page (max 100)
235
+
236
+ Returns:
237
+ List of all results from all pages
238
+
239
+ """
240
+ if params is None:
241
+ params = {}
242
+
243
+ all_results = []
244
+ offset = None
245
+
246
+ while True:
247
+ # Set pagination params
248
+ page_params = params.copy()
249
+ page_params["limit"] = min(limit, 100) # Max 100 per page
250
+ if offset:
251
+ page_params["offset"] = offset
252
+
253
+ # Get page
254
+ response = await self.get(endpoint, params=page_params)
255
+
256
+ # Handle both array and object responses
257
+ if isinstance(response, list):
258
+ results = response
259
+ next_page = None
260
+ else:
261
+ results = response.get("data", [])
262
+ next_page = response.get("next_page")
263
+
264
+ all_results.extend(results)
265
+
266
+ # Check if more pages
267
+ if not next_page or not next_page.get("offset"):
268
+ break
269
+
270
+ offset = next_page["offset"]
271
+
272
+ return all_results
273
+
274
+ async def test_connection(self) -> bool:
275
+ """Test API connection and credentials.
276
+
277
+ Returns:
278
+ True if connection successful
279
+
280
+ """
281
+ try:
282
+ await self.get("/users/me")
283
+ return True
284
+ except Exception as e:
285
+ logger.error(f"Connection test failed: {e}")
286
+ return False
287
+
288
+ async def close(self) -> None:
289
+ """Close HTTP client and cleanup resources."""
290
+ if self._client:
291
+ await self._client.aclose()
292
+ self._client = None
@@ -0,0 +1,334 @@
1
+ """Data mappers for converting between Asana and mcp-ticketer models."""
2
+
3
+ import logging
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+ from ...core.models import (
8
+ Attachment,
9
+ Comment,
10
+ Epic,
11
+ Priority,
12
+ Task,
13
+ TicketState,
14
+ TicketType,
15
+ )
16
+ from .types import map_priority_from_asana, map_state_from_asana, map_state_to_asana
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def parse_asana_datetime(date_str: str | None) -> datetime | None:
22
+ """Parse Asana datetime string to datetime object.
23
+
24
+ Args:
25
+ date_str: ISO 8601 datetime string or None
26
+
27
+ Returns:
28
+ Parsed datetime or None
29
+
30
+ """
31
+ if not date_str:
32
+ return None
33
+
34
+ try:
35
+ # Asana returns ISO 8601 format: "2024-11-15T10:30:00.000Z"
36
+ return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
37
+ except (ValueError, AttributeError) as e:
38
+ logger.warning(f"Failed to parse Asana datetime '{date_str}': {e}")
39
+ return None
40
+
41
+
42
+ def map_asana_project_to_epic(project: dict[str, Any]) -> Epic:
43
+ """Map Asana project to Epic.
44
+
45
+ Args:
46
+ project: Asana project data
47
+
48
+ Returns:
49
+ Epic model instance
50
+
51
+ """
52
+ # Extract custom field for priority if exists
53
+ priority = Priority.MEDIUM
54
+ custom_fields = project.get("custom_fields", [])
55
+ for field in custom_fields:
56
+ if field.get("name", "").lower() == "priority" and field.get("enum_value"):
57
+ priority = map_priority_from_asana(field["enum_value"].get("name"))
58
+ break
59
+
60
+ # Map project state (archived, current, on_hold) to TicketState
61
+ archived = project.get("archived", False)
62
+ state = TicketState.CLOSED if archived else TicketState.OPEN
63
+
64
+ return Epic(
65
+ id=project.get("gid"),
66
+ title=project.get("name", ""),
67
+ description=project.get("notes", ""),
68
+ state=state,
69
+ priority=priority,
70
+ created_at=parse_asana_datetime(project.get("created_at")),
71
+ updated_at=parse_asana_datetime(project.get("modified_at")),
72
+ metadata={
73
+ "asana_gid": project.get("gid"),
74
+ "asana_permalink_url": project.get("permalink_url"),
75
+ "asana_workspace_gid": project.get("workspace", {}).get("gid"),
76
+ "asana_team_gid": (
77
+ project.get("team", {}).get("gid") if project.get("team") else None
78
+ ),
79
+ "asana_color": project.get("color"),
80
+ "asana_archived": archived,
81
+ "asana_public": project.get("public", False),
82
+ },
83
+ )
84
+
85
+
86
+ def map_asana_task_to_task(task: dict[str, Any]) -> Task:
87
+ """Map Asana task to Task.
88
+
89
+ Detects task type based on hierarchy:
90
+ - Has parent task → TASK (subtask)
91
+ - No parent task → ISSUE (standard task)
92
+
93
+ Args:
94
+ task: Asana task data
95
+
96
+ Returns:
97
+ Task model instance
98
+
99
+ """
100
+ # Determine ticket type based on parent
101
+ parent_task = task.get("parent")
102
+ ticket_type = TicketType.TASK if parent_task else TicketType.ISSUE
103
+
104
+ # Extract state from completed field AND Status custom field (Bug Fix #3)
105
+ completed = task.get("completed", False)
106
+ state = TicketState.OPEN
107
+ custom_state = None
108
+
109
+ # Check Status custom field first (if present)
110
+ custom_fields = task.get("custom_fields", [])
111
+ for field in custom_fields:
112
+ if field.get("name", "").lower() == "status":
113
+ enum_value = field.get("enum_value")
114
+ if enum_value:
115
+ custom_state = enum_value.get("name", "")
116
+ break
117
+
118
+ # Use enhanced state mapping that considers both Status field and completed boolean
119
+ state = map_state_from_asana(completed, custom_state)
120
+
121
+ # Extract priority from custom fields
122
+ priority = Priority.MEDIUM
123
+ for field in custom_fields:
124
+ if field.get("name", "").lower() == "priority" and field.get("enum_value"):
125
+ priority = map_priority_from_asana(field["enum_value"].get("name"))
126
+ break
127
+
128
+ # Extract tags
129
+ tags = [tag.get("name", "") for tag in task.get("tags", []) if tag.get("name")]
130
+
131
+ # Extract assignee
132
+ assignee = None
133
+ if task.get("assignee"):
134
+ assignee = task["assignee"].get("gid")
135
+
136
+ # Extract project (parent_epic for issues)
137
+ parent_epic = None
138
+ projects = task.get("projects", [])
139
+ if projects and ticket_type == TicketType.ISSUE:
140
+ # Use first project as parent epic
141
+ parent_epic = projects[0].get("gid")
142
+
143
+ # Extract parent task (parent_issue for subtasks)
144
+ parent_issue = None
145
+ if parent_task:
146
+ parent_issue = parent_task.get("gid")
147
+
148
+ return Task(
149
+ id=task.get("gid"),
150
+ title=task.get("name", ""),
151
+ description=task.get("notes", ""),
152
+ state=state,
153
+ priority=priority,
154
+ tags=tags,
155
+ assignee=assignee,
156
+ ticket_type=ticket_type,
157
+ parent_epic=parent_epic,
158
+ parent_issue=parent_issue,
159
+ created_at=parse_asana_datetime(task.get("created_at")),
160
+ updated_at=parse_asana_datetime(task.get("modified_at")),
161
+ metadata={
162
+ "asana_gid": task.get("gid"),
163
+ "asana_permalink_url": task.get("permalink_url"),
164
+ "asana_workspace_gid": task.get("workspace", {}).get("gid"),
165
+ "asana_completed": completed,
166
+ "asana_completed_at": task.get("completed_at"),
167
+ "asana_due_on": task.get("due_on"),
168
+ "asana_due_at": task.get("due_at"),
169
+ "asana_num_subtasks": task.get("num_subtasks", 0),
170
+ "asana_num_hearts": task.get("num_hearts", 0),
171
+ "asana_num_likes": task.get("num_likes", 0),
172
+ },
173
+ )
174
+
175
+
176
+ def map_epic_to_asana_project(
177
+ epic: Epic,
178
+ workspace_gid: str,
179
+ team_gid: str | None = None,
180
+ ) -> dict[str, Any]:
181
+ """Map Epic to Asana project create/update data.
182
+
183
+ Args:
184
+ epic: Epic model instance
185
+ workspace_gid: Asana workspace GID
186
+ team_gid: Asana team GID (optional, required for organization workspaces)
187
+
188
+ Returns:
189
+ Asana project data for create/update
190
+
191
+ """
192
+ project_data: dict[str, Any] = {
193
+ "name": epic.title,
194
+ "workspace": workspace_gid,
195
+ }
196
+
197
+ # Add team if provided (required for organization workspaces)
198
+ if team_gid:
199
+ project_data["team"] = team_gid
200
+
201
+ if epic.description:
202
+ project_data["notes"] = epic.description
203
+
204
+ # Map state to archived
205
+ if epic.state in (TicketState.CLOSED, TicketState.DONE):
206
+ project_data["archived"] = True
207
+
208
+ return project_data
209
+
210
+
211
+ def map_task_to_asana_task(
212
+ task: Task,
213
+ workspace_gid: str,
214
+ project_gids: list[str] | None = None,
215
+ ) -> dict[str, Any]:
216
+ """Map Task to Asana task create/update data.
217
+
218
+ Args:
219
+ task: Task model instance
220
+ workspace_gid: Asana workspace GID
221
+ project_gids: List of project GIDs to add task to (optional)
222
+
223
+ Returns:
224
+ Asana task data for create/update
225
+
226
+ """
227
+ task_data: dict[str, Any] = {
228
+ "name": task.title,
229
+ "workspace": workspace_gid,
230
+ }
231
+
232
+ if task.description:
233
+ task_data["notes"] = task.description
234
+
235
+ # Map state to completed
236
+ task_data["completed"] = map_state_to_asana(task.state)
237
+
238
+ # Add to projects if provided
239
+ if project_gids:
240
+ task_data["projects"] = project_gids
241
+
242
+ # Add parent if subtask
243
+ if task.parent_issue:
244
+ task_data["parent"] = task.parent_issue
245
+
246
+ # Add assignee if provided
247
+ if task.assignee:
248
+ task_data["assignee"] = task.assignee
249
+
250
+ # Due date mapping
251
+ if task.metadata.get("asana_due_on"):
252
+ task_data["due_on"] = task.metadata["asana_due_on"]
253
+ elif task.metadata.get("asana_due_at"):
254
+ task_data["due_at"] = task.metadata["asana_due_at"]
255
+
256
+ return task_data
257
+
258
+
259
+ def map_asana_story_to_comment(story: dict[str, Any], task_gid: str) -> Comment | None:
260
+ """Map Asana story to Comment.
261
+
262
+ Only maps stories of type 'comment'. Other story types (system events) are filtered out.
263
+
264
+ Args:
265
+ story: Asana story data
266
+ task_gid: Parent task GID
267
+
268
+ Returns:
269
+ Comment model instance or None if not a comment type
270
+
271
+ """
272
+ # Filter: only return actual comments, not system stories
273
+ story_type = story.get("type", "")
274
+ if story_type != "comment":
275
+ return None
276
+
277
+ # Extract author
278
+ created_by = story.get("created_by", {})
279
+ author = created_by.get("gid") or created_by.get("name", "Unknown")
280
+
281
+ return Comment(
282
+ id=story.get("gid"),
283
+ ticket_id=task_gid,
284
+ author=author,
285
+ content=story.get("text", ""),
286
+ created_at=parse_asana_datetime(story.get("created_at")),
287
+ metadata={
288
+ "asana_gid": story.get("gid"),
289
+ "asana_type": story_type,
290
+ "asana_created_by_name": created_by.get("name"),
291
+ },
292
+ )
293
+
294
+
295
+ def map_asana_attachment_to_attachment(
296
+ attachment: dict[str, Any], task_gid: str
297
+ ) -> Attachment:
298
+ """Map Asana attachment to Attachment.
299
+
300
+ IMPORTANT: Use permanent_url for reliable access, not download_url which expires.
301
+
302
+ Args:
303
+ attachment: Asana attachment data
304
+ task_gid: Parent task GID
305
+
306
+ Returns:
307
+ Attachment model instance
308
+
309
+ """
310
+ # Extract creator info
311
+ created_by_data = attachment.get("created_by", {})
312
+ created_by = created_by_data.get("gid") or created_by_data.get("name", "Unknown")
313
+
314
+ # Use permanent_url (not download_url which expires)
315
+ url = attachment.get("permanent_url") or attachment.get("view_url")
316
+
317
+ return Attachment(
318
+ id=attachment.get("gid"),
319
+ ticket_id=task_gid,
320
+ filename=attachment.get("name", ""),
321
+ url=url,
322
+ content_type=attachment.get("resource_subtype"), # e.g., "external", "asana"
323
+ size_bytes=attachment.get("size"),
324
+ created_at=parse_asana_datetime(attachment.get("created_at")),
325
+ created_by=created_by,
326
+ metadata={
327
+ "asana_gid": attachment.get("gid"),
328
+ "asana_host": attachment.get("host"), # e.g., "asana", "dropbox", "google"
329
+ "asana_resource_subtype": attachment.get("resource_subtype"),
330
+ "asana_view_url": attachment.get("view_url"),
331
+ "asana_download_url": attachment.get("download_url"), # Expires!
332
+ "asana_permanent_url": attachment.get("permanent_url"), # Stable URL
333
+ },
334
+ )