mcp-ticketer 0.1.21__py3-none-any.whl → 0.1.23__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 +7 -7
- mcp_ticketer/__version__.py +4 -2
- mcp_ticketer/adapters/__init__.py +4 -4
- mcp_ticketer/adapters/aitrackdown.py +66 -49
- mcp_ticketer/adapters/github.py +192 -125
- mcp_ticketer/adapters/hybrid.py +99 -53
- mcp_ticketer/adapters/jira.py +161 -151
- mcp_ticketer/adapters/linear.py +396 -246
- mcp_ticketer/cache/__init__.py +1 -1
- mcp_ticketer/cache/memory.py +15 -16
- mcp_ticketer/cli/__init__.py +1 -1
- mcp_ticketer/cli/configure.py +69 -93
- mcp_ticketer/cli/discover.py +43 -35
- mcp_ticketer/cli/main.py +283 -298
- mcp_ticketer/cli/mcp_configure.py +39 -15
- mcp_ticketer/cli/migrate_config.py +11 -13
- mcp_ticketer/cli/queue_commands.py +21 -58
- mcp_ticketer/cli/utils.py +121 -66
- mcp_ticketer/core/__init__.py +2 -2
- mcp_ticketer/core/adapter.py +46 -39
- mcp_ticketer/core/config.py +128 -92
- mcp_ticketer/core/env_discovery.py +69 -37
- mcp_ticketer/core/http_client.py +57 -40
- mcp_ticketer/core/mappers.py +98 -54
- mcp_ticketer/core/models.py +38 -24
- mcp_ticketer/core/project_config.py +145 -80
- mcp_ticketer/core/registry.py +16 -16
- mcp_ticketer/mcp/__init__.py +1 -1
- mcp_ticketer/mcp/server.py +199 -145
- mcp_ticketer/queue/__init__.py +2 -2
- mcp_ticketer/queue/__main__.py +1 -1
- mcp_ticketer/queue/manager.py +30 -26
- mcp_ticketer/queue/queue.py +147 -85
- mcp_ticketer/queue/run_worker.py +2 -3
- mcp_ticketer/queue/worker.py +55 -40
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/METADATA +1 -1
- mcp_ticketer-0.1.23.dist-info/RECORD +42 -0
- mcp_ticketer-0.1.21.dist-info/RECORD +0 -42
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/top_level.txt +0 -0
|
@@ -8,14 +8,14 @@ environment files, including:
|
|
|
8
8
|
- Security validation
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
import os
|
|
12
11
|
import logging
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
from typing import Dict, Any, Optional, List, Tuple
|
|
15
12
|
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
16
|
from dotenv import dotenv_values
|
|
17
17
|
|
|
18
|
-
from .project_config import AdapterType
|
|
18
|
+
from .project_config import AdapterType
|
|
19
19
|
|
|
20
20
|
logger = logging.getLogger(__name__)
|
|
21
21
|
|
|
@@ -105,9 +105,9 @@ class DiscoveredAdapter:
|
|
|
105
105
|
"""Information about a discovered adapter configuration."""
|
|
106
106
|
|
|
107
107
|
adapter_type: str
|
|
108
|
-
config:
|
|
108
|
+
config: dict[str, Any]
|
|
109
109
|
confidence: float # 0.0-1.0 how complete the configuration is
|
|
110
|
-
missing_fields:
|
|
110
|
+
missing_fields: list[str] = field(default_factory=list)
|
|
111
111
|
found_in: str = ".env" # Which file it was found in
|
|
112
112
|
|
|
113
113
|
def is_complete(self) -> bool:
|
|
@@ -119,9 +119,9 @@ class DiscoveredAdapter:
|
|
|
119
119
|
class DiscoveryResult:
|
|
120
120
|
"""Result of environment file discovery."""
|
|
121
121
|
|
|
122
|
-
adapters:
|
|
123
|
-
warnings:
|
|
124
|
-
env_files_found:
|
|
122
|
+
adapters: list[DiscoveredAdapter] = field(default_factory=list)
|
|
123
|
+
warnings: list[str] = field(default_factory=list)
|
|
124
|
+
env_files_found: list[str] = field(default_factory=list)
|
|
125
125
|
|
|
126
126
|
def get_primary_adapter(self) -> Optional[DiscoveredAdapter]:
|
|
127
127
|
"""Get the adapter with highest confidence and completeness."""
|
|
@@ -130,9 +130,7 @@ class DiscoveryResult:
|
|
|
130
130
|
|
|
131
131
|
# Sort by: complete configs first, then by confidence
|
|
132
132
|
sorted_adapters = sorted(
|
|
133
|
-
self.adapters,
|
|
134
|
-
key=lambda a: (a.is_complete(), a.confidence),
|
|
135
|
-
reverse=True
|
|
133
|
+
self.adapters, key=lambda a: (a.is_complete(), a.confidence), reverse=True
|
|
136
134
|
)
|
|
137
135
|
return sorted_adapters[0]
|
|
138
136
|
|
|
@@ -160,6 +158,7 @@ class EnvDiscovery:
|
|
|
160
158
|
|
|
161
159
|
Args:
|
|
162
160
|
project_path: Path to project root (defaults to cwd)
|
|
161
|
+
|
|
163
162
|
"""
|
|
164
163
|
self.project_path = project_path or Path.cwd()
|
|
165
164
|
|
|
@@ -168,6 +167,7 @@ class EnvDiscovery:
|
|
|
168
167
|
|
|
169
168
|
Returns:
|
|
170
169
|
DiscoveryResult with found adapters and warnings
|
|
170
|
+
|
|
171
171
|
"""
|
|
172
172
|
result = DiscoveryResult()
|
|
173
173
|
|
|
@@ -179,19 +179,27 @@ class EnvDiscovery:
|
|
|
179
179
|
return result
|
|
180
180
|
|
|
181
181
|
# Detect adapters
|
|
182
|
-
linear_adapter = self._detect_linear(
|
|
182
|
+
linear_adapter = self._detect_linear(
|
|
183
|
+
env_vars, result.env_files_found[0] if result.env_files_found else ".env"
|
|
184
|
+
)
|
|
183
185
|
if linear_adapter:
|
|
184
186
|
result.adapters.append(linear_adapter)
|
|
185
187
|
|
|
186
|
-
github_adapter = self._detect_github(
|
|
188
|
+
github_adapter = self._detect_github(
|
|
189
|
+
env_vars, result.env_files_found[0] if result.env_files_found else ".env"
|
|
190
|
+
)
|
|
187
191
|
if github_adapter:
|
|
188
192
|
result.adapters.append(github_adapter)
|
|
189
193
|
|
|
190
|
-
jira_adapter = self._detect_jira(
|
|
194
|
+
jira_adapter = self._detect_jira(
|
|
195
|
+
env_vars, result.env_files_found[0] if result.env_files_found else ".env"
|
|
196
|
+
)
|
|
191
197
|
if jira_adapter:
|
|
192
198
|
result.adapters.append(jira_adapter)
|
|
193
199
|
|
|
194
|
-
aitrackdown_adapter = self._detect_aitrackdown(
|
|
200
|
+
aitrackdown_adapter = self._detect_aitrackdown(
|
|
201
|
+
env_vars, result.env_files_found[0] if result.env_files_found else ".env"
|
|
202
|
+
)
|
|
195
203
|
if aitrackdown_adapter:
|
|
196
204
|
result.adapters.append(aitrackdown_adapter)
|
|
197
205
|
|
|
@@ -201,7 +209,7 @@ class EnvDiscovery:
|
|
|
201
209
|
|
|
202
210
|
return result
|
|
203
211
|
|
|
204
|
-
def _load_env_files(self, result: DiscoveryResult) ->
|
|
212
|
+
def _load_env_files(self, result: DiscoveryResult) -> dict[str, str]:
|
|
205
213
|
"""Load environment variables from files.
|
|
206
214
|
|
|
207
215
|
Args:
|
|
@@ -209,8 +217,9 @@ class EnvDiscovery:
|
|
|
209
217
|
|
|
210
218
|
Returns:
|
|
211
219
|
Merged dictionary of environment variables
|
|
220
|
+
|
|
212
221
|
"""
|
|
213
|
-
merged_env:
|
|
222
|
+
merged_env: dict[str, str] = {}
|
|
214
223
|
|
|
215
224
|
# Load files in reverse order (lowest priority first)
|
|
216
225
|
for env_file in reversed(self.ENV_FILE_ORDER):
|
|
@@ -229,7 +238,9 @@ class EnvDiscovery:
|
|
|
229
238
|
|
|
230
239
|
return merged_env
|
|
231
240
|
|
|
232
|
-
def _find_key_value(
|
|
241
|
+
def _find_key_value(
|
|
242
|
+
self, env_vars: dict[str, str], patterns: list[str]
|
|
243
|
+
) -> Optional[str]:
|
|
233
244
|
"""Find first matching key value from patterns.
|
|
234
245
|
|
|
235
246
|
Args:
|
|
@@ -238,13 +249,16 @@ class EnvDiscovery:
|
|
|
238
249
|
|
|
239
250
|
Returns:
|
|
240
251
|
Value if found, None otherwise
|
|
252
|
+
|
|
241
253
|
"""
|
|
242
254
|
for pattern in patterns:
|
|
243
255
|
if pattern in env_vars and env_vars[pattern]:
|
|
244
256
|
return env_vars[pattern]
|
|
245
257
|
return None
|
|
246
258
|
|
|
247
|
-
def _detect_linear(
|
|
259
|
+
def _detect_linear(
|
|
260
|
+
self, env_vars: dict[str, str], found_in: str
|
|
261
|
+
) -> Optional[DiscoveredAdapter]:
|
|
248
262
|
"""Detect Linear adapter configuration.
|
|
249
263
|
|
|
250
264
|
Args:
|
|
@@ -253,18 +267,19 @@ class EnvDiscovery:
|
|
|
253
267
|
|
|
254
268
|
Returns:
|
|
255
269
|
DiscoveredAdapter if Linear config detected, None otherwise
|
|
270
|
+
|
|
256
271
|
"""
|
|
257
272
|
api_key = self._find_key_value(env_vars, LINEAR_KEY_PATTERNS)
|
|
258
273
|
|
|
259
274
|
if not api_key:
|
|
260
275
|
return None
|
|
261
276
|
|
|
262
|
-
config:
|
|
277
|
+
config: dict[str, Any] = {
|
|
263
278
|
"api_key": api_key,
|
|
264
279
|
"adapter": AdapterType.LINEAR.value,
|
|
265
280
|
}
|
|
266
281
|
|
|
267
|
-
missing_fields:
|
|
282
|
+
missing_fields: list[str] = []
|
|
268
283
|
confidence = 0.6 # Has API key
|
|
269
284
|
|
|
270
285
|
# Extract team ID (recommended but not required)
|
|
@@ -289,7 +304,9 @@ class EnvDiscovery:
|
|
|
289
304
|
found_in=found_in,
|
|
290
305
|
)
|
|
291
306
|
|
|
292
|
-
def _detect_github(
|
|
307
|
+
def _detect_github(
|
|
308
|
+
self, env_vars: dict[str, str], found_in: str
|
|
309
|
+
) -> Optional[DiscoveredAdapter]:
|
|
293
310
|
"""Detect GitHub adapter configuration.
|
|
294
311
|
|
|
295
312
|
Args:
|
|
@@ -298,18 +315,19 @@ class EnvDiscovery:
|
|
|
298
315
|
|
|
299
316
|
Returns:
|
|
300
317
|
DiscoveredAdapter if GitHub config detected, None otherwise
|
|
318
|
+
|
|
301
319
|
"""
|
|
302
320
|
token = self._find_key_value(env_vars, GITHUB_TOKEN_PATTERNS)
|
|
303
321
|
|
|
304
322
|
if not token:
|
|
305
323
|
return None
|
|
306
324
|
|
|
307
|
-
config:
|
|
325
|
+
config: dict[str, Any] = {
|
|
308
326
|
"token": token,
|
|
309
327
|
"adapter": AdapterType.GITHUB.value,
|
|
310
328
|
}
|
|
311
329
|
|
|
312
|
-
missing_fields:
|
|
330
|
+
missing_fields: list[str] = []
|
|
313
331
|
confidence = 0.4 # Has token
|
|
314
332
|
|
|
315
333
|
# Try to extract owner/repo from combined field
|
|
@@ -345,7 +363,9 @@ class EnvDiscovery:
|
|
|
345
363
|
found_in=found_in,
|
|
346
364
|
)
|
|
347
365
|
|
|
348
|
-
def _detect_jira(
|
|
366
|
+
def _detect_jira(
|
|
367
|
+
self, env_vars: dict[str, str], found_in: str
|
|
368
|
+
) -> Optional[DiscoveredAdapter]:
|
|
349
369
|
"""Detect JIRA adapter configuration.
|
|
350
370
|
|
|
351
371
|
Args:
|
|
@@ -354,18 +374,19 @@ class EnvDiscovery:
|
|
|
354
374
|
|
|
355
375
|
Returns:
|
|
356
376
|
DiscoveredAdapter if JIRA config detected, None otherwise
|
|
377
|
+
|
|
357
378
|
"""
|
|
358
379
|
api_token = self._find_key_value(env_vars, JIRA_TOKEN_PATTERNS)
|
|
359
380
|
|
|
360
381
|
if not api_token:
|
|
361
382
|
return None
|
|
362
383
|
|
|
363
|
-
config:
|
|
384
|
+
config: dict[str, Any] = {
|
|
364
385
|
"api_token": api_token,
|
|
365
386
|
"adapter": AdapterType.JIRA.value,
|
|
366
387
|
}
|
|
367
388
|
|
|
368
|
-
missing_fields:
|
|
389
|
+
missing_fields: list[str] = []
|
|
369
390
|
confidence = 0.3 # Has token
|
|
370
391
|
|
|
371
392
|
# Extract server (required)
|
|
@@ -398,7 +419,9 @@ class EnvDiscovery:
|
|
|
398
419
|
found_in=found_in,
|
|
399
420
|
)
|
|
400
421
|
|
|
401
|
-
def _detect_aitrackdown(
|
|
422
|
+
def _detect_aitrackdown(
|
|
423
|
+
self, env_vars: dict[str, str], found_in: str
|
|
424
|
+
) -> Optional[DiscoveredAdapter]:
|
|
402
425
|
"""Detect AITrackdown adapter configuration.
|
|
403
426
|
|
|
404
427
|
Args:
|
|
@@ -407,6 +430,7 @@ class EnvDiscovery:
|
|
|
407
430
|
|
|
408
431
|
Returns:
|
|
409
432
|
DiscoveredAdapter if AITrackdown config detected, None otherwise
|
|
433
|
+
|
|
410
434
|
"""
|
|
411
435
|
base_path = self._find_key_value(env_vars, AITRACKDOWN_PATH_PATTERNS)
|
|
412
436
|
|
|
@@ -415,7 +439,7 @@ class EnvDiscovery:
|
|
|
415
439
|
if not base_path and not aitrackdown_dir.exists():
|
|
416
440
|
return None
|
|
417
441
|
|
|
418
|
-
config:
|
|
442
|
+
config: dict[str, Any] = {
|
|
419
443
|
"adapter": AdapterType.AITRACKDOWN.value,
|
|
420
444
|
}
|
|
421
445
|
|
|
@@ -435,13 +459,14 @@ class EnvDiscovery:
|
|
|
435
459
|
found_in=found_in,
|
|
436
460
|
)
|
|
437
461
|
|
|
438
|
-
def _validate_security(self) ->
|
|
462
|
+
def _validate_security(self) -> list[str]:
|
|
439
463
|
"""Validate security of environment files.
|
|
440
464
|
|
|
441
465
|
Returns:
|
|
442
466
|
List of security warnings
|
|
467
|
+
|
|
443
468
|
"""
|
|
444
|
-
warnings:
|
|
469
|
+
warnings: list[str] = []
|
|
445
470
|
|
|
446
471
|
# Check if .env files are tracked in git
|
|
447
472
|
gitignore_path = self.project_path / ".gitignore"
|
|
@@ -460,7 +485,7 @@ class EnvDiscovery:
|
|
|
460
485
|
# Check if .gitignore exists and has .env patterns
|
|
461
486
|
if gitignore_path.exists():
|
|
462
487
|
try:
|
|
463
|
-
with open(gitignore_path
|
|
488
|
+
with open(gitignore_path) as f:
|
|
464
489
|
gitignore_content = f.read()
|
|
465
490
|
if ".env" not in gitignore_content:
|
|
466
491
|
warnings.append(
|
|
@@ -479,6 +504,7 @@ class EnvDiscovery:
|
|
|
479
504
|
|
|
480
505
|
Returns:
|
|
481
506
|
True if file is tracked in git, False otherwise
|
|
507
|
+
|
|
482
508
|
"""
|
|
483
509
|
import subprocess
|
|
484
510
|
|
|
@@ -498,7 +524,7 @@ class EnvDiscovery:
|
|
|
498
524
|
logger.debug(f"Git check failed: {e}")
|
|
499
525
|
return False
|
|
500
526
|
|
|
501
|
-
def validate_discovered_config(self, adapter: DiscoveredAdapter) ->
|
|
527
|
+
def validate_discovered_config(self, adapter: DiscoveredAdapter) -> list[str]:
|
|
502
528
|
"""Validate a discovered adapter configuration.
|
|
503
529
|
|
|
504
530
|
Args:
|
|
@@ -506,8 +532,9 @@ class EnvDiscovery:
|
|
|
506
532
|
|
|
507
533
|
Returns:
|
|
508
534
|
List of validation warnings
|
|
535
|
+
|
|
509
536
|
"""
|
|
510
|
-
warnings:
|
|
537
|
+
warnings: list[str] = []
|
|
511
538
|
|
|
512
539
|
# Check API key/token length (basic sanity check)
|
|
513
540
|
if adapter.adapter_type == AdapterType.LINEAR.value:
|
|
@@ -522,12 +549,16 @@ class EnvDiscovery:
|
|
|
522
549
|
|
|
523
550
|
# Validate token prefix
|
|
524
551
|
if token and not token.startswith(("ghp_", "gho_", "ghu_", "ghs_", "ghr_")):
|
|
525
|
-
warnings.append(
|
|
552
|
+
warnings.append(
|
|
553
|
+
"⚠️ GitHub token doesn't match expected format (should start with ghp_, gho_, etc.)"
|
|
554
|
+
)
|
|
526
555
|
|
|
527
556
|
elif adapter.adapter_type == AdapterType.JIRA.value:
|
|
528
557
|
server = adapter.config.get("server", "")
|
|
529
558
|
if server and not server.startswith(("http://", "https://")):
|
|
530
|
-
warnings.append(
|
|
559
|
+
warnings.append(
|
|
560
|
+
"⚠️ JIRA server URL should start with http:// or https://"
|
|
561
|
+
)
|
|
531
562
|
|
|
532
563
|
email = adapter.config.get("email", "")
|
|
533
564
|
if email and "@" not in email:
|
|
@@ -550,6 +581,7 @@ def discover_config(project_path: Optional[Path] = None) -> DiscoveryResult:
|
|
|
550
581
|
|
|
551
582
|
Returns:
|
|
552
583
|
DiscoveryResult with found adapters and warnings
|
|
584
|
+
|
|
553
585
|
"""
|
|
554
586
|
discovery = EnvDiscovery(project_path)
|
|
555
587
|
return discovery.discover()
|
mcp_ticketer/core/http_client.py
CHANGED
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
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
5
|
import time
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Optional, Union
|
|
9
8
|
|
|
10
9
|
import httpx
|
|
11
|
-
from httpx import AsyncClient,
|
|
10
|
+
from httpx import AsyncClient, TimeoutException
|
|
12
11
|
|
|
13
12
|
logger = logging.getLogger(__name__)
|
|
14
13
|
|
|
15
14
|
|
|
16
15
|
class HTTPMethod(str, Enum):
|
|
17
16
|
"""HTTP methods."""
|
|
17
|
+
|
|
18
18
|
GET = "GET"
|
|
19
19
|
POST = "POST"
|
|
20
20
|
PUT = "PUT"
|
|
@@ -32,8 +32,8 @@ class RetryConfig:
|
|
|
32
32
|
max_delay: float = 60.0,
|
|
33
33
|
exponential_base: float = 2.0,
|
|
34
34
|
jitter: bool = True,
|
|
35
|
-
retry_on_status: Optional[
|
|
36
|
-
retry_on_exceptions: Optional[
|
|
35
|
+
retry_on_status: Optional[list[int]] = None,
|
|
36
|
+
retry_on_exceptions: Optional[list[type]] = None,
|
|
37
37
|
):
|
|
38
38
|
self.max_retries = max_retries
|
|
39
39
|
self.initial_delay = initial_delay
|
|
@@ -41,7 +41,11 @@ class RetryConfig:
|
|
|
41
41
|
self.exponential_base = exponential_base
|
|
42
42
|
self.jitter = jitter
|
|
43
43
|
self.retry_on_status = retry_on_status or [429, 502, 503, 504]
|
|
44
|
-
self.retry_on_exceptions = retry_on_exceptions or [
|
|
44
|
+
self.retry_on_exceptions = retry_on_exceptions or [
|
|
45
|
+
TimeoutException,
|
|
46
|
+
httpx.ConnectTimeout,
|
|
47
|
+
httpx.ReadTimeout,
|
|
48
|
+
]
|
|
45
49
|
|
|
46
50
|
|
|
47
51
|
class RateLimiter:
|
|
@@ -53,6 +57,7 @@ class RateLimiter:
|
|
|
53
57
|
Args:
|
|
54
58
|
max_requests: Maximum number of requests allowed
|
|
55
59
|
time_window: Time window in seconds
|
|
60
|
+
|
|
56
61
|
"""
|
|
57
62
|
self.max_requests = max_requests
|
|
58
63
|
self.time_window = time_window
|
|
@@ -69,7 +74,7 @@ class RateLimiter:
|
|
|
69
74
|
time_passed = now - self.last_update
|
|
70
75
|
self.tokens = min(
|
|
71
76
|
self.max_requests,
|
|
72
|
-
self.tokens + (time_passed / self.time_window) * self.max_requests
|
|
77
|
+
self.tokens + (time_passed / self.time_window) * self.max_requests,
|
|
73
78
|
)
|
|
74
79
|
self.last_update = now
|
|
75
80
|
|
|
@@ -89,13 +94,13 @@ class BaseHTTPClient:
|
|
|
89
94
|
def __init__(
|
|
90
95
|
self,
|
|
91
96
|
base_url: str,
|
|
92
|
-
headers: Optional[
|
|
97
|
+
headers: Optional[dict[str, str]] = None,
|
|
93
98
|
auth: Optional[Union[httpx.Auth, tuple]] = None,
|
|
94
99
|
timeout: float = 30.0,
|
|
95
100
|
retry_config: Optional[RetryConfig] = None,
|
|
96
101
|
rate_limiter: Optional[RateLimiter] = None,
|
|
97
102
|
verify_ssl: bool = True,
|
|
98
|
-
follow_redirects: bool = True
|
|
103
|
+
follow_redirects: bool = True,
|
|
99
104
|
):
|
|
100
105
|
"""Initialize HTTP client.
|
|
101
106
|
|
|
@@ -108,6 +113,7 @@ class BaseHTTPClient:
|
|
|
108
113
|
rate_limiter: Rate limiter instance
|
|
109
114
|
verify_ssl: Whether to verify SSL certificates
|
|
110
115
|
follow_redirects: Whether to follow redirects
|
|
116
|
+
|
|
111
117
|
"""
|
|
112
118
|
self.base_url = base_url.rstrip("/")
|
|
113
119
|
self.default_headers = headers or {}
|
|
@@ -137,11 +143,13 @@ class BaseHTTPClient:
|
|
|
137
143
|
auth=self.auth,
|
|
138
144
|
timeout=self.timeout,
|
|
139
145
|
verify=self.verify_ssl,
|
|
140
|
-
follow_redirects=self.follow_redirects
|
|
146
|
+
follow_redirects=self.follow_redirects,
|
|
141
147
|
)
|
|
142
148
|
return self._client
|
|
143
149
|
|
|
144
|
-
async def _calculate_delay(
|
|
150
|
+
async def _calculate_delay(
|
|
151
|
+
self, attempt: int, response: Optional[httpx.Response] = None
|
|
152
|
+
) -> float:
|
|
145
153
|
"""Calculate delay for retry attempt."""
|
|
146
154
|
if response and response.status_code == 429:
|
|
147
155
|
# Use Retry-After header if available
|
|
@@ -162,7 +170,8 @@ class BaseHTTPClient:
|
|
|
162
170
|
# Add jitter to prevent thundering herd
|
|
163
171
|
if self.retry_config.jitter:
|
|
164
172
|
import random
|
|
165
|
-
|
|
173
|
+
|
|
174
|
+
delay *= 0.5 + random.random() * 0.5
|
|
166
175
|
|
|
167
176
|
return delay
|
|
168
177
|
|
|
@@ -170,7 +179,7 @@ class BaseHTTPClient:
|
|
|
170
179
|
self,
|
|
171
180
|
exception: Exception,
|
|
172
181
|
response: Optional[httpx.Response] = None,
|
|
173
|
-
attempt: int = 1
|
|
182
|
+
attempt: int = 1,
|
|
174
183
|
) -> bool:
|
|
175
184
|
"""Determine if request should be retried."""
|
|
176
185
|
if attempt >= self.retry_config.max_retries:
|
|
@@ -191,13 +200,13 @@ class BaseHTTPClient:
|
|
|
191
200
|
self,
|
|
192
201
|
method: Union[HTTPMethod, str],
|
|
193
202
|
endpoint: str,
|
|
194
|
-
data: Optional[
|
|
195
|
-
json: Optional[
|
|
196
|
-
params: Optional[
|
|
197
|
-
headers: Optional[
|
|
203
|
+
data: Optional[dict[str, Any]] = None,
|
|
204
|
+
json: Optional[dict[str, Any]] = None,
|
|
205
|
+
params: Optional[dict[str, Any]] = None,
|
|
206
|
+
headers: Optional[dict[str, str]] = None,
|
|
198
207
|
timeout: Optional[float] = None,
|
|
199
208
|
retry_count: int = 0,
|
|
200
|
-
**kwargs
|
|
209
|
+
**kwargs,
|
|
201
210
|
) -> httpx.Response:
|
|
202
211
|
"""Make HTTP request with retry and rate limiting.
|
|
203
212
|
|
|
@@ -218,6 +227,7 @@ class BaseHTTPClient:
|
|
|
218
227
|
Raises:
|
|
219
228
|
HTTPStatusError: On HTTP errors
|
|
220
229
|
TimeoutException: On timeout
|
|
230
|
+
|
|
221
231
|
"""
|
|
222
232
|
# Rate limiting
|
|
223
233
|
if self.rate_limiter:
|
|
@@ -243,7 +253,7 @@ class BaseHTTPClient:
|
|
|
243
253
|
params=params,
|
|
244
254
|
headers=request_headers,
|
|
245
255
|
timeout=timeout or self.timeout,
|
|
246
|
-
**kwargs
|
|
256
|
+
**kwargs,
|
|
247
257
|
)
|
|
248
258
|
|
|
249
259
|
# Update stats
|
|
@@ -258,7 +268,7 @@ class BaseHTTPClient:
|
|
|
258
268
|
self.stats["errors"] += 1
|
|
259
269
|
|
|
260
270
|
# Check if we should retry
|
|
261
|
-
response = getattr(e,
|
|
271
|
+
response = getattr(e, "response", None)
|
|
262
272
|
if self._should_retry(e, response, retry_count + 1):
|
|
263
273
|
delay = await self._calculate_delay(retry_count + 1, response)
|
|
264
274
|
|
|
@@ -269,7 +279,15 @@ class BaseHTTPClient:
|
|
|
269
279
|
|
|
270
280
|
await asyncio.sleep(delay)
|
|
271
281
|
return await self.request(
|
|
272
|
-
method,
|
|
282
|
+
method,
|
|
283
|
+
endpoint,
|
|
284
|
+
data,
|
|
285
|
+
json,
|
|
286
|
+
params,
|
|
287
|
+
headers,
|
|
288
|
+
timeout,
|
|
289
|
+
retry_count + 1,
|
|
290
|
+
**kwargs,
|
|
273
291
|
)
|
|
274
292
|
|
|
275
293
|
# No more retries, re-raise the exception
|
|
@@ -295,7 +313,7 @@ class BaseHTTPClient:
|
|
|
295
313
|
"""Make DELETE request."""
|
|
296
314
|
return await self.request(HTTPMethod.DELETE, endpoint, **kwargs)
|
|
297
315
|
|
|
298
|
-
async def get_json(self, endpoint: str, **kwargs) ->
|
|
316
|
+
async def get_json(self, endpoint: str, **kwargs) -> dict[str, Any]:
|
|
299
317
|
"""Make GET request and return JSON response."""
|
|
300
318
|
response = await self.get(endpoint, **kwargs)
|
|
301
319
|
|
|
@@ -305,7 +323,7 @@ class BaseHTTPClient:
|
|
|
305
323
|
|
|
306
324
|
return response.json()
|
|
307
325
|
|
|
308
|
-
async def post_json(self, endpoint: str, **kwargs) ->
|
|
326
|
+
async def post_json(self, endpoint: str, **kwargs) -> dict[str, Any]:
|
|
309
327
|
"""Make POST request and return JSON response."""
|
|
310
328
|
response = await self.post(endpoint, **kwargs)
|
|
311
329
|
|
|
@@ -315,7 +333,7 @@ class BaseHTTPClient:
|
|
|
315
333
|
|
|
316
334
|
return response.json()
|
|
317
335
|
|
|
318
|
-
async def put_json(self, endpoint: str, **kwargs) ->
|
|
336
|
+
async def put_json(self, endpoint: str, **kwargs) -> dict[str, Any]:
|
|
319
337
|
"""Make PUT request and return JSON response."""
|
|
320
338
|
response = await self.put(endpoint, **kwargs)
|
|
321
339
|
|
|
@@ -325,7 +343,7 @@ class BaseHTTPClient:
|
|
|
325
343
|
|
|
326
344
|
return response.json()
|
|
327
345
|
|
|
328
|
-
async def patch_json(self, endpoint: str, **kwargs) ->
|
|
346
|
+
async def patch_json(self, endpoint: str, **kwargs) -> dict[str, Any]:
|
|
329
347
|
"""Make PATCH request and return JSON response."""
|
|
330
348
|
response = await self.patch(endpoint, **kwargs)
|
|
331
349
|
|
|
@@ -335,7 +353,7 @@ class BaseHTTPClient:
|
|
|
335
353
|
|
|
336
354
|
return response.json()
|
|
337
355
|
|
|
338
|
-
def get_stats(self) ->
|
|
356
|
+
def get_stats(self) -> dict[str, Any]:
|
|
339
357
|
"""Get client statistics."""
|
|
340
358
|
return self.stats.copy()
|
|
341
359
|
|
|
@@ -364,6 +382,7 @@ class GitHubHTTPClient(BaseHTTPClient):
|
|
|
364
382
|
Args:
|
|
365
383
|
token: GitHub API token
|
|
366
384
|
api_url: GitHub API URL
|
|
385
|
+
|
|
367
386
|
"""
|
|
368
387
|
headers = {
|
|
369
388
|
"Authorization": f"Bearer {token}",
|
|
@@ -379,9 +398,8 @@ class GitHubHTTPClient(BaseHTTPClient):
|
|
|
379
398
|
headers=headers,
|
|
380
399
|
rate_limiter=rate_limiter,
|
|
381
400
|
retry_config=RetryConfig(
|
|
382
|
-
max_retries=3,
|
|
383
|
-
|
|
384
|
-
)
|
|
401
|
+
max_retries=3, retry_on_status=[429, 502, 503, 504, 522, 524]
|
|
402
|
+
),
|
|
385
403
|
)
|
|
386
404
|
|
|
387
405
|
|
|
@@ -394,7 +412,7 @@ class JiraHTTPClient(BaseHTTPClient):
|
|
|
394
412
|
api_token: str,
|
|
395
413
|
server_url: str,
|
|
396
414
|
is_cloud: bool = True,
|
|
397
|
-
verify_ssl: bool = True
|
|
415
|
+
verify_ssl: bool = True,
|
|
398
416
|
):
|
|
399
417
|
"""Initialize JIRA HTTP client.
|
|
400
418
|
|
|
@@ -404,13 +422,13 @@ class JiraHTTPClient(BaseHTTPClient):
|
|
|
404
422
|
server_url: JIRA server URL
|
|
405
423
|
is_cloud: Whether this is JIRA Cloud
|
|
406
424
|
verify_ssl: Whether to verify SSL certificates
|
|
425
|
+
|
|
407
426
|
"""
|
|
408
|
-
api_base =
|
|
427
|
+
api_base = (
|
|
428
|
+
f"{server_url}/rest/api/3" if is_cloud else f"{server_url}/rest/api/2"
|
|
429
|
+
)
|
|
409
430
|
|
|
410
|
-
headers = {
|
|
411
|
-
"Accept": "application/json",
|
|
412
|
-
"Content-Type": "application/json"
|
|
413
|
-
}
|
|
431
|
+
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
|
414
432
|
|
|
415
433
|
auth = httpx.BasicAuth(email, api_token)
|
|
416
434
|
|
|
@@ -424,7 +442,6 @@ class JiraHTTPClient(BaseHTTPClient):
|
|
|
424
442
|
rate_limiter=rate_limiter,
|
|
425
443
|
verify_ssl=verify_ssl,
|
|
426
444
|
retry_config=RetryConfig(
|
|
427
|
-
max_retries=3,
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
)
|
|
445
|
+
max_retries=3, retry_on_status=[429, 502, 503, 504]
|
|
446
|
+
),
|
|
447
|
+
)
|