mcp-ticketer 0.1.12__py3-none-any.whl → 0.1.14__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/__version__.py +1 -1
- mcp_ticketer/adapters/linear.py +427 -3
- mcp_ticketer/cli/discover.py +402 -0
- mcp_ticketer/cli/main.py +311 -87
- mcp_ticketer/core/__init__.py +2 -1
- mcp_ticketer/core/adapter.py +155 -2
- mcp_ticketer/core/env_discovery.py +555 -0
- mcp_ticketer/core/models.py +58 -6
- mcp_ticketer/core/project_config.py +65 -12
- {mcp_ticketer-0.1.12.dist-info → mcp_ticketer-0.1.14.dist-info}/METADATA +1 -1
- {mcp_ticketer-0.1.12.dist-info → mcp_ticketer-0.1.14.dist-info}/RECORD +15 -13
- {mcp_ticketer-0.1.12.dist-info → mcp_ticketer-0.1.14.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.1.12.dist-info → mcp_ticketer-0.1.14.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.1.12.dist-info → mcp_ticketer-0.1.14.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.1.12.dist-info → mcp_ticketer-0.1.14.dist-info}/top_level.txt +0 -0
mcp_ticketer/cli/main.py
CHANGED
|
@@ -21,6 +21,7 @@ from .queue_commands import app as queue_app
|
|
|
21
21
|
from ..__version__ import __version__
|
|
22
22
|
from .configure import configure_wizard, show_current_config, set_adapter_config
|
|
23
23
|
from .migrate_config import migrate_config_command
|
|
24
|
+
from .discover import app as discover_app
|
|
24
25
|
|
|
25
26
|
# Load environment variables
|
|
26
27
|
load_dotenv()
|
|
@@ -159,11 +160,22 @@ def get_adapter(override_adapter: Optional[str] = None, override_config: Optiona
|
|
|
159
160
|
|
|
160
161
|
@app.command()
|
|
161
162
|
def init(
|
|
162
|
-
adapter:
|
|
163
|
-
|
|
163
|
+
adapter: Optional[str] = typer.Option(
|
|
164
|
+
None,
|
|
164
165
|
"--adapter",
|
|
165
166
|
"-a",
|
|
166
|
-
help="Adapter type to use"
|
|
167
|
+
help="Adapter type to use (auto-detected from .env if not specified)"
|
|
168
|
+
),
|
|
169
|
+
project_path: Optional[str] = typer.Option(
|
|
170
|
+
None,
|
|
171
|
+
"--path",
|
|
172
|
+
help="Project path (default: current directory)"
|
|
173
|
+
),
|
|
174
|
+
global_config: bool = typer.Option(
|
|
175
|
+
False,
|
|
176
|
+
"--global",
|
|
177
|
+
"-g",
|
|
178
|
+
help="Save to global config instead of project-specific"
|
|
167
179
|
),
|
|
168
180
|
base_path: Optional[str] = typer.Option(
|
|
169
181
|
None,
|
|
@@ -212,97 +224,306 @@ def init(
|
|
|
212
224
|
help="GitHub Personal Access Token"
|
|
213
225
|
),
|
|
214
226
|
) -> None:
|
|
215
|
-
"""Initialize
|
|
227
|
+
"""Initialize mcp-ticketer for the current project.
|
|
228
|
+
|
|
229
|
+
Creates .mcp-ticketer/config.json in the current directory with
|
|
230
|
+
auto-detected or specified adapter configuration.
|
|
231
|
+
|
|
232
|
+
Examples:
|
|
233
|
+
# Auto-detect from .env.local
|
|
234
|
+
mcp-ticketer init
|
|
235
|
+
|
|
236
|
+
# Force specific adapter
|
|
237
|
+
mcp-ticketer init --adapter linear
|
|
238
|
+
|
|
239
|
+
# Initialize for different project
|
|
240
|
+
mcp-ticketer init --path /path/to/project
|
|
241
|
+
|
|
242
|
+
# Save globally (not recommended)
|
|
243
|
+
mcp-ticketer init --global
|
|
244
|
+
"""
|
|
245
|
+
from pathlib import Path
|
|
246
|
+
from ..core.project_config import ConfigResolver
|
|
247
|
+
from ..core.env_discovery import discover_config
|
|
248
|
+
|
|
249
|
+
# Determine project path
|
|
250
|
+
proj_path = Path(project_path) if project_path else Path.cwd()
|
|
251
|
+
|
|
252
|
+
# Check if already initialized (unless using --global)
|
|
253
|
+
if not global_config:
|
|
254
|
+
config_path = proj_path / ".mcp-ticketer" / "config.json"
|
|
255
|
+
|
|
256
|
+
if config_path.exists():
|
|
257
|
+
if not typer.confirm(
|
|
258
|
+
f"Configuration already exists at {config_path}. Overwrite?",
|
|
259
|
+
default=False
|
|
260
|
+
):
|
|
261
|
+
console.print("[yellow]Initialization cancelled.[/yellow]")
|
|
262
|
+
raise typer.Exit(0)
|
|
263
|
+
|
|
264
|
+
# 1. Try auto-discovery if no adapter specified
|
|
265
|
+
discovered = None
|
|
266
|
+
adapter_type = adapter
|
|
267
|
+
|
|
268
|
+
if not adapter_type:
|
|
269
|
+
console.print("[cyan]🔍 Auto-discovering configuration from .env files...[/cyan]")
|
|
270
|
+
discovered = discover_config(proj_path)
|
|
271
|
+
|
|
272
|
+
if discovered and discovered.adapters:
|
|
273
|
+
primary = discovered.get_primary_adapter()
|
|
274
|
+
if primary:
|
|
275
|
+
adapter_type = primary.adapter_type
|
|
276
|
+
console.print(f"[green]✓ Detected {adapter_type} adapter from environment files[/green]")
|
|
277
|
+
|
|
278
|
+
# Show what was discovered
|
|
279
|
+
console.print(f"\n[dim]Configuration found in: {primary.found_in}[/dim]")
|
|
280
|
+
console.print(f"[dim]Confidence: {primary.confidence:.0%}[/dim]")
|
|
281
|
+
else:
|
|
282
|
+
adapter_type = "aitrackdown" # Fallback
|
|
283
|
+
console.print("[yellow]⚠ No credentials found, defaulting to aitrackdown[/yellow]")
|
|
284
|
+
else:
|
|
285
|
+
adapter_type = "aitrackdown" # Fallback
|
|
286
|
+
console.print("[yellow]⚠ No .env files found, defaulting to aitrackdown[/yellow]")
|
|
287
|
+
|
|
288
|
+
# 2. Create configuration based on adapter type
|
|
216
289
|
config = {
|
|
217
|
-
"default_adapter":
|
|
290
|
+
"default_adapter": adapter_type,
|
|
218
291
|
"adapters": {}
|
|
219
292
|
}
|
|
220
293
|
|
|
221
|
-
|
|
294
|
+
# 3. If discovered and matches adapter_type, use discovered config
|
|
295
|
+
if discovered and adapter_type != "aitrackdown":
|
|
296
|
+
discovered_adapter = discovered.get_adapter_by_type(adapter_type)
|
|
297
|
+
if discovered_adapter:
|
|
298
|
+
config["adapters"][adapter_type] = discovered_adapter.config
|
|
299
|
+
|
|
300
|
+
# 4. Handle manual configuration for specific adapters
|
|
301
|
+
if adapter_type == "aitrackdown":
|
|
222
302
|
config["adapters"]["aitrackdown"] = {"base_path": base_path or ".aitrackdown"}
|
|
223
|
-
elif adapter == AdapterType.LINEAR:
|
|
224
|
-
# For Linear, we need team_id and optionally api_key
|
|
225
|
-
if not team_id:
|
|
226
|
-
console.print("[red]Error:[/red] --team-id is required for Linear adapter")
|
|
227
|
-
raise typer.Exit(1)
|
|
228
|
-
|
|
229
|
-
config["adapters"]["linear"] = {"team_id": team_id}
|
|
230
|
-
|
|
231
|
-
# Check for API key in environment or parameter
|
|
232
|
-
linear_api_key = api_key or os.getenv("LINEAR_API_KEY")
|
|
233
|
-
if not linear_api_key:
|
|
234
|
-
console.print("[yellow]Warning:[/yellow] No Linear API key provided.")
|
|
235
|
-
console.print("Set LINEAR_API_KEY environment variable or use --api-key option")
|
|
236
|
-
else:
|
|
237
|
-
config["adapters"]["linear"]["api_key"] = linear_api_key
|
|
238
|
-
|
|
239
|
-
elif adapter == AdapterType.JIRA:
|
|
240
|
-
# For JIRA, we need server, email, and API token
|
|
241
|
-
server = jira_server or os.getenv("JIRA_SERVER")
|
|
242
|
-
email = jira_email or os.getenv("JIRA_EMAIL")
|
|
243
|
-
token = api_key or os.getenv("JIRA_API_TOKEN")
|
|
244
|
-
project = jira_project or os.getenv("JIRA_PROJECT_KEY")
|
|
245
|
-
|
|
246
|
-
if not server:
|
|
247
|
-
console.print("[red]Error:[/red] JIRA server URL is required")
|
|
248
|
-
console.print("Use --jira-server or set JIRA_SERVER environment variable")
|
|
249
|
-
raise typer.Exit(1)
|
|
250
|
-
|
|
251
|
-
if not email:
|
|
252
|
-
console.print("[red]Error:[/red] JIRA email is required")
|
|
253
|
-
console.print("Use --jira-email or set JIRA_EMAIL environment variable")
|
|
254
|
-
raise typer.Exit(1)
|
|
255
|
-
|
|
256
|
-
if not token:
|
|
257
|
-
console.print("[red]Error:[/red] JIRA API token is required")
|
|
258
|
-
console.print("Use --api-key or set JIRA_API_TOKEN environment variable")
|
|
259
|
-
console.print("[dim]Generate token at: https://id.atlassian.com/manage/api-tokens[/dim]")
|
|
260
|
-
raise typer.Exit(1)
|
|
261
|
-
|
|
262
|
-
config["adapters"]["jira"] = {
|
|
263
|
-
"server": server,
|
|
264
|
-
"email": email,
|
|
265
|
-
"api_token": token
|
|
266
|
-
}
|
|
267
303
|
|
|
268
|
-
|
|
269
|
-
|
|
304
|
+
elif adapter_type == "linear":
|
|
305
|
+
# If not auto-discovered, build from CLI params
|
|
306
|
+
if adapter_type not in config["adapters"]:
|
|
307
|
+
linear_config = {}
|
|
308
|
+
|
|
309
|
+
# Team ID
|
|
310
|
+
if team_id:
|
|
311
|
+
linear_config["team_id"] = team_id
|
|
312
|
+
|
|
313
|
+
# API Key
|
|
314
|
+
linear_api_key = api_key or os.getenv("LINEAR_API_KEY")
|
|
315
|
+
if linear_api_key:
|
|
316
|
+
linear_config["api_key"] = linear_api_key
|
|
317
|
+
elif not discovered:
|
|
318
|
+
console.print("[yellow]Warning:[/yellow] No Linear API key provided.")
|
|
319
|
+
console.print("Set LINEAR_API_KEY environment variable or use --api-key option")
|
|
320
|
+
|
|
321
|
+
if linear_config:
|
|
322
|
+
config["adapters"]["linear"] = linear_config
|
|
323
|
+
|
|
324
|
+
elif adapter_type == "jira":
|
|
325
|
+
# If not auto-discovered, build from CLI params
|
|
326
|
+
if adapter_type not in config["adapters"]:
|
|
327
|
+
server = jira_server or os.getenv("JIRA_SERVER")
|
|
328
|
+
email = jira_email or os.getenv("JIRA_EMAIL")
|
|
329
|
+
token = api_key or os.getenv("JIRA_API_TOKEN")
|
|
330
|
+
project = jira_project or os.getenv("JIRA_PROJECT_KEY")
|
|
331
|
+
|
|
332
|
+
if not server:
|
|
333
|
+
console.print("[red]Error:[/red] JIRA server URL is required")
|
|
334
|
+
console.print("Use --jira-server or set JIRA_SERVER environment variable")
|
|
335
|
+
raise typer.Exit(1)
|
|
336
|
+
|
|
337
|
+
if not email:
|
|
338
|
+
console.print("[red]Error:[/red] JIRA email is required")
|
|
339
|
+
console.print("Use --jira-email or set JIRA_EMAIL environment variable")
|
|
340
|
+
raise typer.Exit(1)
|
|
341
|
+
|
|
342
|
+
if not token:
|
|
343
|
+
console.print("[red]Error:[/red] JIRA API token is required")
|
|
344
|
+
console.print("Use --api-key or set JIRA_API_TOKEN environment variable")
|
|
345
|
+
console.print("[dim]Generate token at: https://id.atlassian.com/manage/api-tokens[/dim]")
|
|
346
|
+
raise typer.Exit(1)
|
|
347
|
+
|
|
348
|
+
jira_config = {
|
|
349
|
+
"server": server,
|
|
350
|
+
"email": email,
|
|
351
|
+
"api_token": token
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if project:
|
|
355
|
+
jira_config["project_key"] = project
|
|
356
|
+
|
|
357
|
+
config["adapters"]["jira"] = jira_config
|
|
358
|
+
|
|
359
|
+
elif adapter_type == "github":
|
|
360
|
+
# If not auto-discovered, build from CLI params
|
|
361
|
+
if adapter_type not in config["adapters"]:
|
|
362
|
+
owner = github_owner or os.getenv("GITHUB_OWNER")
|
|
363
|
+
repo = github_repo or os.getenv("GITHUB_REPO")
|
|
364
|
+
token = github_token or os.getenv("GITHUB_TOKEN")
|
|
365
|
+
|
|
366
|
+
if not owner:
|
|
367
|
+
console.print("[red]Error:[/red] GitHub repository owner is required")
|
|
368
|
+
console.print("Use --github-owner or set GITHUB_OWNER environment variable")
|
|
369
|
+
raise typer.Exit(1)
|
|
370
|
+
|
|
371
|
+
if not repo:
|
|
372
|
+
console.print("[red]Error:[/red] GitHub repository name is required")
|
|
373
|
+
console.print("Use --github-repo or set GITHUB_REPO environment variable")
|
|
374
|
+
raise typer.Exit(1)
|
|
375
|
+
|
|
376
|
+
if not token:
|
|
377
|
+
console.print("[red]Error:[/red] GitHub Personal Access Token is required")
|
|
378
|
+
console.print("Use --github-token or set GITHUB_TOKEN environment variable")
|
|
379
|
+
console.print("[dim]Create token at: https://github.com/settings/tokens/new[/dim]")
|
|
380
|
+
console.print("[dim]Required scopes: repo (for private repos) or public_repo (for public repos)[/dim]")
|
|
381
|
+
raise typer.Exit(1)
|
|
382
|
+
|
|
383
|
+
config["adapters"]["github"] = {
|
|
384
|
+
"owner": owner,
|
|
385
|
+
"repo": repo,
|
|
386
|
+
"token": token
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
# 5. Save to appropriate location
|
|
390
|
+
if global_config:
|
|
391
|
+
# Save to ~/.mcp-ticketer/config.json
|
|
392
|
+
resolver = ConfigResolver(project_path=proj_path)
|
|
393
|
+
config_file_path = resolver.GLOBAL_CONFIG_PATH
|
|
394
|
+
config_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
395
|
+
|
|
396
|
+
with open(config_file_path, 'w') as f:
|
|
397
|
+
json.dump(config, f, indent=2)
|
|
398
|
+
|
|
399
|
+
console.print(f"[green]✓ Initialized with {adapter_type} adapter[/green]")
|
|
400
|
+
console.print(f"[dim]Global configuration saved to {config_file_path}[/dim]")
|
|
401
|
+
else:
|
|
402
|
+
# Save to ./.mcp-ticketer/config.json (PROJECT-SPECIFIC)
|
|
403
|
+
config_file_path = proj_path / ".mcp-ticketer" / "config.json"
|
|
404
|
+
config_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
405
|
+
|
|
406
|
+
with open(config_file_path, 'w') as f:
|
|
407
|
+
json.dump(config, f, indent=2)
|
|
408
|
+
|
|
409
|
+
console.print(f"[green]✓ Initialized with {adapter_type} adapter[/green]")
|
|
410
|
+
console.print(f"[dim]Project configuration saved to {config_file_path}[/dim]")
|
|
411
|
+
|
|
412
|
+
# Add .mcp-ticketer to .gitignore if not already there
|
|
413
|
+
gitignore_path = proj_path / ".gitignore"
|
|
414
|
+
if gitignore_path.exists():
|
|
415
|
+
gitignore_content = gitignore_path.read_text()
|
|
416
|
+
if ".mcp-ticketer" not in gitignore_content:
|
|
417
|
+
with open(gitignore_path, 'a') as f:
|
|
418
|
+
f.write("\n# MCP Ticketer\n.mcp-ticketer/\n")
|
|
419
|
+
console.print("[dim]✓ Added .mcp-ticketer/ to .gitignore[/dim]")
|
|
270
420
|
else:
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
421
|
+
# Create .gitignore if it doesn't exist
|
|
422
|
+
with open(gitignore_path, 'w') as f:
|
|
423
|
+
f.write("# MCP Ticketer\n.mcp-ticketer/\n")
|
|
424
|
+
console.print("[dim]✓ Created .gitignore with .mcp-ticketer/[/dim]")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@app.command()
|
|
428
|
+
def install(
|
|
429
|
+
adapter: Optional[str] = typer.Option(
|
|
430
|
+
None,
|
|
431
|
+
"--adapter",
|
|
432
|
+
"-a",
|
|
433
|
+
help="Adapter type to use (auto-detected from .env if not specified)"
|
|
434
|
+
),
|
|
435
|
+
project_path: Optional[str] = typer.Option(
|
|
436
|
+
None,
|
|
437
|
+
"--path",
|
|
438
|
+
help="Project path (default: current directory)"
|
|
439
|
+
),
|
|
440
|
+
global_config: bool = typer.Option(
|
|
441
|
+
False,
|
|
442
|
+
"--global",
|
|
443
|
+
"-g",
|
|
444
|
+
help="Save to global config instead of project-specific"
|
|
445
|
+
),
|
|
446
|
+
base_path: Optional[str] = typer.Option(
|
|
447
|
+
None,
|
|
448
|
+
"--base-path",
|
|
449
|
+
"-p",
|
|
450
|
+
help="Base path for ticket storage (AITrackdown only)"
|
|
451
|
+
),
|
|
452
|
+
api_key: Optional[str] = typer.Option(
|
|
453
|
+
None,
|
|
454
|
+
"--api-key",
|
|
455
|
+
help="API key for Linear or API token for JIRA"
|
|
456
|
+
),
|
|
457
|
+
team_id: Optional[str] = typer.Option(
|
|
458
|
+
None,
|
|
459
|
+
"--team-id",
|
|
460
|
+
help="Linear team ID (required for Linear adapter)"
|
|
461
|
+
),
|
|
462
|
+
jira_server: Optional[str] = typer.Option(
|
|
463
|
+
None,
|
|
464
|
+
"--jira-server",
|
|
465
|
+
help="JIRA server URL (e.g., https://company.atlassian.net)"
|
|
466
|
+
),
|
|
467
|
+
jira_email: Optional[str] = typer.Option(
|
|
468
|
+
None,
|
|
469
|
+
"--jira-email",
|
|
470
|
+
help="JIRA user email for authentication"
|
|
471
|
+
),
|
|
472
|
+
jira_project: Optional[str] = typer.Option(
|
|
473
|
+
None,
|
|
474
|
+
"--jira-project",
|
|
475
|
+
help="Default JIRA project key"
|
|
476
|
+
),
|
|
477
|
+
github_owner: Optional[str] = typer.Option(
|
|
478
|
+
None,
|
|
479
|
+
"--github-owner",
|
|
480
|
+
help="GitHub repository owner"
|
|
481
|
+
),
|
|
482
|
+
github_repo: Optional[str] = typer.Option(
|
|
483
|
+
None,
|
|
484
|
+
"--github-repo",
|
|
485
|
+
help="GitHub repository name"
|
|
486
|
+
),
|
|
487
|
+
github_token: Optional[str] = typer.Option(
|
|
488
|
+
None,
|
|
489
|
+
"--github-token",
|
|
490
|
+
help="GitHub Personal Access Token"
|
|
491
|
+
),
|
|
492
|
+
) -> None:
|
|
493
|
+
"""Initialize mcp-ticketer for the current project (alias for init).
|
|
494
|
+
|
|
495
|
+
This command is synonymous with 'init' and provides the same functionality.
|
|
496
|
+
Creates .mcp-ticketer/config.json in the current directory with
|
|
497
|
+
auto-detected or specified adapter configuration.
|
|
498
|
+
|
|
499
|
+
Examples:
|
|
500
|
+
# Auto-detect from .env.local
|
|
501
|
+
mcp-ticketer install
|
|
502
|
+
|
|
503
|
+
# Force specific adapter
|
|
504
|
+
mcp-ticketer install --adapter linear
|
|
505
|
+
|
|
506
|
+
# Initialize for different project
|
|
507
|
+
mcp-ticketer install --path /path/to/project
|
|
508
|
+
|
|
509
|
+
# Save globally (not recommended)
|
|
510
|
+
mcp-ticketer install --global
|
|
511
|
+
"""
|
|
512
|
+
# Call init with all parameters
|
|
513
|
+
init(
|
|
514
|
+
adapter=adapter,
|
|
515
|
+
project_path=project_path,
|
|
516
|
+
global_config=global_config,
|
|
517
|
+
base_path=base_path,
|
|
518
|
+
api_key=api_key,
|
|
519
|
+
team_id=team_id,
|
|
520
|
+
jira_server=jira_server,
|
|
521
|
+
jira_email=jira_email,
|
|
522
|
+
jira_project=jira_project,
|
|
523
|
+
github_owner=github_owner,
|
|
524
|
+
github_repo=github_repo,
|
|
525
|
+
github_token=github_token,
|
|
526
|
+
)
|
|
306
527
|
|
|
307
528
|
|
|
308
529
|
@app.command("set")
|
|
@@ -865,6 +1086,9 @@ def search(
|
|
|
865
1086
|
# Add queue command to main app
|
|
866
1087
|
app.add_typer(queue_app, name="queue")
|
|
867
1088
|
|
|
1089
|
+
# Add discover command to main app
|
|
1090
|
+
app.add_typer(discover_app, name="discover")
|
|
1091
|
+
|
|
868
1092
|
|
|
869
1093
|
@app.command()
|
|
870
1094
|
def check(
|
mcp_ticketer/core/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Core models and abstractions for MCP Ticketer."""
|
|
2
2
|
|
|
3
|
-
from .models import Epic, Task, Comment, TicketState, Priority
|
|
3
|
+
from .models import Epic, Task, Comment, TicketState, Priority, TicketType
|
|
4
4
|
from .adapter import BaseAdapter
|
|
5
5
|
from .registry import AdapterRegistry
|
|
6
6
|
|
|
@@ -10,6 +10,7 @@ __all__ = [
|
|
|
10
10
|
"Comment",
|
|
11
11
|
"TicketState",
|
|
12
12
|
"Priority",
|
|
13
|
+
"TicketType",
|
|
13
14
|
"BaseAdapter",
|
|
14
15
|
"AdapterRegistry",
|
|
15
16
|
]
|
mcp_ticketer/core/adapter.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Base adapter abstract class for ticket systems."""
|
|
2
2
|
|
|
3
3
|
from abc import ABC, abstractmethod
|
|
4
|
-
from typing import List, Optional, Dict, Any, TypeVar, Generic
|
|
5
|
-
from .models import Epic, Task, Comment, SearchQuery, TicketState
|
|
4
|
+
from typing import List, Optional, Dict, Any, TypeVar, Generic, Union
|
|
5
|
+
from .models import Epic, Task, Comment, SearchQuery, TicketState, TicketType
|
|
6
6
|
|
|
7
7
|
# Generic type for tickets
|
|
8
8
|
T = TypeVar("T", Epic, Task)
|
|
@@ -206,6 +206,159 @@ class BaseAdapter(ABC, Generic[T]):
|
|
|
206
206
|
return False
|
|
207
207
|
return current_state.can_transition_to(target_state)
|
|
208
208
|
|
|
209
|
+
# Epic/Issue/Task Hierarchy Methods
|
|
210
|
+
|
|
211
|
+
async def create_epic(
|
|
212
|
+
self,
|
|
213
|
+
title: str,
|
|
214
|
+
description: Optional[str] = None,
|
|
215
|
+
**kwargs
|
|
216
|
+
) -> Optional[Epic]:
|
|
217
|
+
"""Create epic (top-level grouping).
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
title: Epic title
|
|
221
|
+
description: Epic description
|
|
222
|
+
**kwargs: Additional adapter-specific fields
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Created epic or None if failed
|
|
226
|
+
"""
|
|
227
|
+
epic = Epic(
|
|
228
|
+
title=title,
|
|
229
|
+
description=description,
|
|
230
|
+
ticket_type=TicketType.EPIC,
|
|
231
|
+
**{k: v for k, v in kwargs.items() if k in Epic.__fields__}
|
|
232
|
+
)
|
|
233
|
+
result = await self.create(epic)
|
|
234
|
+
if isinstance(result, Epic):
|
|
235
|
+
return result
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
async def get_epic(self, epic_id: str) -> Optional[Epic]:
|
|
239
|
+
"""Get epic by ID.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
epic_id: Epic identifier
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Epic if found, None otherwise
|
|
246
|
+
"""
|
|
247
|
+
# Default implementation - subclasses should override for platform-specific logic
|
|
248
|
+
result = await self.read(epic_id)
|
|
249
|
+
if isinstance(result, Epic):
|
|
250
|
+
return result
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
async def list_epics(self, **kwargs) -> List[Epic]:
|
|
254
|
+
"""List all epics.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
**kwargs: Adapter-specific filter parameters
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
List of epics
|
|
261
|
+
"""
|
|
262
|
+
# Default implementation - subclasses should override
|
|
263
|
+
filters = kwargs.copy()
|
|
264
|
+
filters["ticket_type"] = TicketType.EPIC
|
|
265
|
+
results = await self.list(filters=filters)
|
|
266
|
+
return [r for r in results if isinstance(r, Epic)]
|
|
267
|
+
|
|
268
|
+
async def create_issue(
|
|
269
|
+
self,
|
|
270
|
+
title: str,
|
|
271
|
+
description: Optional[str] = None,
|
|
272
|
+
epic_id: Optional[str] = None,
|
|
273
|
+
**kwargs
|
|
274
|
+
) -> Optional[Task]:
|
|
275
|
+
"""Create issue, optionally linked to epic.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
title: Issue title
|
|
279
|
+
description: Issue description
|
|
280
|
+
epic_id: Optional parent epic ID
|
|
281
|
+
**kwargs: Additional adapter-specific fields
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Created issue or None if failed
|
|
285
|
+
"""
|
|
286
|
+
task = Task(
|
|
287
|
+
title=title,
|
|
288
|
+
description=description,
|
|
289
|
+
ticket_type=TicketType.ISSUE,
|
|
290
|
+
parent_epic=epic_id,
|
|
291
|
+
**{k: v for k, v in kwargs.items() if k in Task.__fields__}
|
|
292
|
+
)
|
|
293
|
+
return await self.create(task)
|
|
294
|
+
|
|
295
|
+
async def list_issues_by_epic(self, epic_id: str) -> List[Task]:
|
|
296
|
+
"""List all issues in epic.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
epic_id: Epic identifier
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
List of issues belonging to epic
|
|
303
|
+
"""
|
|
304
|
+
# Default implementation - subclasses should override for efficiency
|
|
305
|
+
filters = {"parent_epic": epic_id, "ticket_type": TicketType.ISSUE}
|
|
306
|
+
results = await self.list(filters=filters)
|
|
307
|
+
return [r for r in results if isinstance(r, Task) and r.is_issue()]
|
|
308
|
+
|
|
309
|
+
async def create_task(
|
|
310
|
+
self,
|
|
311
|
+
title: str,
|
|
312
|
+
parent_id: str,
|
|
313
|
+
description: Optional[str] = None,
|
|
314
|
+
**kwargs
|
|
315
|
+
) -> Optional[Task]:
|
|
316
|
+
"""Create task as sub-ticket of parent issue.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
title: Task title
|
|
320
|
+
parent_id: Required parent issue ID
|
|
321
|
+
description: Task description
|
|
322
|
+
**kwargs: Additional adapter-specific fields
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
Created task or None if failed
|
|
326
|
+
|
|
327
|
+
Raises:
|
|
328
|
+
ValueError: If parent_id is not provided
|
|
329
|
+
"""
|
|
330
|
+
if not parent_id:
|
|
331
|
+
raise ValueError("Tasks must have a parent_id (issue)")
|
|
332
|
+
|
|
333
|
+
task = Task(
|
|
334
|
+
title=title,
|
|
335
|
+
description=description,
|
|
336
|
+
ticket_type=TicketType.TASK,
|
|
337
|
+
parent_issue=parent_id,
|
|
338
|
+
**{k: v for k, v in kwargs.items() if k in Task.__fields__}
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Validate hierarchy before creating
|
|
342
|
+
errors = task.validate_hierarchy()
|
|
343
|
+
if errors:
|
|
344
|
+
raise ValueError(f"Invalid task hierarchy: {'; '.join(errors)}")
|
|
345
|
+
|
|
346
|
+
return await self.create(task)
|
|
347
|
+
|
|
348
|
+
async def list_tasks_by_issue(self, issue_id: str) -> List[Task]:
|
|
349
|
+
"""List all tasks under an issue.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
issue_id: Issue identifier
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
List of tasks belonging to issue
|
|
356
|
+
"""
|
|
357
|
+
# Default implementation - subclasses should override for efficiency
|
|
358
|
+
filters = {"parent_issue": issue_id, "ticket_type": TicketType.TASK}
|
|
359
|
+
results = await self.list(filters=filters)
|
|
360
|
+
return [r for r in results if isinstance(r, Task) and r.is_task()]
|
|
361
|
+
|
|
209
362
|
async def close(self) -> None:
|
|
210
363
|
"""Close adapter and cleanup resources."""
|
|
211
364
|
pass
|