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.
Files changed (160) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +3 -3
  3. mcp_ticketer/_version_scm.py +1 -0
  4. mcp_ticketer/adapters/__init__.py +2 -0
  5. mcp_ticketer/adapters/aitrackdown.py +930 -52
  6. mcp_ticketer/adapters/asana/__init__.py +15 -0
  7. mcp_ticketer/adapters/asana/adapter.py +1537 -0
  8. mcp_ticketer/adapters/asana/client.py +292 -0
  9. mcp_ticketer/adapters/asana/mappers.py +348 -0
  10. mcp_ticketer/adapters/asana/types.py +146 -0
  11. mcp_ticketer/adapters/github/__init__.py +26 -0
  12. mcp_ticketer/adapters/github/adapter.py +3229 -0
  13. mcp_ticketer/adapters/github/client.py +335 -0
  14. mcp_ticketer/adapters/github/mappers.py +797 -0
  15. mcp_ticketer/adapters/github/queries.py +692 -0
  16. mcp_ticketer/adapters/github/types.py +460 -0
  17. mcp_ticketer/adapters/hybrid.py +58 -16
  18. mcp_ticketer/adapters/jira/__init__.py +35 -0
  19. mcp_ticketer/adapters/jira/adapter.py +1351 -0
  20. mcp_ticketer/adapters/jira/client.py +271 -0
  21. mcp_ticketer/adapters/jira/mappers.py +246 -0
  22. mcp_ticketer/adapters/jira/queries.py +216 -0
  23. mcp_ticketer/adapters/jira/types.py +304 -0
  24. mcp_ticketer/adapters/linear/__init__.py +1 -1
  25. mcp_ticketer/adapters/linear/adapter.py +3810 -462
  26. mcp_ticketer/adapters/linear/client.py +312 -69
  27. mcp_ticketer/adapters/linear/mappers.py +305 -85
  28. mcp_ticketer/adapters/linear/queries.py +317 -17
  29. mcp_ticketer/adapters/linear/types.py +187 -64
  30. mcp_ticketer/adapters/linear.py +2 -2
  31. mcp_ticketer/analysis/__init__.py +56 -0
  32. mcp_ticketer/analysis/dependency_graph.py +255 -0
  33. mcp_ticketer/analysis/health_assessment.py +304 -0
  34. mcp_ticketer/analysis/orphaned.py +218 -0
  35. mcp_ticketer/analysis/project_status.py +594 -0
  36. mcp_ticketer/analysis/similarity.py +224 -0
  37. mcp_ticketer/analysis/staleness.py +266 -0
  38. mcp_ticketer/automation/__init__.py +11 -0
  39. mcp_ticketer/automation/project_updates.py +378 -0
  40. mcp_ticketer/cache/memory.py +9 -8
  41. mcp_ticketer/cli/adapter_diagnostics.py +421 -0
  42. mcp_ticketer/cli/auggie_configure.py +116 -15
  43. mcp_ticketer/cli/codex_configure.py +274 -82
  44. mcp_ticketer/cli/configure.py +1323 -151
  45. mcp_ticketer/cli/cursor_configure.py +314 -0
  46. mcp_ticketer/cli/diagnostics.py +209 -114
  47. mcp_ticketer/cli/discover.py +297 -26
  48. mcp_ticketer/cli/gemini_configure.py +119 -26
  49. mcp_ticketer/cli/init_command.py +880 -0
  50. mcp_ticketer/cli/install_mcp_server.py +418 -0
  51. mcp_ticketer/cli/instruction_commands.py +435 -0
  52. mcp_ticketer/cli/linear_commands.py +256 -130
  53. mcp_ticketer/cli/main.py +140 -1284
  54. mcp_ticketer/cli/mcp_configure.py +1013 -100
  55. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  56. mcp_ticketer/cli/migrate_config.py +12 -8
  57. mcp_ticketer/cli/platform_commands.py +123 -0
  58. mcp_ticketer/cli/platform_detection.py +477 -0
  59. mcp_ticketer/cli/platform_installer.py +545 -0
  60. mcp_ticketer/cli/project_update_commands.py +350 -0
  61. mcp_ticketer/cli/python_detection.py +126 -0
  62. mcp_ticketer/cli/queue_commands.py +15 -15
  63. mcp_ticketer/cli/setup_command.py +794 -0
  64. mcp_ticketer/cli/simple_health.py +84 -59
  65. mcp_ticketer/cli/ticket_commands.py +1375 -0
  66. mcp_ticketer/cli/update_checker.py +313 -0
  67. mcp_ticketer/cli/utils.py +195 -72
  68. mcp_ticketer/core/__init__.py +64 -1
  69. mcp_ticketer/core/adapter.py +618 -18
  70. mcp_ticketer/core/config.py +77 -68
  71. mcp_ticketer/core/env_discovery.py +75 -16
  72. mcp_ticketer/core/env_loader.py +121 -97
  73. mcp_ticketer/core/exceptions.py +32 -24
  74. mcp_ticketer/core/http_client.py +26 -26
  75. mcp_ticketer/core/instructions.py +405 -0
  76. mcp_ticketer/core/label_manager.py +732 -0
  77. mcp_ticketer/core/mappers.py +42 -30
  78. mcp_ticketer/core/milestone_manager.py +252 -0
  79. mcp_ticketer/core/models.py +566 -19
  80. mcp_ticketer/core/onepassword_secrets.py +379 -0
  81. mcp_ticketer/core/priority_matcher.py +463 -0
  82. mcp_ticketer/core/project_config.py +189 -49
  83. mcp_ticketer/core/project_utils.py +281 -0
  84. mcp_ticketer/core/project_validator.py +376 -0
  85. mcp_ticketer/core/registry.py +3 -3
  86. mcp_ticketer/core/session_state.py +176 -0
  87. mcp_ticketer/core/state_matcher.py +592 -0
  88. mcp_ticketer/core/url_parser.py +425 -0
  89. mcp_ticketer/core/validators.py +69 -0
  90. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  91. mcp_ticketer/mcp/__init__.py +29 -1
  92. mcp_ticketer/mcp/__main__.py +60 -0
  93. mcp_ticketer/mcp/server/__init__.py +25 -0
  94. mcp_ticketer/mcp/server/__main__.py +60 -0
  95. mcp_ticketer/mcp/server/constants.py +58 -0
  96. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  97. mcp_ticketer/mcp/server/dto.py +195 -0
  98. mcp_ticketer/mcp/server/main.py +1343 -0
  99. mcp_ticketer/mcp/server/response_builder.py +206 -0
  100. mcp_ticketer/mcp/server/routing.py +723 -0
  101. mcp_ticketer/mcp/server/server_sdk.py +151 -0
  102. mcp_ticketer/mcp/server/tools/__init__.py +69 -0
  103. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  104. mcp_ticketer/mcp/server/tools/attachment_tools.py +224 -0
  105. mcp_ticketer/mcp/server/tools/bulk_tools.py +330 -0
  106. mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
  107. mcp_ticketer/mcp/server/tools/config_tools.py +1564 -0
  108. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  109. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +942 -0
  110. mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
  111. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  112. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  113. mcp_ticketer/mcp/server/tools/pr_tools.py +150 -0
  114. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  115. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  116. mcp_ticketer/mcp/server/tools/search_tools.py +318 -0
  117. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  118. mcp_ticketer/mcp/server/tools/ticket_tools.py +1413 -0
  119. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
  120. mcp_ticketer/queue/__init__.py +1 -0
  121. mcp_ticketer/queue/health_monitor.py +168 -136
  122. mcp_ticketer/queue/manager.py +78 -63
  123. mcp_ticketer/queue/queue.py +108 -21
  124. mcp_ticketer/queue/run_worker.py +2 -2
  125. mcp_ticketer/queue/ticket_registry.py +213 -155
  126. mcp_ticketer/queue/worker.py +96 -58
  127. mcp_ticketer/utils/__init__.py +5 -0
  128. mcp_ticketer/utils/token_utils.py +246 -0
  129. mcp_ticketer-2.2.9.dist-info/METADATA +1396 -0
  130. mcp_ticketer-2.2.9.dist-info/RECORD +158 -0
  131. mcp_ticketer-2.2.9.dist-info/top_level.txt +2 -0
  132. py_mcp_installer/examples/phase3_demo.py +178 -0
  133. py_mcp_installer/scripts/manage_version.py +54 -0
  134. py_mcp_installer/setup.py +6 -0
  135. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  136. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  137. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  138. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  139. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  140. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  141. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  142. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  143. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  144. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  145. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  146. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  147. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  148. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  149. py_mcp_installer/tests/__init__.py +0 -0
  150. py_mcp_installer/tests/platforms/__init__.py +0 -0
  151. py_mcp_installer/tests/test_platform_detector.py +17 -0
  152. mcp_ticketer/adapters/github.py +0 -1354
  153. mcp_ticketer/adapters/jira.py +0 -1011
  154. mcp_ticketer/mcp/server.py +0 -1895
  155. mcp_ticketer-0.2.0.dist-info/METADATA +0 -414
  156. mcp_ticketer-0.2.0.dist-info/RECORD +0 -58
  157. mcp_ticketer-0.2.0.dist-info/top_level.txt +0 -1
  158. {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
  159. {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
  160. {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
- from typing import Any, Dict, Optional
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.aiohttp import AIOHTTPTransport
11
- from gql.transport.exceptions import TransportError
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
- AIOHTTPTransport = None
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) -> "Client":
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("gql library not installed. Install with: pip install gql[aiohttp]", "linear")
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
- transport = AIOHTTPTransport(
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": f"Bearer {self.api_key}"},
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: Optional[Dict[str, Any]] = None,
85
+ variables: dict[str, Any] | None = None,
71
86
  retries: int = 3,
72
- ) -> Dict[str, Any]:
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(query, variable_values=variables or {})
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, 'response') and e.response:
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("Invalid Linear API key", "linear")
227
+ raise AuthenticationError(
228
+ "Invalid Linear API key", "linear"
229
+ ) from e
104
230
  elif status_code == 403:
105
- raise AuthenticationError("Insufficient permissions", "linear")
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
- "linear",
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 ** attempt) # Exponential backoff
243
+ await asyncio.sleep(2**attempt) # Exponential backoff
118
244
  continue
119
- raise AdapterError(f"Linear API server error: {status_code}", "linear")
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 ** attempt)
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 "authentication" in error_msg.lower() or "unauthorized" in error_msg.lower():
133
- raise AuthenticationError(f"Linear authentication failed: {error_msg}", "linear")
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("Linear API rate limit exceeded", "linear")
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 ** attempt)
284
+ await asyncio.sleep(2**attempt)
140
285
  continue
141
- raise AdapterError(f"Linear GraphQL error: {error_msg}", "linear")
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: Optional[Dict[str, Any]] = None,
296
+ variables: dict[str, Any] | None = None,
150
297
  retries: int = 3,
151
- ) -> Dict[str, Any]:
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
- return bool(result.get("viewer"))
188
-
189
- except Exception:
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) -> Optional[Dict[str, Any]]:
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) -> Optional[Dict[str, Any]]:
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.