mcp-ticketer 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of mcp-ticketer might be problematic. Click here for more details.
- mcp_ticketer/__init__.py +27 -0
- mcp_ticketer/__version__.py +40 -0
- mcp_ticketer/adapters/__init__.py +8 -0
- mcp_ticketer/adapters/aitrackdown.py +396 -0
- mcp_ticketer/adapters/github.py +974 -0
- mcp_ticketer/adapters/jira.py +831 -0
- mcp_ticketer/adapters/linear.py +1355 -0
- mcp_ticketer/cache/__init__.py +5 -0
- mcp_ticketer/cache/memory.py +193 -0
- mcp_ticketer/cli/__init__.py +5 -0
- mcp_ticketer/cli/main.py +812 -0
- mcp_ticketer/cli/queue_commands.py +285 -0
- mcp_ticketer/cli/utils.py +523 -0
- mcp_ticketer/core/__init__.py +15 -0
- mcp_ticketer/core/adapter.py +211 -0
- mcp_ticketer/core/config.py +403 -0
- mcp_ticketer/core/http_client.py +430 -0
- mcp_ticketer/core/mappers.py +492 -0
- mcp_ticketer/core/models.py +111 -0
- mcp_ticketer/core/registry.py +128 -0
- mcp_ticketer/mcp/__init__.py +5 -0
- mcp_ticketer/mcp/server.py +459 -0
- mcp_ticketer/py.typed +0 -0
- mcp_ticketer/queue/__init__.py +7 -0
- mcp_ticketer/queue/__main__.py +6 -0
- mcp_ticketer/queue/manager.py +261 -0
- mcp_ticketer/queue/queue.py +357 -0
- mcp_ticketer/queue/run_worker.py +38 -0
- mcp_ticketer/queue/worker.py +425 -0
- mcp_ticketer-0.1.1.dist-info/METADATA +362 -0
- mcp_ticketer-0.1.1.dist-info/RECORD +35 -0
- mcp_ticketer-0.1.1.dist-info/WHEEL +5 -0
- mcp_ticketer-0.1.1.dist-info/entry_points.txt +3 -0
- mcp_ticketer-0.1.1.dist-info/licenses/LICENSE +21 -0
- mcp_ticketer-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""Base HTTP client with retry, rate limiting, and error handling."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Dict, Any, Optional, List, Union, Callable
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from enum import Enum
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from httpx import AsyncClient, HTTPStatusError, TimeoutException
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HTTPMethod(str, Enum):
|
|
17
|
+
"""HTTP methods."""
|
|
18
|
+
GET = "GET"
|
|
19
|
+
POST = "POST"
|
|
20
|
+
PUT = "PUT"
|
|
21
|
+
PATCH = "PATCH"
|
|
22
|
+
DELETE = "DELETE"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RetryConfig:
|
|
26
|
+
"""Configuration for retry logic."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
max_retries: int = 3,
|
|
31
|
+
initial_delay: float = 1.0,
|
|
32
|
+
max_delay: float = 60.0,
|
|
33
|
+
exponential_base: float = 2.0,
|
|
34
|
+
jitter: bool = True,
|
|
35
|
+
retry_on_status: Optional[List[int]] = None,
|
|
36
|
+
retry_on_exceptions: Optional[List[type]] = None
|
|
37
|
+
):
|
|
38
|
+
self.max_retries = max_retries
|
|
39
|
+
self.initial_delay = initial_delay
|
|
40
|
+
self.max_delay = max_delay
|
|
41
|
+
self.exponential_base = exponential_base
|
|
42
|
+
self.jitter = jitter
|
|
43
|
+
self.retry_on_status = retry_on_status or [429, 502, 503, 504]
|
|
44
|
+
self.retry_on_exceptions = retry_on_exceptions or [TimeoutException, httpx.ConnectTimeout, httpx.ReadTimeout]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RateLimiter:
|
|
48
|
+
"""Token bucket rate limiter."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, max_requests: int, time_window: float):
|
|
51
|
+
"""Initialize rate limiter.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
max_requests: Maximum number of requests allowed
|
|
55
|
+
time_window: Time window in seconds
|
|
56
|
+
"""
|
|
57
|
+
self.max_requests = max_requests
|
|
58
|
+
self.time_window = time_window
|
|
59
|
+
self.tokens = max_requests
|
|
60
|
+
self.last_update = time.time()
|
|
61
|
+
self._lock = asyncio.Lock()
|
|
62
|
+
|
|
63
|
+
async def acquire(self) -> None:
|
|
64
|
+
"""Acquire a token for making a request."""
|
|
65
|
+
async with self._lock:
|
|
66
|
+
now = time.time()
|
|
67
|
+
|
|
68
|
+
# Refill tokens based on time passed
|
|
69
|
+
time_passed = now - self.last_update
|
|
70
|
+
self.tokens = min(
|
|
71
|
+
self.max_requests,
|
|
72
|
+
self.tokens + (time_passed / self.time_window) * self.max_requests
|
|
73
|
+
)
|
|
74
|
+
self.last_update = now
|
|
75
|
+
|
|
76
|
+
if self.tokens >= 1:
|
|
77
|
+
self.tokens -= 1
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
# Wait until we can get a token
|
|
81
|
+
wait_time = (1 - self.tokens) * (self.time_window / self.max_requests)
|
|
82
|
+
await asyncio.sleep(wait_time)
|
|
83
|
+
self.tokens = 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class BaseHTTPClient:
|
|
87
|
+
"""Base HTTP client with retry logic, rate limiting, and error handling."""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
base_url: str,
|
|
92
|
+
headers: Optional[Dict[str, str]] = None,
|
|
93
|
+
auth: Optional[Union[httpx.Auth, tuple]] = None,
|
|
94
|
+
timeout: float = 30.0,
|
|
95
|
+
retry_config: Optional[RetryConfig] = None,
|
|
96
|
+
rate_limiter: Optional[RateLimiter] = None,
|
|
97
|
+
verify_ssl: bool = True,
|
|
98
|
+
follow_redirects: bool = True
|
|
99
|
+
):
|
|
100
|
+
"""Initialize HTTP client.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
base_url: Base URL for requests
|
|
104
|
+
headers: Default headers
|
|
105
|
+
auth: Authentication (httpx.Auth or (username, password) tuple)
|
|
106
|
+
timeout: Request timeout in seconds
|
|
107
|
+
retry_config: Retry configuration
|
|
108
|
+
rate_limiter: Rate limiter instance
|
|
109
|
+
verify_ssl: Whether to verify SSL certificates
|
|
110
|
+
follow_redirects: Whether to follow redirects
|
|
111
|
+
"""
|
|
112
|
+
self.base_url = base_url.rstrip("/")
|
|
113
|
+
self.default_headers = headers or {}
|
|
114
|
+
self.auth = auth
|
|
115
|
+
self.timeout = timeout
|
|
116
|
+
self.retry_config = retry_config or RetryConfig()
|
|
117
|
+
self.rate_limiter = rate_limiter
|
|
118
|
+
self.verify_ssl = verify_ssl
|
|
119
|
+
self.follow_redirects = follow_redirects
|
|
120
|
+
|
|
121
|
+
# Statistics
|
|
122
|
+
self.stats = {
|
|
123
|
+
"requests_made": 0,
|
|
124
|
+
"retries_performed": 0,
|
|
125
|
+
"rate_limit_waits": 0,
|
|
126
|
+
"errors": 0,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
self._client: Optional[AsyncClient] = None
|
|
130
|
+
|
|
131
|
+
async def _get_client(self) -> AsyncClient:
|
|
132
|
+
"""Get or create HTTP client instance."""
|
|
133
|
+
if self._client is None:
|
|
134
|
+
self._client = AsyncClient(
|
|
135
|
+
base_url=self.base_url,
|
|
136
|
+
headers=self.default_headers,
|
|
137
|
+
auth=self.auth,
|
|
138
|
+
timeout=self.timeout,
|
|
139
|
+
verify=self.verify_ssl,
|
|
140
|
+
follow_redirects=self.follow_redirects
|
|
141
|
+
)
|
|
142
|
+
return self._client
|
|
143
|
+
|
|
144
|
+
async def _calculate_delay(self, attempt: int, response: Optional[httpx.Response] = None) -> float:
|
|
145
|
+
"""Calculate delay for retry attempt."""
|
|
146
|
+
if response and response.status_code == 429:
|
|
147
|
+
# Use Retry-After header if available
|
|
148
|
+
retry_after = response.headers.get("Retry-After")
|
|
149
|
+
if retry_after:
|
|
150
|
+
try:
|
|
151
|
+
return float(retry_after)
|
|
152
|
+
except ValueError:
|
|
153
|
+
# Retry-After might be a date
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
# Exponential backoff
|
|
157
|
+
delay = self.retry_config.initial_delay * (
|
|
158
|
+
self.retry_config.exponential_base ** (attempt - 1)
|
|
159
|
+
)
|
|
160
|
+
delay = min(delay, self.retry_config.max_delay)
|
|
161
|
+
|
|
162
|
+
# Add jitter to prevent thundering herd
|
|
163
|
+
if self.retry_config.jitter:
|
|
164
|
+
import random
|
|
165
|
+
delay *= (0.5 + random.random() * 0.5)
|
|
166
|
+
|
|
167
|
+
return delay
|
|
168
|
+
|
|
169
|
+
def _should_retry(
|
|
170
|
+
self,
|
|
171
|
+
exception: Exception,
|
|
172
|
+
response: Optional[httpx.Response] = None,
|
|
173
|
+
attempt: int = 1
|
|
174
|
+
) -> bool:
|
|
175
|
+
"""Determine if request should be retried."""
|
|
176
|
+
if attempt >= self.retry_config.max_retries:
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
# Check response status codes
|
|
180
|
+
if response and response.status_code in self.retry_config.retry_on_status:
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
# Check exception types
|
|
184
|
+
for exc_type in self.retry_config.retry_on_exceptions:
|
|
185
|
+
if isinstance(exception, exc_type):
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
async def request(
|
|
191
|
+
self,
|
|
192
|
+
method: Union[HTTPMethod, str],
|
|
193
|
+
endpoint: str,
|
|
194
|
+
data: Optional[Dict[str, Any]] = None,
|
|
195
|
+
json: Optional[Dict[str, Any]] = None,
|
|
196
|
+
params: Optional[Dict[str, Any]] = None,
|
|
197
|
+
headers: Optional[Dict[str, str]] = None,
|
|
198
|
+
timeout: Optional[float] = None,
|
|
199
|
+
retry_count: int = 0,
|
|
200
|
+
**kwargs
|
|
201
|
+
) -> httpx.Response:
|
|
202
|
+
"""Make HTTP request with retry and rate limiting.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
method: HTTP method
|
|
206
|
+
endpoint: API endpoint (relative to base_url)
|
|
207
|
+
data: Form data
|
|
208
|
+
json: JSON data
|
|
209
|
+
params: Query parameters
|
|
210
|
+
headers: Additional headers
|
|
211
|
+
timeout: Request timeout override
|
|
212
|
+
retry_count: Current retry attempt
|
|
213
|
+
**kwargs: Additional httpx arguments
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
HTTP response
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
HTTPStatusError: On HTTP errors
|
|
220
|
+
TimeoutException: On timeout
|
|
221
|
+
"""
|
|
222
|
+
# Rate limiting
|
|
223
|
+
if self.rate_limiter:
|
|
224
|
+
await self.rate_limiter.acquire()
|
|
225
|
+
if retry_count == 0: # Only count first attempts
|
|
226
|
+
self.stats["rate_limit_waits"] += 1
|
|
227
|
+
|
|
228
|
+
# Prepare request
|
|
229
|
+
url = f"{self.base_url}/{endpoint.lstrip('/')}" if endpoint else self.base_url
|
|
230
|
+
|
|
231
|
+
request_headers = self.default_headers.copy()
|
|
232
|
+
if headers:
|
|
233
|
+
request_headers.update(headers)
|
|
234
|
+
|
|
235
|
+
client = await self._get_client()
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
response = await client.request(
|
|
239
|
+
method=str(method),
|
|
240
|
+
url=url,
|
|
241
|
+
data=data,
|
|
242
|
+
json=json,
|
|
243
|
+
params=params,
|
|
244
|
+
headers=request_headers,
|
|
245
|
+
timeout=timeout or self.timeout,
|
|
246
|
+
**kwargs
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Update stats
|
|
250
|
+
self.stats["requests_made"] += 1
|
|
251
|
+
if retry_count > 0:
|
|
252
|
+
self.stats["retries_performed"] += 1
|
|
253
|
+
|
|
254
|
+
response.raise_for_status()
|
|
255
|
+
return response
|
|
256
|
+
|
|
257
|
+
except Exception as e:
|
|
258
|
+
self.stats["errors"] += 1
|
|
259
|
+
|
|
260
|
+
# Check if we should retry
|
|
261
|
+
response = getattr(e, 'response', None)
|
|
262
|
+
if self._should_retry(e, response, retry_count + 1):
|
|
263
|
+
delay = await self._calculate_delay(retry_count + 1, response)
|
|
264
|
+
|
|
265
|
+
logger.warning(
|
|
266
|
+
f"Request failed (attempt {retry_count + 1}/{self.retry_config.max_retries}), "
|
|
267
|
+
f"retrying in {delay:.2f}s: {e}"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
await asyncio.sleep(delay)
|
|
271
|
+
return await self.request(
|
|
272
|
+
method, endpoint, data, json, params, headers, timeout, retry_count + 1, **kwargs
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# No more retries, re-raise the exception
|
|
276
|
+
raise
|
|
277
|
+
|
|
278
|
+
async def get(self, endpoint: str, **kwargs) -> httpx.Response:
|
|
279
|
+
"""Make GET request."""
|
|
280
|
+
return await self.request(HTTPMethod.GET, endpoint, **kwargs)
|
|
281
|
+
|
|
282
|
+
async def post(self, endpoint: str, **kwargs) -> httpx.Response:
|
|
283
|
+
"""Make POST request."""
|
|
284
|
+
return await self.request(HTTPMethod.POST, endpoint, **kwargs)
|
|
285
|
+
|
|
286
|
+
async def put(self, endpoint: str, **kwargs) -> httpx.Response:
|
|
287
|
+
"""Make PUT request."""
|
|
288
|
+
return await self.request(HTTPMethod.PUT, endpoint, **kwargs)
|
|
289
|
+
|
|
290
|
+
async def patch(self, endpoint: str, **kwargs) -> httpx.Response:
|
|
291
|
+
"""Make PATCH request."""
|
|
292
|
+
return await self.request(HTTPMethod.PATCH, endpoint, **kwargs)
|
|
293
|
+
|
|
294
|
+
async def delete(self, endpoint: str, **kwargs) -> httpx.Response:
|
|
295
|
+
"""Make DELETE request."""
|
|
296
|
+
return await self.request(HTTPMethod.DELETE, endpoint, **kwargs)
|
|
297
|
+
|
|
298
|
+
async def get_json(self, endpoint: str, **kwargs) -> Dict[str, Any]:
|
|
299
|
+
"""Make GET request and return JSON response."""
|
|
300
|
+
response = await self.get(endpoint, **kwargs)
|
|
301
|
+
|
|
302
|
+
# Handle empty responses
|
|
303
|
+
if response.status_code == 204 or not response.content:
|
|
304
|
+
return {}
|
|
305
|
+
|
|
306
|
+
return response.json()
|
|
307
|
+
|
|
308
|
+
async def post_json(self, endpoint: str, **kwargs) -> Dict[str, Any]:
|
|
309
|
+
"""Make POST request and return JSON response."""
|
|
310
|
+
response = await self.post(endpoint, **kwargs)
|
|
311
|
+
|
|
312
|
+
# Handle empty responses
|
|
313
|
+
if response.status_code == 204 or not response.content:
|
|
314
|
+
return {}
|
|
315
|
+
|
|
316
|
+
return response.json()
|
|
317
|
+
|
|
318
|
+
async def put_json(self, endpoint: str, **kwargs) -> Dict[str, Any]:
|
|
319
|
+
"""Make PUT request and return JSON response."""
|
|
320
|
+
response = await self.put(endpoint, **kwargs)
|
|
321
|
+
|
|
322
|
+
# Handle empty responses
|
|
323
|
+
if response.status_code == 204 or not response.content:
|
|
324
|
+
return {}
|
|
325
|
+
|
|
326
|
+
return response.json()
|
|
327
|
+
|
|
328
|
+
async def patch_json(self, endpoint: str, **kwargs) -> Dict[str, Any]:
|
|
329
|
+
"""Make PATCH request and return JSON response."""
|
|
330
|
+
response = await self.patch(endpoint, **kwargs)
|
|
331
|
+
|
|
332
|
+
# Handle empty responses
|
|
333
|
+
if response.status_code == 204 or not response.content:
|
|
334
|
+
return {}
|
|
335
|
+
|
|
336
|
+
return response.json()
|
|
337
|
+
|
|
338
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
339
|
+
"""Get client statistics."""
|
|
340
|
+
return self.stats.copy()
|
|
341
|
+
|
|
342
|
+
def reset_stats(self) -> None:
|
|
343
|
+
"""Reset client statistics."""
|
|
344
|
+
self.stats = {
|
|
345
|
+
"requests_made": 0,
|
|
346
|
+
"retries_performed": 0,
|
|
347
|
+
"rate_limit_waits": 0,
|
|
348
|
+
"errors": 0,
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async def close(self) -> None:
|
|
352
|
+
"""Close the HTTP client."""
|
|
353
|
+
if self._client:
|
|
354
|
+
await self._client.aclose()
|
|
355
|
+
self._client = None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class GitHubHTTPClient(BaseHTTPClient):
|
|
359
|
+
"""GitHub-specific HTTP client with rate limiting."""
|
|
360
|
+
|
|
361
|
+
def __init__(self, token: str, api_url: str = "https://api.github.com"):
|
|
362
|
+
"""Initialize GitHub HTTP client.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
token: GitHub API token
|
|
366
|
+
api_url: GitHub API URL
|
|
367
|
+
"""
|
|
368
|
+
headers = {
|
|
369
|
+
"Authorization": f"Bearer {token}",
|
|
370
|
+
"Accept": "application/vnd.github.v3+json",
|
|
371
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
# GitHub rate limiting: 5000 requests per hour for authenticated requests
|
|
375
|
+
rate_limiter = RateLimiter(max_requests=5000, time_window=3600)
|
|
376
|
+
|
|
377
|
+
super().__init__(
|
|
378
|
+
base_url=api_url,
|
|
379
|
+
headers=headers,
|
|
380
|
+
rate_limiter=rate_limiter,
|
|
381
|
+
retry_config=RetryConfig(
|
|
382
|
+
max_retries=3,
|
|
383
|
+
retry_on_status=[429, 502, 503, 504, 522, 524]
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class JiraHTTPClient(BaseHTTPClient):
|
|
389
|
+
"""JIRA-specific HTTP client with authentication and retry logic."""
|
|
390
|
+
|
|
391
|
+
def __init__(
|
|
392
|
+
self,
|
|
393
|
+
email: str,
|
|
394
|
+
api_token: str,
|
|
395
|
+
server_url: str,
|
|
396
|
+
is_cloud: bool = True,
|
|
397
|
+
verify_ssl: bool = True
|
|
398
|
+
):
|
|
399
|
+
"""Initialize JIRA HTTP client.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
email: User email
|
|
403
|
+
api_token: API token
|
|
404
|
+
server_url: JIRA server URL
|
|
405
|
+
is_cloud: Whether this is JIRA Cloud
|
|
406
|
+
verify_ssl: Whether to verify SSL certificates
|
|
407
|
+
"""
|
|
408
|
+
api_base = f"{server_url}/rest/api/3" if is_cloud else f"{server_url}/rest/api/2"
|
|
409
|
+
|
|
410
|
+
headers = {
|
|
411
|
+
"Accept": "application/json",
|
|
412
|
+
"Content-Type": "application/json"
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
auth = httpx.BasicAuth(email, api_token)
|
|
416
|
+
|
|
417
|
+
# JIRA rate limiting varies by plan, using conservative limits
|
|
418
|
+
rate_limiter = RateLimiter(max_requests=100, time_window=60)
|
|
419
|
+
|
|
420
|
+
super().__init__(
|
|
421
|
+
base_url=api_base,
|
|
422
|
+
headers=headers,
|
|
423
|
+
auth=auth,
|
|
424
|
+
rate_limiter=rate_limiter,
|
|
425
|
+
verify_ssl=verify_ssl,
|
|
426
|
+
retry_config=RetryConfig(
|
|
427
|
+
max_retries=3,
|
|
428
|
+
retry_on_status=[429, 502, 503, 504]
|
|
429
|
+
)
|
|
430
|
+
)
|