mcp-ticketer 0.1.39__py3-none-any.whl → 0.2.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.
- mcp_ticketer/__version__.py +1 -1
- mcp_ticketer/adapters/linear/__init__.py +24 -0
- mcp_ticketer/adapters/linear/adapter.py +813 -0
- mcp_ticketer/adapters/linear/client.py +257 -0
- mcp_ticketer/adapters/linear/mappers.py +307 -0
- mcp_ticketer/adapters/linear/queries.py +366 -0
- mcp_ticketer/adapters/linear/types.py +277 -0
- mcp_ticketer/adapters/linear.py +10 -2384
- mcp_ticketer/core/exceptions.py +152 -0
- {mcp_ticketer-0.1.39.dist-info → mcp_ticketer-0.2.0.dist-info}/METADATA +1 -1
- {mcp_ticketer-0.1.39.dist-info → mcp_ticketer-0.2.0.dist-info}/RECORD +15 -8
- {mcp_ticketer-0.1.39.dist-info → mcp_ticketer-0.2.0.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.1.39.dist-info → mcp_ticketer-0.2.0.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.1.39.dist-info → mcp_ticketer-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.1.39.dist-info → mcp_ticketer-0.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""GraphQL client management for Linear API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from gql import Client, gql
|
|
10
|
+
from gql.transport.aiohttp import AIOHTTPTransport
|
|
11
|
+
from gql.transport.exceptions import TransportError
|
|
12
|
+
except ImportError:
|
|
13
|
+
# Handle missing gql dependency gracefully
|
|
14
|
+
Client = None
|
|
15
|
+
gql = None
|
|
16
|
+
AIOHTTPTransport = None
|
|
17
|
+
TransportError = Exception
|
|
18
|
+
|
|
19
|
+
from ...core.exceptions import AdapterError, AuthenticationError, RateLimitError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LinearGraphQLClient:
|
|
23
|
+
"""GraphQL client for Linear API with error handling and retry logic."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, api_key: str, timeout: int = 30):
|
|
26
|
+
"""Initialize the Linear GraphQL client.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
api_key: Linear API key
|
|
30
|
+
timeout: Request timeout in seconds
|
|
31
|
+
"""
|
|
32
|
+
self.api_key = api_key
|
|
33
|
+
self.timeout = timeout
|
|
34
|
+
self._base_url = "https://api.linear.app/graphql"
|
|
35
|
+
|
|
36
|
+
def create_client(self) -> "Client":
|
|
37
|
+
"""Create a new GraphQL client instance.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Configured GraphQL client
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
AuthenticationError: If API key is invalid
|
|
44
|
+
AdapterError: If client creation fails
|
|
45
|
+
"""
|
|
46
|
+
if Client is None:
|
|
47
|
+
raise AdapterError("gql library not installed. Install with: pip install gql[aiohttp]", "linear")
|
|
48
|
+
|
|
49
|
+
if not self.api_key:
|
|
50
|
+
raise AuthenticationError("Linear API key is required", "linear")
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
# Create transport with authentication
|
|
54
|
+
transport = AIOHTTPTransport(
|
|
55
|
+
url=self._base_url,
|
|
56
|
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
57
|
+
timeout=self.timeout,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Create client
|
|
61
|
+
client = Client(transport=transport, fetch_schema_from_transport=False)
|
|
62
|
+
return client
|
|
63
|
+
|
|
64
|
+
except Exception as e:
|
|
65
|
+
raise AdapterError(f"Failed to create Linear client: {e}", "linear")
|
|
66
|
+
|
|
67
|
+
async def execute_query(
|
|
68
|
+
self,
|
|
69
|
+
query_string: str,
|
|
70
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
71
|
+
retries: int = 3,
|
|
72
|
+
) -> Dict[str, Any]:
|
|
73
|
+
"""Execute a GraphQL query with error handling and retries.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
query_string: GraphQL query string
|
|
77
|
+
variables: Query variables
|
|
78
|
+
retries: Number of retry attempts
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Query result data
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
AuthenticationError: If authentication fails
|
|
85
|
+
RateLimitError: If rate limit is exceeded
|
|
86
|
+
AdapterError: If query execution fails
|
|
87
|
+
"""
|
|
88
|
+
query = gql(query_string)
|
|
89
|
+
|
|
90
|
+
for attempt in range(retries + 1):
|
|
91
|
+
try:
|
|
92
|
+
client = self.create_client()
|
|
93
|
+
async with client as session:
|
|
94
|
+
result = await session.execute(query, variable_values=variables or {})
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
except TransportError as e:
|
|
98
|
+
# Handle HTTP errors
|
|
99
|
+
if hasattr(e, 'response') and e.response:
|
|
100
|
+
status_code = e.response.status
|
|
101
|
+
|
|
102
|
+
if status_code == 401:
|
|
103
|
+
raise AuthenticationError("Invalid Linear API key", "linear")
|
|
104
|
+
elif status_code == 403:
|
|
105
|
+
raise AuthenticationError("Insufficient permissions", "linear")
|
|
106
|
+
elif status_code == 429:
|
|
107
|
+
# Rate limit exceeded
|
|
108
|
+
retry_after = e.response.headers.get("Retry-After", "60")
|
|
109
|
+
raise RateLimitError(
|
|
110
|
+
"Linear API rate limit exceeded",
|
|
111
|
+
"linear",
|
|
112
|
+
retry_after
|
|
113
|
+
)
|
|
114
|
+
elif status_code >= 500:
|
|
115
|
+
# Server error - retry
|
|
116
|
+
if attempt < retries:
|
|
117
|
+
await asyncio.sleep(2 ** attempt) # Exponential backoff
|
|
118
|
+
continue
|
|
119
|
+
raise AdapterError(f"Linear API server error: {status_code}", "linear")
|
|
120
|
+
|
|
121
|
+
# Network or other transport error
|
|
122
|
+
if attempt < retries:
|
|
123
|
+
await asyncio.sleep(2 ** attempt)
|
|
124
|
+
continue
|
|
125
|
+
raise AdapterError(f"Linear API transport error: {e}", "linear")
|
|
126
|
+
|
|
127
|
+
except Exception as e:
|
|
128
|
+
# GraphQL or other errors
|
|
129
|
+
error_msg = str(e)
|
|
130
|
+
|
|
131
|
+
# Check for specific GraphQL errors
|
|
132
|
+
if "authentication" in error_msg.lower() or "unauthorized" in error_msg.lower():
|
|
133
|
+
raise AuthenticationError(f"Linear authentication failed: {error_msg}", "linear")
|
|
134
|
+
elif "rate limit" in error_msg.lower():
|
|
135
|
+
raise RateLimitError("Linear API rate limit exceeded", "linear")
|
|
136
|
+
|
|
137
|
+
# Generic error
|
|
138
|
+
if attempt < retries:
|
|
139
|
+
await asyncio.sleep(2 ** attempt)
|
|
140
|
+
continue
|
|
141
|
+
raise AdapterError(f"Linear GraphQL error: {error_msg}", "linear")
|
|
142
|
+
|
|
143
|
+
# Should never reach here
|
|
144
|
+
raise AdapterError("Maximum retries exceeded", "linear")
|
|
145
|
+
|
|
146
|
+
async def execute_mutation(
|
|
147
|
+
self,
|
|
148
|
+
mutation_string: str,
|
|
149
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
150
|
+
retries: int = 3,
|
|
151
|
+
) -> Dict[str, Any]:
|
|
152
|
+
"""Execute a GraphQL mutation with error handling.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
mutation_string: GraphQL mutation string
|
|
156
|
+
variables: Mutation variables
|
|
157
|
+
retries: Number of retry attempts
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Mutation result data
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
AuthenticationError: If authentication fails
|
|
164
|
+
RateLimitError: If rate limit is exceeded
|
|
165
|
+
AdapterError: If mutation execution fails
|
|
166
|
+
"""
|
|
167
|
+
return await self.execute_query(mutation_string, variables, retries)
|
|
168
|
+
|
|
169
|
+
async def test_connection(self) -> bool:
|
|
170
|
+
"""Test the connection to Linear API.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
True if connection is successful, False otherwise
|
|
174
|
+
"""
|
|
175
|
+
try:
|
|
176
|
+
# Simple query to test authentication
|
|
177
|
+
test_query = """
|
|
178
|
+
query TestConnection {
|
|
179
|
+
viewer {
|
|
180
|
+
id
|
|
181
|
+
name
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
result = await self.execute_query(test_query)
|
|
187
|
+
return bool(result.get("viewer"))
|
|
188
|
+
|
|
189
|
+
except Exception:
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
async def get_team_info(self, team_id: str) -> Optional[Dict[str, Any]]:
|
|
193
|
+
"""Get team information by ID.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
team_id: Linear team ID
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Team information or None if not found
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
query = """
|
|
203
|
+
query GetTeam($teamId: String!) {
|
|
204
|
+
team(id: $teamId) {
|
|
205
|
+
id
|
|
206
|
+
name
|
|
207
|
+
key
|
|
208
|
+
description
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
result = await self.execute_query(query, {"teamId": team_id})
|
|
214
|
+
return result.get("team")
|
|
215
|
+
|
|
216
|
+
except Exception:
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
async def get_user_by_email(self, email: str) -> Optional[Dict[str, Any]]:
|
|
220
|
+
"""Get user information by email.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
email: User email address
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
User information or None if not found
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
query = """
|
|
230
|
+
query GetUserByEmail($email: String!) {
|
|
231
|
+
users(filter: { email: { eq: $email } }) {
|
|
232
|
+
nodes {
|
|
233
|
+
id
|
|
234
|
+
name
|
|
235
|
+
email
|
|
236
|
+
displayName
|
|
237
|
+
avatarUrl
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
result = await self.execute_query(query, {"email": email})
|
|
244
|
+
users = result.get("users", {}).get("nodes", [])
|
|
245
|
+
return users[0] if users else None
|
|
246
|
+
|
|
247
|
+
except Exception:
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
async def close(self) -> None:
|
|
251
|
+
"""Close the client connection.
|
|
252
|
+
|
|
253
|
+
Since we create fresh clients for each operation, there's no persistent
|
|
254
|
+
connection to close. Each client's transport is automatically closed when
|
|
255
|
+
the async context manager exits.
|
|
256
|
+
"""
|
|
257
|
+
pass
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Data transformation mappers for Linear API responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from ...core.models import Comment, Epic, Priority, Task, TicketState
|
|
9
|
+
from .types import (
|
|
10
|
+
extract_linear_metadata,
|
|
11
|
+
get_universal_priority,
|
|
12
|
+
get_universal_state,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def map_linear_issue_to_task(issue_data: Dict[str, Any]) -> Task:
|
|
17
|
+
"""Convert Linear issue data to universal Task model.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
issue_data: Raw Linear issue data from GraphQL
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Universal Task model
|
|
24
|
+
"""
|
|
25
|
+
# Extract basic fields
|
|
26
|
+
task_id = issue_data["identifier"]
|
|
27
|
+
title = issue_data["title"]
|
|
28
|
+
description = issue_data.get("description", "")
|
|
29
|
+
|
|
30
|
+
# Map priority
|
|
31
|
+
linear_priority = issue_data.get("priority", 3)
|
|
32
|
+
priority = get_universal_priority(linear_priority)
|
|
33
|
+
|
|
34
|
+
# Map state
|
|
35
|
+
state_data = issue_data.get("state", {})
|
|
36
|
+
state_type = state_data.get("type", "unstarted")
|
|
37
|
+
state = get_universal_state(state_type)
|
|
38
|
+
|
|
39
|
+
# Extract assignee
|
|
40
|
+
assignee = None
|
|
41
|
+
if issue_data.get("assignee"):
|
|
42
|
+
assignee_data = issue_data["assignee"]
|
|
43
|
+
assignee = assignee_data.get("email") or assignee_data.get("displayName")
|
|
44
|
+
|
|
45
|
+
# Extract creator
|
|
46
|
+
creator = None
|
|
47
|
+
if issue_data.get("creator"):
|
|
48
|
+
creator_data = issue_data["creator"]
|
|
49
|
+
creator = creator_data.get("email") or creator_data.get("displayName")
|
|
50
|
+
|
|
51
|
+
# Extract tags (labels)
|
|
52
|
+
tags = []
|
|
53
|
+
if issue_data.get("labels", {}).get("nodes"):
|
|
54
|
+
tags = [label["name"] for label in issue_data["labels"]["nodes"]]
|
|
55
|
+
|
|
56
|
+
# Extract parent epic (project)
|
|
57
|
+
parent_epic = None
|
|
58
|
+
if issue_data.get("project"):
|
|
59
|
+
parent_epic = issue_data["project"]["id"]
|
|
60
|
+
|
|
61
|
+
# Extract parent issue
|
|
62
|
+
parent_issue = None
|
|
63
|
+
if issue_data.get("parent"):
|
|
64
|
+
parent_issue = issue_data["parent"]["identifier"]
|
|
65
|
+
|
|
66
|
+
# Extract dates
|
|
67
|
+
created_at = None
|
|
68
|
+
if issue_data.get("createdAt"):
|
|
69
|
+
created_at = datetime.fromisoformat(issue_data["createdAt"].replace("Z", "+00:00"))
|
|
70
|
+
|
|
71
|
+
updated_at = None
|
|
72
|
+
if issue_data.get("updatedAt"):
|
|
73
|
+
updated_at = datetime.fromisoformat(issue_data["updatedAt"].replace("Z", "+00:00"))
|
|
74
|
+
|
|
75
|
+
# Extract Linear-specific metadata
|
|
76
|
+
linear_metadata = extract_linear_metadata(issue_data)
|
|
77
|
+
metadata = {"linear": linear_metadata} if linear_metadata else None
|
|
78
|
+
|
|
79
|
+
return Task(
|
|
80
|
+
id=task_id,
|
|
81
|
+
title=title,
|
|
82
|
+
description=description,
|
|
83
|
+
state=state,
|
|
84
|
+
priority=priority,
|
|
85
|
+
assignee=assignee,
|
|
86
|
+
creator=creator,
|
|
87
|
+
tags=tags,
|
|
88
|
+
parent_epic=parent_epic,
|
|
89
|
+
parent_issue=parent_issue,
|
|
90
|
+
created_at=created_at,
|
|
91
|
+
updated_at=updated_at,
|
|
92
|
+
metadata=metadata,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def map_linear_project_to_epic(project_data: Dict[str, Any]) -> Epic:
|
|
97
|
+
"""Convert Linear project data to universal Epic model.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
project_data: Raw Linear project data from GraphQL
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Universal Epic model
|
|
104
|
+
"""
|
|
105
|
+
# Extract basic fields
|
|
106
|
+
epic_id = project_data["id"]
|
|
107
|
+
title = project_data["name"]
|
|
108
|
+
description = project_data.get("description", "")
|
|
109
|
+
|
|
110
|
+
# Map state based on project state
|
|
111
|
+
project_state = project_data.get("state", "planned")
|
|
112
|
+
if project_state == "completed":
|
|
113
|
+
state = TicketState.DONE
|
|
114
|
+
elif project_state == "started":
|
|
115
|
+
state = TicketState.IN_PROGRESS
|
|
116
|
+
elif project_state == "canceled":
|
|
117
|
+
state = TicketState.CLOSED
|
|
118
|
+
else:
|
|
119
|
+
state = TicketState.OPEN
|
|
120
|
+
|
|
121
|
+
# Extract dates
|
|
122
|
+
created_at = None
|
|
123
|
+
if project_data.get("createdAt"):
|
|
124
|
+
created_at = datetime.fromisoformat(project_data["createdAt"].replace("Z", "+00:00"))
|
|
125
|
+
|
|
126
|
+
updated_at = None
|
|
127
|
+
if project_data.get("updatedAt"):
|
|
128
|
+
updated_at = datetime.fromisoformat(project_data["updatedAt"].replace("Z", "+00:00"))
|
|
129
|
+
|
|
130
|
+
# Extract Linear-specific metadata
|
|
131
|
+
metadata = {"linear": {}}
|
|
132
|
+
if project_data.get("url"):
|
|
133
|
+
metadata["linear"]["linear_url"] = project_data["url"]
|
|
134
|
+
if project_data.get("icon"):
|
|
135
|
+
metadata["linear"]["icon"] = project_data["icon"]
|
|
136
|
+
if project_data.get("color"):
|
|
137
|
+
metadata["linear"]["color"] = project_data["color"]
|
|
138
|
+
if project_data.get("targetDate"):
|
|
139
|
+
metadata["linear"]["target_date"] = project_data["targetDate"]
|
|
140
|
+
|
|
141
|
+
return Epic(
|
|
142
|
+
id=epic_id,
|
|
143
|
+
title=title,
|
|
144
|
+
description=description,
|
|
145
|
+
state=state,
|
|
146
|
+
priority=Priority.MEDIUM, # Projects don't have priority in Linear
|
|
147
|
+
created_at=created_at,
|
|
148
|
+
updated_at=updated_at,
|
|
149
|
+
metadata=metadata if metadata["linear"] else None,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def map_linear_comment_to_comment(comment_data: Dict[str, Any], ticket_id: str) -> Comment:
|
|
154
|
+
"""Convert Linear comment data to universal Comment model.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
comment_data: Raw Linear comment data from GraphQL
|
|
158
|
+
ticket_id: ID of the ticket this comment belongs to
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Universal Comment model
|
|
162
|
+
"""
|
|
163
|
+
# Extract basic fields
|
|
164
|
+
comment_id = comment_data["id"]
|
|
165
|
+
body = comment_data.get("body", "")
|
|
166
|
+
|
|
167
|
+
# Extract author
|
|
168
|
+
author = None
|
|
169
|
+
if comment_data.get("user"):
|
|
170
|
+
user_data = comment_data["user"]
|
|
171
|
+
author = user_data.get("email") or user_data.get("displayName")
|
|
172
|
+
|
|
173
|
+
# Extract dates
|
|
174
|
+
created_at = None
|
|
175
|
+
if comment_data.get("createdAt"):
|
|
176
|
+
created_at = datetime.fromisoformat(comment_data["createdAt"].replace("Z", "+00:00"))
|
|
177
|
+
|
|
178
|
+
updated_at = None
|
|
179
|
+
if comment_data.get("updatedAt"):
|
|
180
|
+
updated_at = datetime.fromisoformat(comment_data["updatedAt"].replace("Z", "+00:00"))
|
|
181
|
+
|
|
182
|
+
return Comment(
|
|
183
|
+
id=comment_id,
|
|
184
|
+
ticket_id=ticket_id,
|
|
185
|
+
body=body,
|
|
186
|
+
author=author,
|
|
187
|
+
created_at=created_at,
|
|
188
|
+
updated_at=updated_at,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def build_linear_issue_input(task: Task, team_id: str) -> Dict[str, Any]:
|
|
193
|
+
"""Build Linear issue input from universal Task model.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
task: Universal Task model
|
|
197
|
+
team_id: Linear team ID
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Linear issue input dictionary
|
|
201
|
+
"""
|
|
202
|
+
from .types import get_linear_priority
|
|
203
|
+
|
|
204
|
+
issue_input = {
|
|
205
|
+
"title": task.title,
|
|
206
|
+
"teamId": team_id,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
# Add description if provided
|
|
210
|
+
if task.description:
|
|
211
|
+
issue_input["description"] = task.description
|
|
212
|
+
|
|
213
|
+
# Add priority
|
|
214
|
+
if task.priority:
|
|
215
|
+
issue_input["priority"] = get_linear_priority(task.priority)
|
|
216
|
+
|
|
217
|
+
# Add assignee if provided (assumes it's a user ID)
|
|
218
|
+
if task.assignee:
|
|
219
|
+
issue_input["assigneeId"] = task.assignee
|
|
220
|
+
|
|
221
|
+
# Add parent issue if provided
|
|
222
|
+
if task.parent_issue:
|
|
223
|
+
issue_input["parentId"] = task.parent_issue
|
|
224
|
+
|
|
225
|
+
# Add project (epic) if provided
|
|
226
|
+
if task.parent_epic:
|
|
227
|
+
issue_input["projectId"] = task.parent_epic
|
|
228
|
+
|
|
229
|
+
# Add labels (tags) if provided
|
|
230
|
+
if task.tags:
|
|
231
|
+
# Note: Linear requires label IDs, not names
|
|
232
|
+
# This would need to be resolved by the adapter
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
# Add Linear-specific metadata
|
|
236
|
+
if task.metadata and "linear" in task.metadata:
|
|
237
|
+
linear_meta = task.metadata["linear"]
|
|
238
|
+
if "due_date" in linear_meta:
|
|
239
|
+
issue_input["dueDate"] = linear_meta["due_date"]
|
|
240
|
+
if "cycle_id" in linear_meta:
|
|
241
|
+
issue_input["cycleId"] = linear_meta["cycle_id"]
|
|
242
|
+
if "estimate" in linear_meta:
|
|
243
|
+
issue_input["estimate"] = linear_meta["estimate"]
|
|
244
|
+
|
|
245
|
+
return issue_input
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def build_linear_issue_update_input(updates: Dict[str, Any]) -> Dict[str, Any]:
|
|
249
|
+
"""Build Linear issue update input from update dictionary.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
updates: Dictionary of fields to update
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Linear issue update input dictionary
|
|
256
|
+
"""
|
|
257
|
+
from .types import get_linear_priority
|
|
258
|
+
|
|
259
|
+
update_input = {}
|
|
260
|
+
|
|
261
|
+
# Map standard fields
|
|
262
|
+
if "title" in updates:
|
|
263
|
+
update_input["title"] = updates["title"]
|
|
264
|
+
|
|
265
|
+
if "description" in updates:
|
|
266
|
+
update_input["description"] = updates["description"]
|
|
267
|
+
|
|
268
|
+
if "priority" in updates:
|
|
269
|
+
priority = Priority(updates["priority"]) if isinstance(updates["priority"], str) else updates["priority"]
|
|
270
|
+
update_input["priority"] = get_linear_priority(priority)
|
|
271
|
+
|
|
272
|
+
if "assignee" in updates:
|
|
273
|
+
update_input["assigneeId"] = updates["assignee"]
|
|
274
|
+
|
|
275
|
+
# Handle state transitions (would need workflow state mapping)
|
|
276
|
+
if "state" in updates:
|
|
277
|
+
# This would need to be handled by the adapter with proper state mapping
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
# Handle metadata updates
|
|
281
|
+
if "metadata" in updates and "linear" in updates["metadata"]:
|
|
282
|
+
linear_meta = updates["metadata"]["linear"]
|
|
283
|
+
if "due_date" in linear_meta:
|
|
284
|
+
update_input["dueDate"] = linear_meta["due_date"]
|
|
285
|
+
if "cycle_id" in linear_meta:
|
|
286
|
+
update_input["cycleId"] = linear_meta["cycle_id"]
|
|
287
|
+
if "project_id" in linear_meta:
|
|
288
|
+
update_input["projectId"] = linear_meta["project_id"]
|
|
289
|
+
if "estimate" in linear_meta:
|
|
290
|
+
update_input["estimate"] = linear_meta["estimate"]
|
|
291
|
+
|
|
292
|
+
return update_input
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def extract_child_issue_ids(issue_data: Dict[str, Any]) -> List[str]:
|
|
296
|
+
"""Extract child issue IDs from Linear issue data.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
issue_data: Raw Linear issue data from GraphQL
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
List of child issue identifiers
|
|
303
|
+
"""
|
|
304
|
+
child_ids = []
|
|
305
|
+
if issue_data.get("children", {}).get("nodes"):
|
|
306
|
+
child_ids = [child["identifier"] for child in issue_data["children"]["nodes"]]
|
|
307
|
+
return child_ids
|