mcp-ticketer 0.1.30__py3-none-any.whl → 1.2.11__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 +10 -10
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +796 -46
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1416 -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.py +879 -129
- mcp_ticketer/adapters/hybrid.py +11 -11
- mcp_ticketer/adapters/jira.py +973 -73
- mcp_ticketer/adapters/linear/__init__.py +24 -0
- mcp_ticketer/adapters/linear/adapter.py +2732 -0
- mcp_ticketer/adapters/linear/client.py +344 -0
- mcp_ticketer/adapters/linear/mappers.py +420 -0
- mcp_ticketer/adapters/linear/queries.py +479 -0
- mcp_ticketer/adapters/linear/types.py +360 -0
- mcp_ticketer/adapters/linear.py +10 -2315
- mcp_ticketer/analysis/__init__.py +23 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -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 +888 -151
- mcp_ticketer/cli/diagnostics.py +400 -157
- 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/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +616 -0
- mcp_ticketer/cli/main.py +203 -1165
- mcp_ticketer/cli/mcp_configure.py +474 -90
- 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 +418 -0
- mcp_ticketer/cli/platform_installer.py +513 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +90 -65
- mcp_ticketer/cli/ticket_commands.py +1013 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +114 -66
- mcp_ticketer/core/__init__.py +24 -1
- mcp_ticketer/core/adapter.py +250 -16
- mcp_ticketer/core/config.py +145 -37
- mcp_ticketer/core/env_discovery.py +101 -22
- mcp_ticketer/core/env_loader.py +349 -0
- mcp_ticketer/core/exceptions.py +160 -0
- 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/models.py +280 -28
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/project_config.py +183 -49
- mcp_ticketer/core/registry.py +3 -3
- mcp_ticketer/core/session_state.py +171 -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 +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +151 -0
- mcp_ticketer/mcp/server/tools/__init__.py +56 -0
- mcp_ticketer/mcp/server/tools/analysis_tools.py +495 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +1439 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +921 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +300 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +948 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +215 -0
- mcp_ticketer/mcp/server/tools/session_tools.py +170 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1268 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +547 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +168 -136
- mcp_ticketer/queue/manager.py +95 -25
- mcp_ticketer/queue/queue.py +40 -21
- mcp_ticketer/queue/run_worker.py +6 -1
- mcp_ticketer/queue/ticket_registry.py +213 -155
- mcp_ticketer/queue/worker.py +109 -49
- mcp_ticketer-1.2.11.dist-info/METADATA +792 -0
- mcp_ticketer-1.2.11.dist-info/RECORD +110 -0
- mcp_ticketer/mcp/server.py +0 -1895
- mcp_ticketer-0.1.30.dist-info/METADATA +0 -413
- mcp_ticketer-0.1.30.dist-info/RECORD +0 -49
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/top_level.txt +0 -0
|
@@ -14,7 +14,7 @@ import os
|
|
|
14
14
|
from dataclasses import asdict, dataclass, field
|
|
15
15
|
from enum import Enum
|
|
16
16
|
from pathlib import Path
|
|
17
|
-
from typing import Any, Optional
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
18
18
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
20
|
from .env_discovery import DiscoveryResult
|
|
@@ -47,29 +47,29 @@ class AdapterConfig:
|
|
|
47
47
|
enabled: bool = True
|
|
48
48
|
|
|
49
49
|
# Common fields (not all adapters use all fields)
|
|
50
|
-
api_key:
|
|
51
|
-
token:
|
|
50
|
+
api_key: str | None = None
|
|
51
|
+
token: str | None = None
|
|
52
52
|
|
|
53
53
|
# Linear-specific
|
|
54
|
-
team_id:
|
|
55
|
-
team_key:
|
|
56
|
-
workspace:
|
|
54
|
+
team_id: str | None = None
|
|
55
|
+
team_key: str | None = None
|
|
56
|
+
workspace: str | None = None
|
|
57
57
|
|
|
58
58
|
# JIRA-specific
|
|
59
|
-
server:
|
|
60
|
-
email:
|
|
61
|
-
api_token:
|
|
62
|
-
project_key:
|
|
59
|
+
server: str | None = None
|
|
60
|
+
email: str | None = None
|
|
61
|
+
api_token: str | None = None
|
|
62
|
+
project_key: str | None = None
|
|
63
63
|
|
|
64
64
|
# GitHub-specific
|
|
65
|
-
owner:
|
|
66
|
-
repo:
|
|
65
|
+
owner: str | None = None
|
|
66
|
+
repo: str | None = None
|
|
67
67
|
|
|
68
68
|
# AITrackdown-specific
|
|
69
|
-
base_path:
|
|
69
|
+
base_path: str | None = None
|
|
70
70
|
|
|
71
71
|
# Project ID (can be used by any adapter for scoping)
|
|
72
|
-
project_id:
|
|
72
|
+
project_id: str | None = None
|
|
73
73
|
|
|
74
74
|
# Additional adapter-specific configuration
|
|
75
75
|
additional_config: dict[str, Any] = field(default_factory=dict)
|
|
@@ -126,9 +126,9 @@ class ProjectConfig:
|
|
|
126
126
|
"""Configuration for a specific project."""
|
|
127
127
|
|
|
128
128
|
adapter: str
|
|
129
|
-
api_key:
|
|
130
|
-
project_id:
|
|
131
|
-
team_id:
|
|
129
|
+
api_key: str | None = None
|
|
130
|
+
project_id: str | None = None
|
|
131
|
+
team_id: str | None = None
|
|
132
132
|
additional_config: dict[str, Any] = field(default_factory=dict)
|
|
133
133
|
|
|
134
134
|
def to_dict(self) -> dict[str, Any]:
|
|
@@ -147,7 +147,7 @@ class HybridConfig:
|
|
|
147
147
|
|
|
148
148
|
enabled: bool = False
|
|
149
149
|
adapters: list[str] = field(default_factory=list)
|
|
150
|
-
primary_adapter:
|
|
150
|
+
primary_adapter: str | None = None
|
|
151
151
|
sync_strategy: SyncStrategy = SyncStrategy.PRIMARY_SOURCE
|
|
152
152
|
|
|
153
153
|
def to_dict(self) -> dict[str, Any]:
|
|
@@ -167,16 +167,73 @@ class HybridConfig:
|
|
|
167
167
|
|
|
168
168
|
@dataclass
|
|
169
169
|
class TicketerConfig:
|
|
170
|
-
"""Complete ticketer configuration with hierarchical resolution.
|
|
170
|
+
"""Complete ticketer configuration with hierarchical resolution.
|
|
171
|
+
|
|
172
|
+
Supports URL parsing for default_project field:
|
|
173
|
+
- Linear URLs: https://linear.app/workspace/project/project-slug-abc123
|
|
174
|
+
- JIRA URLs: https://company.atlassian.net/browse/PROJ-123
|
|
175
|
+
- GitHub URLs: https://github.com/owner/repo/projects/1
|
|
176
|
+
- Plain IDs: PROJ-123, abc-123, 1 (backward compatible)
|
|
177
|
+
"""
|
|
171
178
|
|
|
172
179
|
default_adapter: str = "aitrackdown"
|
|
173
180
|
project_configs: dict[str, ProjectConfig] = field(default_factory=dict)
|
|
174
181
|
adapters: dict[str, AdapterConfig] = field(default_factory=dict)
|
|
175
|
-
hybrid_mode:
|
|
182
|
+
hybrid_mode: HybridConfig | None = None
|
|
183
|
+
|
|
184
|
+
# Default values for ticket operations
|
|
185
|
+
default_user: str | None = None # Default assignee (user_id or email)
|
|
186
|
+
default_project: str | None = None # Default project/epic ID (supports URLs)
|
|
187
|
+
default_epic: str | None = None # Alias for default_project (backward compat)
|
|
188
|
+
default_tags: list[str] | None = None # Default tags for new tickets
|
|
189
|
+
default_team: str | None = None # Default team ID/key for multi-team platforms
|
|
190
|
+
default_cycle: str | None = None # Default sprint/cycle ID for timeline scoping
|
|
191
|
+
assignment_labels: list[str] | None = None # Labels indicating ticket assignment
|
|
192
|
+
|
|
193
|
+
def __post_init__(self):
|
|
194
|
+
"""Normalize default_project if it's a URL."""
|
|
195
|
+
if self.default_project:
|
|
196
|
+
self.default_project = self._normalize_project_id(self.default_project)
|
|
197
|
+
if self.default_epic:
|
|
198
|
+
self.default_epic = self._normalize_project_id(self.default_epic)
|
|
199
|
+
|
|
200
|
+
def _normalize_project_id(self, value: str) -> str:
|
|
201
|
+
"""Normalize project ID by extracting from URL if needed.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
value: Project ID or URL
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Normalized project ID (plain ID, not URL)
|
|
208
|
+
|
|
209
|
+
Examples:
|
|
210
|
+
>>> config._normalize_project_id("PROJ-123")
|
|
211
|
+
'PROJ-123'
|
|
212
|
+
>>> config._normalize_project_id("https://linear.app/team/project/abc-123")
|
|
213
|
+
'abc-123'
|
|
214
|
+
|
|
215
|
+
"""
|
|
216
|
+
from .url_parser import is_url, normalize_project_id
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
# If it's a URL, use auto-detection (don't rely on default_adapter)
|
|
220
|
+
# This allows users to paste URLs from any platform
|
|
221
|
+
if is_url(value):
|
|
222
|
+
normalized = normalize_project_id(value, adapter_type=None)
|
|
223
|
+
else:
|
|
224
|
+
# For plain IDs, just return as-is
|
|
225
|
+
normalized = normalize_project_id(value, self.default_adapter)
|
|
226
|
+
|
|
227
|
+
logger.debug(f"Normalized '{value}' to '{normalized}'")
|
|
228
|
+
return normalized
|
|
229
|
+
except Exception as e:
|
|
230
|
+
# If normalization fails, log warning but keep original value
|
|
231
|
+
logger.warning(f"Failed to normalize project ID '{value}': {e}")
|
|
232
|
+
return value
|
|
176
233
|
|
|
177
234
|
def to_dict(self) -> dict[str, Any]:
|
|
178
235
|
"""Convert to dictionary for JSON serialization."""
|
|
179
|
-
|
|
236
|
+
result = {
|
|
180
237
|
"default_adapter": self.default_adapter,
|
|
181
238
|
"project_configs": {
|
|
182
239
|
path: config.to_dict() for path, config in self.project_configs.items()
|
|
@@ -186,6 +243,22 @@ class TicketerConfig:
|
|
|
186
243
|
},
|
|
187
244
|
"hybrid_mode": self.hybrid_mode.to_dict() if self.hybrid_mode else None,
|
|
188
245
|
}
|
|
246
|
+
# Add optional fields if set
|
|
247
|
+
if self.default_user is not None:
|
|
248
|
+
result["default_user"] = self.default_user
|
|
249
|
+
if self.default_project is not None:
|
|
250
|
+
result["default_project"] = self.default_project
|
|
251
|
+
if self.default_epic is not None:
|
|
252
|
+
result["default_epic"] = self.default_epic
|
|
253
|
+
if self.default_tags is not None:
|
|
254
|
+
result["default_tags"] = self.default_tags
|
|
255
|
+
if self.default_team is not None:
|
|
256
|
+
result["default_team"] = self.default_team
|
|
257
|
+
if self.default_cycle is not None:
|
|
258
|
+
result["default_cycle"] = self.default_cycle
|
|
259
|
+
if self.assignment_labels is not None:
|
|
260
|
+
result["assignment_labels"] = self.assignment_labels
|
|
261
|
+
return result
|
|
189
262
|
|
|
190
263
|
@classmethod
|
|
191
264
|
def from_dict(cls, data: dict[str, Any]) -> "TicketerConfig":
|
|
@@ -212,6 +285,13 @@ class TicketerConfig:
|
|
|
212
285
|
project_configs=project_configs,
|
|
213
286
|
adapters=adapters,
|
|
214
287
|
hybrid_mode=hybrid_mode,
|
|
288
|
+
default_user=data.get("default_user"),
|
|
289
|
+
default_project=data.get("default_project"),
|
|
290
|
+
default_epic=data.get("default_epic"),
|
|
291
|
+
default_tags=data.get("default_tags"),
|
|
292
|
+
default_team=data.get("default_team"),
|
|
293
|
+
default_cycle=data.get("default_cycle"),
|
|
294
|
+
assignment_labels=data.get("assignment_labels"),
|
|
215
295
|
)
|
|
216
296
|
|
|
217
297
|
|
|
@@ -219,29 +299,77 @@ class ConfigValidator:
|
|
|
219
299
|
"""Validate adapter configurations."""
|
|
220
300
|
|
|
221
301
|
@staticmethod
|
|
222
|
-
def validate_linear_config(config: dict[str, Any]) -> tuple[bool,
|
|
302
|
+
def validate_linear_config(config: dict[str, Any]) -> tuple[bool, str | None]:
|
|
223
303
|
"""Validate Linear adapter configuration.
|
|
224
304
|
|
|
305
|
+
Args:
|
|
306
|
+
config: Linear configuration dictionary
|
|
307
|
+
|
|
225
308
|
Returns:
|
|
226
309
|
Tuple of (is_valid, error_message)
|
|
227
310
|
|
|
228
311
|
"""
|
|
312
|
+
import logging
|
|
313
|
+
import re
|
|
314
|
+
|
|
315
|
+
logger = logging.getLogger(__name__)
|
|
316
|
+
|
|
229
317
|
required = ["api_key"]
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
318
|
+
missing_fields = []
|
|
319
|
+
|
|
320
|
+
for field_name in required:
|
|
321
|
+
if field_name not in config or not config[field_name]:
|
|
322
|
+
missing_fields.append(field_name)
|
|
233
323
|
|
|
234
|
-
|
|
235
|
-
if not config.get("team_key") and not config.get("team_id"):
|
|
324
|
+
if missing_fields:
|
|
236
325
|
return (
|
|
237
326
|
False,
|
|
238
|
-
"Linear config
|
|
327
|
+
f"Linear config missing required fields: {', '.join(missing_fields)}",
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# Require either team_key or team_id (team_key is preferred)
|
|
331
|
+
has_team_key = config.get("team_key") and config["team_key"].strip()
|
|
332
|
+
has_team_id = config.get("team_id") and config["team_id"].strip()
|
|
333
|
+
|
|
334
|
+
if not has_team_key and not has_team_id:
|
|
335
|
+
return (
|
|
336
|
+
False,
|
|
337
|
+
"Linear config requires either team_key (short key like 'ENG') or team_id (UUID)",
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# Validate team_id format if provided (should be UUID)
|
|
341
|
+
if has_team_id:
|
|
342
|
+
team_id = config["team_id"]
|
|
343
|
+
uuid_pattern = re.compile(
|
|
344
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
|
345
|
+
re.IGNORECASE,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
if not uuid_pattern.match(team_id):
|
|
349
|
+
# Not a UUID - could be a team_key mistakenly stored as team_id
|
|
350
|
+
logger.warning(
|
|
351
|
+
f"team_id '{team_id}' is not a UUID format. "
|
|
352
|
+
f"It will be treated as team_key and resolved at runtime."
|
|
353
|
+
)
|
|
354
|
+
# Move it to team_key if team_key is empty
|
|
355
|
+
if not has_team_key:
|
|
356
|
+
config["team_key"] = team_id
|
|
357
|
+
del config["team_id"]
|
|
358
|
+
logger.info(f"Moved non-UUID team_id to team_key: {team_id}")
|
|
359
|
+
|
|
360
|
+
# Validate user_email format if provided
|
|
361
|
+
if config.get("user_email"):
|
|
362
|
+
email = config["user_email"]
|
|
363
|
+
email_pattern = re.compile(
|
|
364
|
+
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
|
239
365
|
)
|
|
366
|
+
if not email_pattern.match(email):
|
|
367
|
+
return False, f"Invalid email format for user_email: {email}"
|
|
240
368
|
|
|
241
369
|
return True, None
|
|
242
370
|
|
|
243
371
|
@staticmethod
|
|
244
|
-
def validate_github_config(config: dict[str, Any]) -> tuple[bool,
|
|
372
|
+
def validate_github_config(config: dict[str, Any]) -> tuple[bool, str | None]:
|
|
245
373
|
"""Validate GitHub adapter configuration.
|
|
246
374
|
|
|
247
375
|
Returns:
|
|
@@ -263,14 +391,14 @@ class ConfigValidator:
|
|
|
263
391
|
|
|
264
392
|
# Otherwise need explicit owner and repo
|
|
265
393
|
required = ["owner", "repo"]
|
|
266
|
-
for
|
|
267
|
-
if
|
|
268
|
-
return False, f"GitHub config missing required field: {
|
|
394
|
+
for field_name in required:
|
|
395
|
+
if field_name not in config or not config[field_name]:
|
|
396
|
+
return False, f"GitHub config missing required field: {field_name}"
|
|
269
397
|
|
|
270
398
|
return True, None
|
|
271
399
|
|
|
272
400
|
@staticmethod
|
|
273
|
-
def validate_jira_config(config: dict[str, Any]) -> tuple[bool,
|
|
401
|
+
def validate_jira_config(config: dict[str, Any]) -> tuple[bool, str | None]:
|
|
274
402
|
"""Validate JIRA adapter configuration.
|
|
275
403
|
|
|
276
404
|
Returns:
|
|
@@ -278,9 +406,9 @@ class ConfigValidator:
|
|
|
278
406
|
|
|
279
407
|
"""
|
|
280
408
|
required = ["server", "email", "api_token"]
|
|
281
|
-
for
|
|
282
|
-
if
|
|
283
|
-
return False, f"JIRA config missing required field: {
|
|
409
|
+
for field_name in required:
|
|
410
|
+
if field_name not in config or not config[field_name]:
|
|
411
|
+
return False, f"JIRA config missing required field: {field_name}"
|
|
284
412
|
|
|
285
413
|
# Validate server URL format
|
|
286
414
|
server = config["server"]
|
|
@@ -292,7 +420,7 @@ class ConfigValidator:
|
|
|
292
420
|
@staticmethod
|
|
293
421
|
def validate_aitrackdown_config(
|
|
294
422
|
config: dict[str, Any],
|
|
295
|
-
) -> tuple[bool,
|
|
423
|
+
) -> tuple[bool, str | None]:
|
|
296
424
|
"""Validate AITrackdown adapter configuration.
|
|
297
425
|
|
|
298
426
|
Returns:
|
|
@@ -306,7 +434,7 @@ class ConfigValidator:
|
|
|
306
434
|
@classmethod
|
|
307
435
|
def validate(
|
|
308
436
|
cls, adapter_type: str, config: dict[str, Any]
|
|
309
|
-
) -> tuple[bool,
|
|
437
|
+
) -> tuple[bool, str | None]:
|
|
310
438
|
"""Validate configuration for any adapter type.
|
|
311
439
|
|
|
312
440
|
Args:
|
|
@@ -350,7 +478,7 @@ class ConfigResolver:
|
|
|
350
478
|
PROJECT_CONFIG_SUBPATH = ".mcp-ticketer" / Path("config.json")
|
|
351
479
|
|
|
352
480
|
def __init__(
|
|
353
|
-
self, project_path:
|
|
481
|
+
self, project_path: Path | None = None, enable_env_discovery: bool = True
|
|
354
482
|
):
|
|
355
483
|
"""Initialize config resolver.
|
|
356
484
|
|
|
@@ -361,8 +489,8 @@ class ConfigResolver:
|
|
|
361
489
|
"""
|
|
362
490
|
self.project_path = project_path or Path.cwd()
|
|
363
491
|
self.enable_env_discovery = enable_env_discovery
|
|
364
|
-
self._project_config:
|
|
365
|
-
self._discovered_config:
|
|
492
|
+
self._project_config: TicketerConfig | None = None
|
|
493
|
+
self._discovered_config: DiscoveryResult | None = None
|
|
366
494
|
|
|
367
495
|
def load_global_config(self) -> TicketerConfig:
|
|
368
496
|
"""Load default configuration (global config loading removed for security).
|
|
@@ -382,8 +510,8 @@ class ConfigResolver:
|
|
|
382
510
|
return default_config
|
|
383
511
|
|
|
384
512
|
def load_project_config(
|
|
385
|
-
self, project_path:
|
|
386
|
-
) ->
|
|
513
|
+
self, project_path: Path | None = None
|
|
514
|
+
) -> TicketerConfig | None:
|
|
387
515
|
"""Load project-specific configuration.
|
|
388
516
|
|
|
389
517
|
Args:
|
|
@@ -424,7 +552,7 @@ class ConfigResolver:
|
|
|
424
552
|
self.save_project_config(config)
|
|
425
553
|
|
|
426
554
|
def save_project_config(
|
|
427
|
-
self, config: TicketerConfig, project_path:
|
|
555
|
+
self, config: TicketerConfig, project_path: Path | None = None
|
|
428
556
|
) -> None:
|
|
429
557
|
"""Save project-specific configuration.
|
|
430
558
|
|
|
@@ -461,8 +589,8 @@ class ConfigResolver:
|
|
|
461
589
|
|
|
462
590
|
def resolve_adapter_config(
|
|
463
591
|
self,
|
|
464
|
-
adapter_name:
|
|
465
|
-
cli_overrides:
|
|
592
|
+
adapter_name: str | None = None,
|
|
593
|
+
cli_overrides: dict[str, Any] | None = None,
|
|
466
594
|
) -> dict[str, Any]:
|
|
467
595
|
"""Resolve adapter configuration with hierarchical precedence.
|
|
468
596
|
|
|
@@ -582,6 +710,12 @@ class ConfigResolver:
|
|
|
582
710
|
overrides["team_id"] = os.getenv("MCP_TICKETER_LINEAR_TEAM_ID")
|
|
583
711
|
if os.getenv("LINEAR_API_KEY"):
|
|
584
712
|
overrides["api_key"] = os.getenv("LINEAR_API_KEY")
|
|
713
|
+
if os.getenv("LINEAR_TEAM_ID"):
|
|
714
|
+
overrides["team_id"] = os.getenv("LINEAR_TEAM_ID")
|
|
715
|
+
if os.getenv("LINEAR_TEAM_KEY"):
|
|
716
|
+
overrides["team_key"] = os.getenv("LINEAR_TEAM_KEY")
|
|
717
|
+
if os.getenv("MCP_TICKETER_LINEAR_TEAM_KEY"):
|
|
718
|
+
overrides["team_key"] = os.getenv("MCP_TICKETER_LINEAR_TEAM_KEY")
|
|
585
719
|
|
|
586
720
|
elif adapter_type == AdapterType.GITHUB.value:
|
|
587
721
|
if os.getenv("MCP_TICKETER_GITHUB_TOKEN"):
|
|
@@ -623,7 +757,7 @@ class ConfigResolver:
|
|
|
623
757
|
|
|
624
758
|
return overrides
|
|
625
759
|
|
|
626
|
-
def get_hybrid_config(self) ->
|
|
760
|
+
def get_hybrid_config(self) -> HybridConfig | None:
|
|
627
761
|
"""Get hybrid mode configuration if enabled.
|
|
628
762
|
|
|
629
763
|
Returns:
|
|
@@ -655,10 +789,10 @@ class ConfigResolver:
|
|
|
655
789
|
|
|
656
790
|
|
|
657
791
|
# Singleton instance for global access
|
|
658
|
-
_default_resolver:
|
|
792
|
+
_default_resolver: ConfigResolver | None = None
|
|
659
793
|
|
|
660
794
|
|
|
661
|
-
def get_config_resolver(project_path:
|
|
795
|
+
def get_config_resolver(project_path: Path | None = None) -> ConfigResolver:
|
|
662
796
|
"""Get the global config resolver instance.
|
|
663
797
|
|
|
664
798
|
Args:
|
mcp_ticketer/core/registry.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Adapter registry for dynamic adapter management."""
|
|
2
2
|
|
|
3
|
-
from typing import Any
|
|
3
|
+
from typing import Any
|
|
4
4
|
|
|
5
5
|
from .adapter import BaseAdapter
|
|
6
6
|
|
|
@@ -37,7 +37,7 @@ class AdapterRegistry:
|
|
|
37
37
|
|
|
38
38
|
@classmethod
|
|
39
39
|
def get_adapter(
|
|
40
|
-
cls, name: str, config:
|
|
40
|
+
cls, name: str, config: dict[str, Any] | None = None, force_new: bool = False
|
|
41
41
|
) -> BaseAdapter:
|
|
42
42
|
"""Get or create an adapter instance.
|
|
43
43
|
|
|
@@ -115,7 +115,7 @@ class AdapterRegistry:
|
|
|
115
115
|
|
|
116
116
|
|
|
117
117
|
def adapter_factory(adapter_type: str, config: dict[str, Any]) -> BaseAdapter:
|
|
118
|
-
"""
|
|
118
|
+
"""Create adapter instance using factory pattern.
|
|
119
119
|
|
|
120
120
|
Args:
|
|
121
121
|
adapter_type: Type of adapter to create
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Session state management for tracking current ticket associations."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
# Session timeout: 30 minutes of inactivity
|
|
14
|
+
SESSION_TIMEOUT_MINUTES = 30
|
|
15
|
+
SESSION_STATE_FILE = ".mcp-ticketer/session.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class SessionState:
|
|
20
|
+
"""Track session-specific state for ticket associations."""
|
|
21
|
+
|
|
22
|
+
session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
23
|
+
current_ticket: str | None = None # Current ticket ID
|
|
24
|
+
ticket_opted_out: bool = False # User explicitly chose "none"
|
|
25
|
+
last_activity: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict[str, Any]:
|
|
28
|
+
"""Serialize to dictionary."""
|
|
29
|
+
return {
|
|
30
|
+
"session_id": self.session_id,
|
|
31
|
+
"current_ticket": self.current_ticket,
|
|
32
|
+
"ticket_opted_out": self.ticket_opted_out,
|
|
33
|
+
"last_activity": self.last_activity,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_dict(cls, data: dict[str, Any]) -> "SessionState":
|
|
38
|
+
"""Deserialize from dictionary."""
|
|
39
|
+
return cls(
|
|
40
|
+
session_id=data.get("session_id", str(uuid.uuid4())),
|
|
41
|
+
current_ticket=data.get("current_ticket"),
|
|
42
|
+
ticket_opted_out=data.get("ticket_opted_out", False),
|
|
43
|
+
last_activity=data.get("last_activity", datetime.now().isoformat()),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def is_expired(self) -> bool:
|
|
47
|
+
"""Check if session has expired due to inactivity."""
|
|
48
|
+
try:
|
|
49
|
+
last_activity = datetime.fromisoformat(self.last_activity)
|
|
50
|
+
timeout = timedelta(minutes=SESSION_TIMEOUT_MINUTES)
|
|
51
|
+
return datetime.now() - last_activity > timeout
|
|
52
|
+
except (ValueError, TypeError):
|
|
53
|
+
# Invalid timestamp, consider expired
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
def touch(self) -> None:
|
|
57
|
+
"""Update last activity timestamp."""
|
|
58
|
+
self.last_activity = datetime.now().isoformat()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SessionStateManager:
|
|
62
|
+
"""Manage session state persistence and lifecycle."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, project_path: Path | None = None):
|
|
65
|
+
"""Initialize session state manager.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
project_path: Project root directory (defaults to current directory)
|
|
69
|
+
|
|
70
|
+
"""
|
|
71
|
+
self.project_path = project_path or Path.cwd()
|
|
72
|
+
self.state_file = self.project_path / SESSION_STATE_FILE
|
|
73
|
+
|
|
74
|
+
def load_session(self) -> SessionState:
|
|
75
|
+
"""Load session state from file.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
SessionState instance (creates new if expired or not found)
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
if not self.state_file.exists():
|
|
82
|
+
logger.debug("No session state file found, creating new session")
|
|
83
|
+
return SessionState()
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
with open(self.state_file) as f:
|
|
87
|
+
data = json.load(f)
|
|
88
|
+
|
|
89
|
+
state = SessionState.from_dict(data)
|
|
90
|
+
|
|
91
|
+
# Check if session expired
|
|
92
|
+
if state.is_expired():
|
|
93
|
+
logger.info(
|
|
94
|
+
f"Session {state.session_id} expired after "
|
|
95
|
+
f"{SESSION_TIMEOUT_MINUTES} minutes, creating new session"
|
|
96
|
+
)
|
|
97
|
+
return SessionState()
|
|
98
|
+
|
|
99
|
+
# Touch to update activity
|
|
100
|
+
state.touch()
|
|
101
|
+
return state
|
|
102
|
+
|
|
103
|
+
except (json.JSONDecodeError, FileNotFoundError, KeyError) as e:
|
|
104
|
+
logger.warning(f"Failed to load session state: {e}, creating new session")
|
|
105
|
+
return SessionState()
|
|
106
|
+
|
|
107
|
+
def save_session(self, state: SessionState) -> None:
|
|
108
|
+
"""Save session state to file.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
state: SessionState to persist
|
|
112
|
+
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
# Ensure directory exists
|
|
116
|
+
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
|
|
118
|
+
# Touch before saving
|
|
119
|
+
state.touch()
|
|
120
|
+
|
|
121
|
+
# Write state
|
|
122
|
+
with open(self.state_file, "w") as f:
|
|
123
|
+
json.dump(state.to_dict(), f, indent=2)
|
|
124
|
+
|
|
125
|
+
logger.debug(f"Saved session state: session_id={state.session_id}")
|
|
126
|
+
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.error(f"Failed to save session state: {e}")
|
|
129
|
+
|
|
130
|
+
def clear_session(self) -> None:
|
|
131
|
+
"""Clear session state (delete file)."""
|
|
132
|
+
try:
|
|
133
|
+
if self.state_file.exists():
|
|
134
|
+
self.state_file.unlink()
|
|
135
|
+
logger.info("Session state cleared")
|
|
136
|
+
except Exception as e:
|
|
137
|
+
logger.error(f"Failed to clear session state: {e}")
|
|
138
|
+
|
|
139
|
+
def get_current_ticket(self) -> str | None:
|
|
140
|
+
"""Get current ticket for this session (convenience method).
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Current ticket ID or None
|
|
144
|
+
|
|
145
|
+
"""
|
|
146
|
+
state = self.load_session()
|
|
147
|
+
|
|
148
|
+
# If user opted out, return None
|
|
149
|
+
if state.ticket_opted_out:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
return state.current_ticket
|
|
153
|
+
|
|
154
|
+
def set_current_ticket(self, ticket_id: str | None) -> None:
|
|
155
|
+
"""Set current ticket for this session (convenience method).
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
ticket_id: Ticket ID to set (None to clear)
|
|
159
|
+
|
|
160
|
+
"""
|
|
161
|
+
state = self.load_session()
|
|
162
|
+
state.current_ticket = ticket_id
|
|
163
|
+
state.ticket_opted_out = False # Clear opt-out when setting ticket
|
|
164
|
+
self.save_session(state)
|
|
165
|
+
|
|
166
|
+
def opt_out_ticket(self) -> None:
|
|
167
|
+
"""Mark that user doesn't want to associate work with a ticket (convenience method)."""
|
|
168
|
+
state = self.load_session()
|
|
169
|
+
state.current_ticket = None
|
|
170
|
+
state.ticket_opted_out = True
|
|
171
|
+
self.save_session(state)
|