mcp-ticketer 0.2.0__py3-none-any.whl → 2.2.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/_version_scm.py +1 -0
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +930 -52
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1537 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +348 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github/__init__.py +26 -0
- mcp_ticketer/adapters/github/adapter.py +3229 -0
- mcp_ticketer/adapters/github/client.py +335 -0
- mcp_ticketer/adapters/github/mappers.py +797 -0
- mcp_ticketer/adapters/github/queries.py +692 -0
- mcp_ticketer/adapters/github/types.py +460 -0
- mcp_ticketer/adapters/hybrid.py +58 -16
- mcp_ticketer/adapters/jira/__init__.py +35 -0
- mcp_ticketer/adapters/jira/adapter.py +1351 -0
- mcp_ticketer/adapters/jira/client.py +271 -0
- mcp_ticketer/adapters/jira/mappers.py +246 -0
- mcp_ticketer/adapters/jira/queries.py +216 -0
- mcp_ticketer/adapters/jira/types.py +304 -0
- mcp_ticketer/adapters/linear/__init__.py +1 -1
- mcp_ticketer/adapters/linear/adapter.py +3810 -462
- mcp_ticketer/adapters/linear/client.py +312 -69
- mcp_ticketer/adapters/linear/mappers.py +305 -85
- mcp_ticketer/adapters/linear/queries.py +317 -17
- mcp_ticketer/adapters/linear/types.py +187 -64
- mcp_ticketer/adapters/linear.py +2 -2
- mcp_ticketer/analysis/__init__.py +56 -0
- mcp_ticketer/analysis/dependency_graph.py +255 -0
- mcp_ticketer/analysis/health_assessment.py +304 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/project_status.py +594 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/automation/__init__.py +11 -0
- mcp_ticketer/automation/project_updates.py +378 -0
- mcp_ticketer/cache/memory.py +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +421 -0
- mcp_ticketer/cli/auggie_configure.py +116 -15
- mcp_ticketer/cli/codex_configure.py +274 -82
- mcp_ticketer/cli/configure.py +1323 -151
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +209 -114
- mcp_ticketer/cli/discover.py +297 -26
- mcp_ticketer/cli/gemini_configure.py +119 -26
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/install_mcp_server.py +418 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +256 -130
- mcp_ticketer/cli/main.py +140 -1284
- mcp_ticketer/cli/mcp_configure.py +1013 -100
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +477 -0
- mcp_ticketer/cli/platform_installer.py +545 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +794 -0
- mcp_ticketer/cli/simple_health.py +84 -59
- mcp_ticketer/cli/ticket_commands.py +1375 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +195 -72
- mcp_ticketer/core/__init__.py +64 -1
- mcp_ticketer/core/adapter.py +618 -18
- mcp_ticketer/core/config.py +77 -68
- mcp_ticketer/core/env_discovery.py +75 -16
- mcp_ticketer/core/env_loader.py +121 -97
- mcp_ticketer/core/exceptions.py +32 -24
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +42 -30
- mcp_ticketer/core/milestone_manager.py +252 -0
- mcp_ticketer/core/models.py +566 -19
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +189 -49
- mcp_ticketer/core/project_utils.py +281 -0
- mcp_ticketer/core/project_validator.py +376 -0
- mcp_ticketer/core/registry.py +3 -3
- mcp_ticketer/core/session_state.py +176 -0
- mcp_ticketer/core/state_matcher.py +592 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- 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/server/constants.py +58 -0
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/dto.py +195 -0
- mcp_ticketer/mcp/server/main.py +1343 -0
- mcp_ticketer/mcp/server/response_builder.py +206 -0
- mcp_ticketer/mcp/server/routing.py +723 -0
- mcp_ticketer/mcp/server/server_sdk.py +151 -0
- mcp_ticketer/mcp/server/tools/__init__.py +69 -0
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +224 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +330 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +1564 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +150 -0
- mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
- mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +318 -0
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1413 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +168 -136
- mcp_ticketer/queue/manager.py +78 -63
- mcp_ticketer/queue/queue.py +108 -21
- mcp_ticketer/queue/run_worker.py +2 -2
- mcp_ticketer/queue/ticket_registry.py +213 -155
- mcp_ticketer/queue/worker.py +96 -58
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.2.9.dist-info/METADATA +1396 -0
- mcp_ticketer-2.2.9.dist-info/RECORD +158 -0
- mcp_ticketer-2.2.9.dist-info/top_level.txt +2 -0
- py_mcp_installer/examples/phase3_demo.py +178 -0
- py_mcp_installer/scripts/manage_version.py +54 -0
- py_mcp_installer/setup.py +6 -0
- py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
- py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
- py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
- py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
- py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
- py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
- py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
- py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
- py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
- py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
- py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
- py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
- py_mcp_installer/src/py_mcp_installer/types.py +222 -0
- py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
- py_mcp_installer/tests/__init__.py +0 -0
- py_mcp_installer/tests/platforms/__init__.py +0 -0
- py_mcp_installer/tests/test_platform_detector.py +17 -0
- mcp_ticketer/adapters/github.py +0 -1354
- mcp_ticketer/adapters/jira.py +0 -1011
- mcp_ticketer/mcp/server.py +0 -1895
- mcp_ticketer-0.2.0.dist-info/METADATA +0 -414
- mcp_ticketer-0.2.0.dist-info/RECORD +0 -58
- mcp_ticketer-0.2.0.dist-info/top_level.txt +0 -1
- {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/licenses/LICENSE +0 -0
|
@@ -3,57 +3,72 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
7
9
|
|
|
8
10
|
try:
|
|
9
11
|
from gql import Client, gql
|
|
10
|
-
from gql.transport.
|
|
11
|
-
from gql.transport.
|
|
12
|
+
from gql.transport.exceptions import TransportError, TransportQueryError
|
|
13
|
+
from gql.transport.httpx import HTTPXAsyncTransport
|
|
12
14
|
except ImportError:
|
|
13
15
|
# Handle missing gql dependency gracefully
|
|
14
16
|
Client = None
|
|
15
17
|
gql = None
|
|
16
|
-
|
|
18
|
+
HTTPXAsyncTransport = None
|
|
17
19
|
TransportError = Exception
|
|
20
|
+
TransportQueryError = Exception
|
|
18
21
|
|
|
19
22
|
from ...core.exceptions import AdapterError, AuthenticationError, RateLimitError
|
|
20
23
|
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
21
26
|
|
|
22
27
|
class LinearGraphQLClient:
|
|
23
28
|
"""GraphQL client for Linear API with error handling and retry logic."""
|
|
24
|
-
|
|
29
|
+
|
|
25
30
|
def __init__(self, api_key: str, timeout: int = 30):
|
|
26
31
|
"""Initialize the Linear GraphQL client.
|
|
27
|
-
|
|
32
|
+
|
|
28
33
|
Args:
|
|
34
|
+
----
|
|
29
35
|
api_key: Linear API key
|
|
30
36
|
timeout: Request timeout in seconds
|
|
37
|
+
|
|
31
38
|
"""
|
|
32
39
|
self.api_key = api_key
|
|
33
40
|
self.timeout = timeout
|
|
34
41
|
self._base_url = "https://api.linear.app/graphql"
|
|
35
|
-
|
|
36
|
-
def create_client(self) ->
|
|
42
|
+
|
|
43
|
+
def create_client(self) -> Client:
|
|
37
44
|
"""Create a new GraphQL client instance.
|
|
38
45
|
|
|
39
46
|
Returns:
|
|
47
|
+
-------
|
|
40
48
|
Configured GraphQL client
|
|
41
49
|
|
|
42
50
|
Raises:
|
|
51
|
+
------
|
|
43
52
|
AuthenticationError: If API key is invalid
|
|
44
53
|
AdapterError: If client creation fails
|
|
54
|
+
|
|
45
55
|
"""
|
|
46
56
|
if Client is None:
|
|
47
|
-
raise AdapterError(
|
|
57
|
+
raise AdapterError(
|
|
58
|
+
"gql library not installed. Install with: pip install gql[httpx]",
|
|
59
|
+
"linear",
|
|
60
|
+
)
|
|
48
61
|
|
|
49
62
|
if not self.api_key:
|
|
50
63
|
raise AuthenticationError("Linear API key is required", "linear")
|
|
51
64
|
|
|
52
65
|
try:
|
|
53
66
|
# Create transport with authentication
|
|
54
|
-
|
|
67
|
+
# Linear API keys are passed directly (no Bearer prefix)
|
|
68
|
+
# Only OAuth tokens use Bearer scheme
|
|
69
|
+
transport = HTTPXAsyncTransport(
|
|
55
70
|
url=self._base_url,
|
|
56
|
-
headers={"Authorization":
|
|
71
|
+
headers={"Authorization": self.api_key},
|
|
57
72
|
timeout=self.timeout,
|
|
58
73
|
)
|
|
59
74
|
|
|
@@ -62,116 +77,264 @@ class LinearGraphQLClient:
|
|
|
62
77
|
return client
|
|
63
78
|
|
|
64
79
|
except Exception as e:
|
|
65
|
-
raise AdapterError(f"Failed to create Linear client: {e}", "linear")
|
|
66
|
-
|
|
80
|
+
raise AdapterError(f"Failed to create Linear client: {e}", "linear") from e
|
|
81
|
+
|
|
67
82
|
async def execute_query(
|
|
68
83
|
self,
|
|
69
84
|
query_string: str,
|
|
70
|
-
variables:
|
|
85
|
+
variables: dict[str, Any] | None = None,
|
|
71
86
|
retries: int = 3,
|
|
72
|
-
) ->
|
|
87
|
+
) -> dict[str, Any]:
|
|
73
88
|
"""Execute a GraphQL query with error handling and retries.
|
|
74
|
-
|
|
89
|
+
|
|
75
90
|
Args:
|
|
91
|
+
----
|
|
76
92
|
query_string: GraphQL query string
|
|
77
93
|
variables: Query variables
|
|
78
94
|
retries: Number of retry attempts
|
|
79
|
-
|
|
95
|
+
|
|
80
96
|
Returns:
|
|
97
|
+
-------
|
|
81
98
|
Query result data
|
|
82
|
-
|
|
99
|
+
|
|
83
100
|
Raises:
|
|
101
|
+
------
|
|
84
102
|
AuthenticationError: If authentication fails
|
|
85
103
|
RateLimitError: If rate limit is exceeded
|
|
86
104
|
AdapterError: If query execution fails
|
|
105
|
+
|
|
87
106
|
"""
|
|
88
107
|
query = gql(query_string)
|
|
89
|
-
|
|
108
|
+
|
|
109
|
+
# Extract operation name from query for logging
|
|
110
|
+
operation_name = "unknown"
|
|
111
|
+
try:
|
|
112
|
+
# Simple extraction - look for 'query' or 'mutation' keyword
|
|
113
|
+
query_lower = query_string.strip().lower()
|
|
114
|
+
if query_lower.startswith("mutation"):
|
|
115
|
+
operation_name = (
|
|
116
|
+
query_string.split("{")[0].strip().replace("mutation", "").strip()
|
|
117
|
+
)
|
|
118
|
+
elif query_lower.startswith("query"):
|
|
119
|
+
operation_name = (
|
|
120
|
+
query_string.split("{")[0].strip().replace("query", "").strip()
|
|
121
|
+
)
|
|
122
|
+
except Exception:
|
|
123
|
+
pass # Use default 'unknown' if extraction fails
|
|
124
|
+
|
|
90
125
|
for attempt in range(retries + 1):
|
|
91
126
|
try:
|
|
127
|
+
# Log request details before execution
|
|
128
|
+
logger.debug(
|
|
129
|
+
f"[Linear GraphQL] Executing operation: {operation_name}\n"
|
|
130
|
+
f"Variables:\n{json.dumps(variables or {}, indent=2, default=str)}"
|
|
131
|
+
)
|
|
132
|
+
|
|
92
133
|
client = self.create_client()
|
|
93
134
|
async with client as session:
|
|
94
|
-
result = await session.execute(
|
|
135
|
+
result = await session.execute(
|
|
136
|
+
query, variable_values=variables or {}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Log successful response
|
|
140
|
+
logger.debug(
|
|
141
|
+
f"[Linear GraphQL] Operation successful: {operation_name}\n"
|
|
142
|
+
f"Response:\n{json.dumps(result, indent=2, default=str)}"
|
|
143
|
+
)
|
|
144
|
+
|
|
95
145
|
return result
|
|
96
|
-
|
|
146
|
+
|
|
147
|
+
except TransportQueryError as e:
|
|
148
|
+
"""
|
|
149
|
+
Handle GraphQL validation errors (e.g., duplicate label names).
|
|
150
|
+
TransportQueryError is a subclass of TransportError with .errors attribute.
|
|
151
|
+
|
|
152
|
+
Related: 1M-398 - Label duplicate error handling
|
|
153
|
+
"""
|
|
154
|
+
# Log detailed error information
|
|
155
|
+
logger.error(
|
|
156
|
+
f"[Linear GraphQL] TransportQueryError occurred\n"
|
|
157
|
+
f"Operation: {operation_name}\n"
|
|
158
|
+
f"Variables:\n{json.dumps(variables or {}, indent=2, default=str)}\n"
|
|
159
|
+
f"Error: {e}\n"
|
|
160
|
+
f"Error details: {e.errors if hasattr(e, 'errors') else 'No error details'}"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if e.errors:
|
|
164
|
+
error = e.errors[0]
|
|
165
|
+
error_msg = error.get("message", "Unknown GraphQL error")
|
|
166
|
+
|
|
167
|
+
# Parse extensions for field-specific details (enhanced debugging)
|
|
168
|
+
extensions = error.get("extensions", {})
|
|
169
|
+
|
|
170
|
+
# Check for user-presentable message (clearer error for users)
|
|
171
|
+
user_message = extensions.get("userPresentableMessage")
|
|
172
|
+
if user_message:
|
|
173
|
+
error_msg = user_message
|
|
174
|
+
|
|
175
|
+
# Check for argument path (which field failed validation)
|
|
176
|
+
arg_path = extensions.get("argumentPath")
|
|
177
|
+
if arg_path:
|
|
178
|
+
field_path = ".".join(str(p) for p in arg_path)
|
|
179
|
+
error_msg = f"{error_msg} (field: {field_path})"
|
|
180
|
+
|
|
181
|
+
# Check for validation errors (additional context)
|
|
182
|
+
validation_errors = extensions.get("validationErrors")
|
|
183
|
+
if validation_errors:
|
|
184
|
+
error_msg = (
|
|
185
|
+
f"{error_msg}\nValidation errors: {validation_errors}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Log full error context for debugging
|
|
189
|
+
logger.error(
|
|
190
|
+
"Linear GraphQL error: %s (extensions: %s)",
|
|
191
|
+
error_msg,
|
|
192
|
+
extensions,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Check for duplicate label errors specifically
|
|
196
|
+
if (
|
|
197
|
+
"duplicate" in error_msg.lower()
|
|
198
|
+
and "label" in error_msg.lower()
|
|
199
|
+
):
|
|
200
|
+
raise AdapterError(
|
|
201
|
+
f"Label already exists: {error_msg}", "linear"
|
|
202
|
+
) from e
|
|
203
|
+
|
|
204
|
+
# Other validation errors
|
|
205
|
+
raise AdapterError(
|
|
206
|
+
f"Linear GraphQL validation error: {error_msg}", "linear"
|
|
207
|
+
) from e
|
|
208
|
+
|
|
209
|
+
# Fallback if no errors attribute
|
|
210
|
+
raise AdapterError(f"Linear GraphQL error: {e}", "linear") from e
|
|
211
|
+
|
|
97
212
|
except TransportError as e:
|
|
213
|
+
# Log transport error details
|
|
214
|
+
logger.error(
|
|
215
|
+
f"[Linear GraphQL] TransportError occurred\n"
|
|
216
|
+
f"Operation: {operation_name}\n"
|
|
217
|
+
f"Variables:\n{json.dumps(variables or {}, indent=2, default=str)}\n"
|
|
218
|
+
f"Error: {e}\n"
|
|
219
|
+
f"Status code: {e.response.status if hasattr(e, 'response') and e.response else 'N/A'}"
|
|
220
|
+
)
|
|
221
|
+
|
|
98
222
|
# Handle HTTP errors
|
|
99
|
-
if hasattr(e,
|
|
223
|
+
if hasattr(e, "response") and e.response:
|
|
100
224
|
status_code = e.response.status
|
|
101
|
-
|
|
225
|
+
|
|
102
226
|
if status_code == 401:
|
|
103
|
-
raise AuthenticationError(
|
|
227
|
+
raise AuthenticationError(
|
|
228
|
+
"Invalid Linear API key", "linear"
|
|
229
|
+
) from e
|
|
104
230
|
elif status_code == 403:
|
|
105
|
-
raise AuthenticationError(
|
|
231
|
+
raise AuthenticationError(
|
|
232
|
+
"Insufficient permissions", "linear"
|
|
233
|
+
) from e
|
|
106
234
|
elif status_code == 429:
|
|
107
235
|
# Rate limit exceeded
|
|
108
236
|
retry_after = e.response.headers.get("Retry-After", "60")
|
|
109
237
|
raise RateLimitError(
|
|
110
|
-
"Linear API rate limit exceeded",
|
|
111
|
-
|
|
112
|
-
retry_after
|
|
113
|
-
)
|
|
238
|
+
"Linear API rate limit exceeded", "linear", retry_after
|
|
239
|
+
) from e
|
|
114
240
|
elif status_code >= 500:
|
|
115
241
|
# Server error - retry
|
|
116
242
|
if attempt < retries:
|
|
117
|
-
await asyncio.sleep(2
|
|
243
|
+
await asyncio.sleep(2**attempt) # Exponential backoff
|
|
118
244
|
continue
|
|
119
|
-
raise AdapterError(
|
|
120
|
-
|
|
245
|
+
raise AdapterError(
|
|
246
|
+
f"Linear API server error: {status_code}", "linear"
|
|
247
|
+
) from e
|
|
248
|
+
|
|
121
249
|
# Network or other transport error
|
|
122
250
|
if attempt < retries:
|
|
123
|
-
await asyncio.sleep(2
|
|
251
|
+
await asyncio.sleep(2**attempt)
|
|
124
252
|
continue
|
|
125
|
-
raise AdapterError(f"Linear API transport error: {e}", "linear")
|
|
126
|
-
|
|
253
|
+
raise AdapterError(f"Linear API transport error: {e}", "linear") from e
|
|
254
|
+
|
|
127
255
|
except Exception as e:
|
|
256
|
+
# Log generic error details
|
|
257
|
+
logger.error(
|
|
258
|
+
f"[Linear GraphQL] Unexpected error occurred\n"
|
|
259
|
+
f"Operation: {operation_name}\n"
|
|
260
|
+
f"Variables:\n{json.dumps(variables or {}, indent=2, default=str)}\n"
|
|
261
|
+
f"Error type: {type(e).__name__}\n"
|
|
262
|
+
f"Error: {e}",
|
|
263
|
+
exc_info=True,
|
|
264
|
+
)
|
|
265
|
+
|
|
128
266
|
# GraphQL or other errors
|
|
129
267
|
error_msg = str(e)
|
|
130
|
-
|
|
268
|
+
|
|
131
269
|
# Check for specific GraphQL errors
|
|
132
|
-
if
|
|
133
|
-
|
|
270
|
+
if (
|
|
271
|
+
"authentication" in error_msg.lower()
|
|
272
|
+
or "unauthorized" in error_msg.lower()
|
|
273
|
+
):
|
|
274
|
+
raise AuthenticationError(
|
|
275
|
+
f"Linear authentication failed: {error_msg}", "linear"
|
|
276
|
+
) from e
|
|
134
277
|
elif "rate limit" in error_msg.lower():
|
|
135
|
-
raise RateLimitError(
|
|
136
|
-
|
|
278
|
+
raise RateLimitError(
|
|
279
|
+
"Linear API rate limit exceeded", "linear"
|
|
280
|
+
) from e
|
|
281
|
+
|
|
137
282
|
# Generic error
|
|
138
283
|
if attempt < retries:
|
|
139
|
-
await asyncio.sleep(2
|
|
284
|
+
await asyncio.sleep(2**attempt)
|
|
140
285
|
continue
|
|
141
|
-
raise AdapterError(
|
|
142
|
-
|
|
286
|
+
raise AdapterError(
|
|
287
|
+
f"Linear GraphQL error: {error_msg}", "linear"
|
|
288
|
+
) from e
|
|
289
|
+
|
|
143
290
|
# Should never reach here
|
|
144
291
|
raise AdapterError("Maximum retries exceeded", "linear")
|
|
145
|
-
|
|
292
|
+
|
|
146
293
|
async def execute_mutation(
|
|
147
294
|
self,
|
|
148
295
|
mutation_string: str,
|
|
149
|
-
variables:
|
|
296
|
+
variables: dict[str, Any] | None = None,
|
|
150
297
|
retries: int = 3,
|
|
151
|
-
) ->
|
|
298
|
+
) -> dict[str, Any]:
|
|
152
299
|
"""Execute a GraphQL mutation with error handling.
|
|
153
|
-
|
|
300
|
+
|
|
154
301
|
Args:
|
|
302
|
+
----
|
|
155
303
|
mutation_string: GraphQL mutation string
|
|
156
304
|
variables: Mutation variables
|
|
157
305
|
retries: Number of retry attempts
|
|
158
|
-
|
|
306
|
+
|
|
159
307
|
Returns:
|
|
308
|
+
-------
|
|
160
309
|
Mutation result data
|
|
161
|
-
|
|
310
|
+
|
|
162
311
|
Raises:
|
|
312
|
+
------
|
|
163
313
|
AuthenticationError: If authentication fails
|
|
164
314
|
RateLimitError: If rate limit is exceeded
|
|
165
315
|
AdapterError: If mutation execution fails
|
|
316
|
+
|
|
166
317
|
"""
|
|
167
318
|
return await self.execute_query(mutation_string, variables, retries)
|
|
168
|
-
|
|
319
|
+
|
|
169
320
|
async def test_connection(self) -> bool:
|
|
170
321
|
"""Test the connection to Linear API.
|
|
171
|
-
|
|
322
|
+
|
|
172
323
|
Returns:
|
|
324
|
+
-------
|
|
173
325
|
True if connection is successful, False otherwise
|
|
326
|
+
|
|
327
|
+
Design Decision: Enhanced Debug Logging (1M-431)
|
|
328
|
+
-------------------------------------------------
|
|
329
|
+
Added comprehensive logging to diagnose connection failures.
|
|
330
|
+
Logs API key preview, query results, and specific failure reasons
|
|
331
|
+
to help users troubleshoot authentication and configuration issues.
|
|
332
|
+
|
|
174
333
|
"""
|
|
334
|
+
import logging
|
|
335
|
+
|
|
336
|
+
logger = logging.getLogger(__name__)
|
|
337
|
+
|
|
175
338
|
try:
|
|
176
339
|
# Simple query to test authentication
|
|
177
340
|
test_query = """
|
|
@@ -179,24 +342,55 @@ class LinearGraphQLClient:
|
|
|
179
342
|
viewer {
|
|
180
343
|
id
|
|
181
344
|
name
|
|
345
|
+
email
|
|
182
346
|
}
|
|
183
347
|
}
|
|
184
348
|
"""
|
|
185
|
-
|
|
349
|
+
|
|
350
|
+
logger.debug(
|
|
351
|
+
f"Testing Linear API connection with API key: {self.api_key[:20]}..."
|
|
352
|
+
)
|
|
186
353
|
result = await self.execute_query(test_query)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
354
|
+
|
|
355
|
+
# Log the actual response for debugging
|
|
356
|
+
logger.debug(f"Linear API test response: {result}")
|
|
357
|
+
|
|
358
|
+
viewer = result.get("viewer")
|
|
359
|
+
|
|
360
|
+
if not viewer:
|
|
361
|
+
logger.warning(
|
|
362
|
+
f"Linear test connection query succeeded but returned no viewer data. "
|
|
363
|
+
f"Response: {result}"
|
|
364
|
+
)
|
|
365
|
+
return False
|
|
366
|
+
|
|
367
|
+
if not viewer.get("id"):
|
|
368
|
+
logger.warning(f"Linear viewer missing id field. Viewer data: {viewer}")
|
|
369
|
+
return False
|
|
370
|
+
|
|
371
|
+
logger.info(
|
|
372
|
+
f"Linear API connected successfully as: {viewer.get('name')} ({viewer.get('email')})"
|
|
373
|
+
)
|
|
374
|
+
return True
|
|
375
|
+
|
|
376
|
+
except Exception as e:
|
|
377
|
+
logger.error(
|
|
378
|
+
f"Linear connection test failed: {type(e).__name__}: {e}",
|
|
379
|
+
exc_info=True,
|
|
380
|
+
)
|
|
190
381
|
return False
|
|
191
|
-
|
|
192
|
-
async def get_team_info(self, team_id: str) ->
|
|
382
|
+
|
|
383
|
+
async def get_team_info(self, team_id: str) -> dict[str, Any] | None:
|
|
193
384
|
"""Get team information by ID.
|
|
194
|
-
|
|
385
|
+
|
|
195
386
|
Args:
|
|
387
|
+
----
|
|
196
388
|
team_id: Linear team ID
|
|
197
|
-
|
|
389
|
+
|
|
198
390
|
Returns:
|
|
391
|
+
-------
|
|
199
392
|
Team information or None if not found
|
|
393
|
+
|
|
200
394
|
"""
|
|
201
395
|
try:
|
|
202
396
|
query = """
|
|
@@ -209,21 +403,24 @@ class LinearGraphQLClient:
|
|
|
209
403
|
}
|
|
210
404
|
}
|
|
211
405
|
"""
|
|
212
|
-
|
|
406
|
+
|
|
213
407
|
result = await self.execute_query(query, {"teamId": team_id})
|
|
214
408
|
return result.get("team")
|
|
215
|
-
|
|
409
|
+
|
|
216
410
|
except Exception:
|
|
217
411
|
return None
|
|
218
|
-
|
|
219
|
-
async def get_user_by_email(self, email: str) ->
|
|
412
|
+
|
|
413
|
+
async def get_user_by_email(self, email: str) -> dict[str, Any] | None:
|
|
220
414
|
"""Get user information by email.
|
|
221
|
-
|
|
415
|
+
|
|
222
416
|
Args:
|
|
417
|
+
----
|
|
223
418
|
email: User email address
|
|
224
|
-
|
|
419
|
+
|
|
225
420
|
Returns:
|
|
421
|
+
-------
|
|
226
422
|
User information or None if not found
|
|
423
|
+
|
|
227
424
|
"""
|
|
228
425
|
try:
|
|
229
426
|
query = """
|
|
@@ -239,17 +436,63 @@ class LinearGraphQLClient:
|
|
|
239
436
|
}
|
|
240
437
|
}
|
|
241
438
|
"""
|
|
242
|
-
|
|
439
|
+
|
|
243
440
|
result = await self.execute_query(query, {"email": email})
|
|
244
441
|
users = result.get("users", {}).get("nodes", [])
|
|
245
442
|
return users[0] if users else None
|
|
246
|
-
|
|
443
|
+
|
|
247
444
|
except Exception:
|
|
248
445
|
return None
|
|
249
|
-
|
|
446
|
+
|
|
447
|
+
async def get_users_by_name(self, name: str) -> list[dict[str, Any]]:
|
|
448
|
+
"""Search users by display name or full name.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
----
|
|
452
|
+
name: Display name or full name to search for
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
-------
|
|
456
|
+
List of matching users (may be empty)
|
|
457
|
+
|
|
458
|
+
"""
|
|
459
|
+
import logging
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
query = """
|
|
463
|
+
query SearchUsers($nameFilter: String!) {
|
|
464
|
+
users(
|
|
465
|
+
filter: {
|
|
466
|
+
or: [
|
|
467
|
+
{ displayName: { containsIgnoreCase: $nameFilter } }
|
|
468
|
+
{ name: { containsIgnoreCase: $nameFilter } }
|
|
469
|
+
]
|
|
470
|
+
}
|
|
471
|
+
first: 10
|
|
472
|
+
) {
|
|
473
|
+
nodes {
|
|
474
|
+
id
|
|
475
|
+
name
|
|
476
|
+
email
|
|
477
|
+
displayName
|
|
478
|
+
avatarUrl
|
|
479
|
+
active
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
"""
|
|
484
|
+
|
|
485
|
+
result = await self.execute_query(query, {"nameFilter": name})
|
|
486
|
+
users = result.get("users", {}).get("nodes", [])
|
|
487
|
+
return [u for u in users if u.get("active", True)] # Filter active users
|
|
488
|
+
|
|
489
|
+
except Exception as e:
|
|
490
|
+
logging.getLogger(__name__).warning(f"Failed to search users by name: {e}")
|
|
491
|
+
return []
|
|
492
|
+
|
|
250
493
|
async def close(self) -> None:
|
|
251
494
|
"""Close the client connection.
|
|
252
|
-
|
|
495
|
+
|
|
253
496
|
Since we create fresh clients for each operation, there's no persistent
|
|
254
497
|
connection to close. Each client's transport is automatically closed when
|
|
255
498
|
the async context manager exits.
|