mcp-ticketer 0.4.1__py3-none-any.whl → 0.4.3__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.

Files changed (56) hide show
  1. mcp_ticketer/__init__.py +3 -12
  2. mcp_ticketer/__version__.py +1 -1
  3. mcp_ticketer/adapters/aitrackdown.py +243 -11
  4. mcp_ticketer/adapters/github.py +15 -14
  5. mcp_ticketer/adapters/hybrid.py +11 -11
  6. mcp_ticketer/adapters/jira.py +22 -25
  7. mcp_ticketer/adapters/linear/adapter.py +9 -21
  8. mcp_ticketer/adapters/linear/client.py +2 -1
  9. mcp_ticketer/adapters/linear/mappers.py +2 -1
  10. mcp_ticketer/cache/memory.py +6 -5
  11. mcp_ticketer/cli/adapter_diagnostics.py +4 -2
  12. mcp_ticketer/cli/auggie_configure.py +66 -0
  13. mcp_ticketer/cli/codex_configure.py +70 -2
  14. mcp_ticketer/cli/configure.py +7 -14
  15. mcp_ticketer/cli/diagnostics.py +2 -2
  16. mcp_ticketer/cli/discover.py +6 -11
  17. mcp_ticketer/cli/gemini_configure.py +68 -2
  18. mcp_ticketer/cli/linear_commands.py +6 -7
  19. mcp_ticketer/cli/main.py +341 -203
  20. mcp_ticketer/cli/mcp_configure.py +61 -2
  21. mcp_ticketer/cli/ticket_commands.py +27 -30
  22. mcp_ticketer/cli/utils.py +23 -22
  23. mcp_ticketer/core/__init__.py +3 -1
  24. mcp_ticketer/core/adapter.py +82 -13
  25. mcp_ticketer/core/config.py +27 -29
  26. mcp_ticketer/core/env_discovery.py +10 -10
  27. mcp_ticketer/core/env_loader.py +8 -8
  28. mcp_ticketer/core/http_client.py +16 -16
  29. mcp_ticketer/core/mappers.py +10 -10
  30. mcp_ticketer/core/models.py +50 -20
  31. mcp_ticketer/core/project_config.py +40 -34
  32. mcp_ticketer/core/registry.py +2 -2
  33. mcp_ticketer/mcp/dto.py +32 -32
  34. mcp_ticketer/mcp/response_builder.py +2 -2
  35. mcp_ticketer/mcp/server.py +17 -37
  36. mcp_ticketer/mcp/server_sdk.py +93 -0
  37. mcp_ticketer/mcp/tools/__init__.py +36 -0
  38. mcp_ticketer/mcp/tools/attachment_tools.py +179 -0
  39. mcp_ticketer/mcp/tools/bulk_tools.py +273 -0
  40. mcp_ticketer/mcp/tools/comment_tools.py +90 -0
  41. mcp_ticketer/mcp/tools/hierarchy_tools.py +383 -0
  42. mcp_ticketer/mcp/tools/pr_tools.py +154 -0
  43. mcp_ticketer/mcp/tools/search_tools.py +206 -0
  44. mcp_ticketer/mcp/tools/ticket_tools.py +277 -0
  45. mcp_ticketer/queue/health_monitor.py +4 -4
  46. mcp_ticketer/queue/manager.py +2 -2
  47. mcp_ticketer/queue/queue.py +16 -16
  48. mcp_ticketer/queue/ticket_registry.py +7 -7
  49. mcp_ticketer/queue/worker.py +2 -2
  50. {mcp_ticketer-0.4.1.dist-info → mcp_ticketer-0.4.3.dist-info}/METADATA +90 -17
  51. mcp_ticketer-0.4.3.dist-info/RECORD +73 -0
  52. mcp_ticketer-0.4.1.dist-info/RECORD +0 -64
  53. {mcp_ticketer-0.4.1.dist-info → mcp_ticketer-0.4.3.dist-info}/WHEEL +0 -0
  54. {mcp_ticketer-0.4.1.dist-info → mcp_ticketer-0.4.3.dist-info}/entry_points.txt +0 -0
  55. {mcp_ticketer-0.4.1.dist-info → mcp_ticketer-0.4.3.dist-info}/licenses/LICENSE +0 -0
  56. {mcp_ticketer-0.4.1.dist-info → mcp_ticketer-0.4.3.dist-info}/top_level.txt +0 -0
@@ -5,7 +5,6 @@ import os
5
5
  import shutil
6
6
  import sys
7
7
  from pathlib import Path
8
- from typing import Optional
9
8
 
10
9
  from rich.console import Console
11
10
 
@@ -171,7 +170,7 @@ def save_claude_mcp_config(config_path: Path, config: dict) -> None:
171
170
 
172
171
 
173
172
  def create_mcp_server_config(
174
- binary_path: str, project_config: dict, cwd: Optional[str] = None
173
+ binary_path: str, project_config: dict, cwd: str | None = None
175
174
  ) -> dict:
176
175
  """Create MCP server configuration for mcp-ticketer.
177
176
 
@@ -217,6 +216,66 @@ def create_mcp_server_config(
217
216
  return config
218
217
 
219
218
 
219
+ def remove_claude_mcp(global_config: bool = False, dry_run: bool = False) -> None:
220
+ """Remove mcp-ticketer from Claude Code/Desktop configuration.
221
+
222
+ Args:
223
+ global_config: Remove from Claude Desktop instead of project-level
224
+ dry_run: Show what would be removed without making changes
225
+
226
+ """
227
+ # Step 1: Find Claude MCP config location
228
+ config_type = "Claude Desktop" if global_config else "project-level"
229
+ console.print(f"[cyan]🔍 Removing {config_type} MCP configuration...[/cyan]")
230
+
231
+ mcp_config_path = find_claude_mcp_config(global_config)
232
+ console.print(f"[dim]Config location: {mcp_config_path}[/dim]")
233
+
234
+ # Step 2: Check if config file exists
235
+ if not mcp_config_path.exists():
236
+ console.print(f"[yellow]⚠ No configuration found at {mcp_config_path}[/yellow]")
237
+ console.print("[dim]mcp-ticketer is not configured for this platform[/dim]")
238
+ return
239
+
240
+ # Step 3: Load existing MCP configuration
241
+ mcp_config = load_claude_mcp_config(mcp_config_path)
242
+
243
+ # Step 4: Check if mcp-ticketer is configured
244
+ if "mcp-ticketer" not in mcp_config.get("mcpServers", {}):
245
+ console.print("[yellow]⚠ mcp-ticketer is not configured[/yellow]")
246
+ console.print(f"[dim]No mcp-ticketer entry found in {mcp_config_path}[/dim]")
247
+ return
248
+
249
+ # Step 5: Show what would be removed (dry run or actual removal)
250
+ if dry_run:
251
+ console.print("\n[cyan]DRY RUN - Would remove:[/cyan]")
252
+ console.print(" Server name: mcp-ticketer")
253
+ console.print(f" From: {mcp_config_path}")
254
+ return
255
+
256
+ # Step 6: Remove mcp-ticketer from configuration
257
+ del mcp_config["mcpServers"]["mcp-ticketer"]
258
+
259
+ # Step 7: Save updated configuration
260
+ try:
261
+ save_claude_mcp_config(mcp_config_path, mcp_config)
262
+ console.print("\n[green]✓ Successfully removed mcp-ticketer[/green]")
263
+ console.print(f"[dim]Configuration updated: {mcp_config_path}[/dim]")
264
+
265
+ # Next steps
266
+ console.print("\n[bold cyan]Next Steps:[/bold cyan]")
267
+ if global_config:
268
+ console.print("1. Restart Claude Desktop")
269
+ console.print("2. mcp-ticketer will no longer be available in MCP menu")
270
+ else:
271
+ console.print("1. Restart Claude Code")
272
+ console.print("2. mcp-ticketer will no longer be available in this project")
273
+
274
+ except Exception as e:
275
+ console.print(f"\n[red]✗ Failed to update configuration:[/red] {e}")
276
+ raise
277
+
278
+
220
279
  def configure_claude_mcp(global_config: bool = False, force: bool = False) -> None:
221
280
  """Configure Claude Code to use mcp-ticketer.
222
281
 
@@ -5,7 +5,6 @@ import json
5
5
  import os
6
6
  from enum import Enum
7
7
  from pathlib import Path
8
- from typing import Optional
9
8
 
10
9
  import typer
11
10
  from rich.console import Console
@@ -36,7 +35,7 @@ console = Console()
36
35
 
37
36
 
38
37
  # Configuration functions (moved from main.py to avoid circular import)
39
- def load_config(project_dir: Optional[Path] = None) -> dict:
38
+ def load_config(project_dir: Path | None = None) -> dict:
40
39
  """Load configuration from project-local config file."""
41
40
  import logging
42
41
 
@@ -73,7 +72,7 @@ def save_config(config: dict) -> None:
73
72
 
74
73
 
75
74
  def get_adapter(
76
- override_adapter: Optional[str] = None, override_config: Optional[dict] = None
75
+ override_adapter: str | None = None, override_config: dict | None = None
77
76
  ):
78
77
  """Get configured adapter instance."""
79
78
  config = load_config()
@@ -108,7 +107,7 @@ def get_adapter(
108
107
  return AdapterRegistry.get_adapter(adapter_type, adapter_config)
109
108
 
110
109
 
111
- def _discover_from_env_files() -> Optional[str]:
110
+ def _discover_from_env_files() -> str | None:
112
111
  """Discover adapter configuration from .env or .env.local files.
113
112
 
114
113
  Returns:
@@ -191,29 +190,29 @@ def _save_adapter_to_config(adapter_name: str) -> None:
191
190
  @app.command()
192
191
  def create(
193
192
  title: str = typer.Argument(..., help="Ticket title"),
194
- description: Optional[str] = typer.Option(
193
+ description: str | None = typer.Option(
195
194
  None, "--description", "-d", help="Ticket description"
196
195
  ),
197
196
  priority: Priority = typer.Option(
198
197
  Priority.MEDIUM, "--priority", "-p", help="Priority level"
199
198
  ),
200
- tags: Optional[list[str]] = typer.Option(
199
+ tags: list[str] | None = typer.Option(
201
200
  None, "--tag", "-t", help="Tags (can be specified multiple times)"
202
201
  ),
203
- assignee: Optional[str] = typer.Option(
202
+ assignee: str | None = typer.Option(
204
203
  None, "--assignee", "-a", help="Assignee username"
205
204
  ),
206
- project: Optional[str] = typer.Option(
205
+ project: str | None = typer.Option(
207
206
  None,
208
207
  "--project",
209
208
  help="Parent project/epic ID (synonym for --epic)",
210
209
  ),
211
- epic: Optional[str] = typer.Option(
210
+ epic: str | None = typer.Option(
212
211
  None,
213
212
  "--epic",
214
213
  help="Parent epic/project ID (synonym for --project)",
215
214
  ),
216
- adapter: Optional[AdapterType] = typer.Option(
215
+ adapter: AdapterType | None = typer.Option(
217
216
  None, "--adapter", help="Override default adapter"
218
217
  ),
219
218
  ) -> None:
@@ -417,14 +416,14 @@ def create(
417
416
 
418
417
  @app.command("list")
419
418
  def list_tickets(
420
- state: Optional[TicketState] = typer.Option(
419
+ state: TicketState | None = typer.Option(
421
420
  None, "--state", "-s", help="Filter by state"
422
421
  ),
423
- priority: Optional[Priority] = typer.Option(
422
+ priority: Priority | None = typer.Option(
424
423
  None, "--priority", "-p", help="Filter by priority"
425
424
  ),
426
425
  limit: int = typer.Option(10, "--limit", "-l", help="Maximum number of tickets"),
427
- adapter: Optional[AdapterType] = typer.Option(
426
+ adapter: AdapterType | None = typer.Option(
428
427
  None, "--adapter", help="Override default adapter"
429
428
  ),
430
429
  ) -> None:
@@ -474,7 +473,7 @@ def list_tickets(
474
473
  def show(
475
474
  ticket_id: str = typer.Argument(..., help="Ticket ID"),
476
475
  comments: bool = typer.Option(False, "--comments", "-c", help="Show comments"),
477
- adapter: Optional[AdapterType] = typer.Option(
476
+ adapter: AdapterType | None = typer.Option(
478
477
  None, "--adapter", help="Override default adapter"
479
478
  ),
480
479
  ) -> None:
@@ -524,7 +523,7 @@ def show(
524
523
  def comment(
525
524
  ticket_id: str = typer.Argument(..., help="Ticket ID"),
526
525
  content: str = typer.Argument(..., help="Comment content"),
527
- adapter: Optional[AdapterType] = typer.Option(
526
+ adapter: AdapterType | None = typer.Option(
528
527
  None, "--adapter", help="Override default adapter"
529
528
  ),
530
529
  ) -> None:
@@ -559,17 +558,15 @@ def comment(
559
558
  @app.command()
560
559
  def update(
561
560
  ticket_id: str = typer.Argument(..., help="Ticket ID"),
562
- title: Optional[str] = typer.Option(None, "--title", help="New title"),
563
- description: Optional[str] = typer.Option(
561
+ title: str | None = typer.Option(None, "--title", help="New title"),
562
+ description: str | None = typer.Option(
564
563
  None, "--description", "-d", help="New description"
565
564
  ),
566
- priority: Optional[Priority] = typer.Option(
565
+ priority: Priority | None = typer.Option(
567
566
  None, "--priority", "-p", help="New priority"
568
567
  ),
569
- assignee: Optional[str] = typer.Option(
570
- None, "--assignee", "-a", help="New assignee"
571
- ),
572
- adapter: Optional[AdapterType] = typer.Option(
568
+ assignee: str | None = typer.Option(None, "--assignee", "-a", help="New assignee"),
569
+ adapter: AdapterType | None = typer.Option(
573
570
  None, "--adapter", help="Override default adapter"
574
571
  ),
575
572
  ) -> None:
@@ -625,13 +622,13 @@ def update(
625
622
  @app.command()
626
623
  def transition(
627
624
  ticket_id: str = typer.Argument(..., help="Ticket ID"),
628
- state_positional: Optional[TicketState] = typer.Argument(
625
+ state_positional: TicketState | None = typer.Argument(
629
626
  None, help="Target state (positional - deprecated, use --state instead)"
630
627
  ),
631
- state: Optional[TicketState] = typer.Option(
628
+ state: TicketState | None = typer.Option(
632
629
  None, "--state", "-s", help="Target state (recommended)"
633
630
  ),
634
- adapter: Optional[AdapterType] = typer.Option(
631
+ adapter: AdapterType | None = typer.Option(
635
632
  None, "--adapter", help="Override default adapter"
636
633
  ),
637
634
  ) -> None:
@@ -692,12 +689,12 @@ def transition(
692
689
 
693
690
  @app.command()
694
691
  def search(
695
- query: Optional[str] = typer.Argument(None, help="Search query"),
696
- state: Optional[TicketState] = typer.Option(None, "--state", "-s"),
697
- priority: Optional[Priority] = typer.Option(None, "--priority", "-p"),
698
- assignee: Optional[str] = typer.Option(None, "--assignee", "-a"),
692
+ query: str | None = typer.Argument(None, help="Search query"),
693
+ state: TicketState | None = typer.Option(None, "--state", "-s"),
694
+ priority: Priority | None = typer.Option(None, "--priority", "-p"),
695
+ assignee: str | None = typer.Option(None, "--assignee", "-a"),
699
696
  limit: int = typer.Option(10, "--limit", "-l"),
700
- adapter: Optional[AdapterType] = typer.Option(
697
+ adapter: AdapterType | None = typer.Option(
701
698
  None, "--adapter", help="Override default adapter"
702
699
  ),
703
700
  ) -> None:
mcp_ticketer/cli/utils.py CHANGED
@@ -4,9 +4,10 @@ import asyncio
4
4
  import json
5
5
  import logging
6
6
  import os
7
+ from collections.abc import Callable
7
8
  from functools import wraps
8
9
  from pathlib import Path
9
- from typing import Any, Callable, Optional, TypeVar
10
+ from typing import Any, TypeVar
10
11
 
11
12
  import typer
12
13
  from rich.console import Console
@@ -154,7 +155,7 @@ class CommonPatterns:
154
155
 
155
156
  @staticmethod
156
157
  def get_adapter(
157
- override_adapter: Optional[str] = None, override_config: Optional[dict] = None
158
+ override_adapter: str | None = None, override_config: dict | None = None
158
159
  ):
159
160
  """Get configured adapter instance with environment variable support."""
160
161
  config = CommonPatterns.load_config()
@@ -206,7 +207,7 @@ class CommonPatterns:
206
207
  def queue_operation(
207
208
  ticket_data: dict[str, Any],
208
209
  operation: str,
209
- adapter_name: Optional[str] = None,
210
+ adapter_name: str | None = None,
210
211
  show_progress: bool = True,
211
212
  ) -> str:
212
213
  """Queue an operation and optionally start the worker."""
@@ -265,7 +266,7 @@ class CommonPatterns:
265
266
  console.print(table)
266
267
 
267
268
  @staticmethod
268
- def display_ticket_details(ticket: Task, comments: Optional[list] = None) -> None:
269
+ def display_ticket_details(ticket: Task, comments: list | None = None) -> None:
269
270
  """Display detailed ticket information."""
270
271
  console.print(f"\n[bold]Ticket: {ticket.id}[/bold]")
271
272
  console.print(f"Title: {ticket.title}")
@@ -334,7 +335,7 @@ def with_adapter(f: Callable) -> Callable:
334
335
  """Decorator to inject adapter instance into CLI commands."""
335
336
 
336
337
  @wraps(f)
337
- def wrapper(adapter: Optional[str] = None, *args, **kwargs):
338
+ def wrapper(adapter: str | None = None, *args, **kwargs):
338
339
  adapter_instance = CommonPatterns.get_adapter(override_adapter=adapter)
339
340
  return f(adapter_instance, *args, **kwargs)
340
341
 
@@ -446,7 +447,7 @@ class ConfigValidator:
446
447
  return issues
447
448
 
448
449
  @staticmethod
449
- def _get_env_var(adapter_type: str, field: str) -> Optional[str]:
450
+ def _get_env_var(adapter_type: str, field: str) -> str | None:
450
451
  """Get corresponding environment variable name for a config field."""
451
452
  env_mapping = {
452
453
  "github": {
@@ -520,14 +521,14 @@ def create_standard_ticket_command(operation: str):
520
521
  """Create a standard ticket operation command."""
521
522
 
522
523
  def command_template(
523
- ticket_id: Optional[str] = None,
524
- title: Optional[str] = None,
525
- description: Optional[str] = None,
526
- priority: Optional[Priority] = None,
527
- state: Optional[TicketState] = None,
528
- assignee: Optional[str] = None,
529
- tags: Optional[list[str]] = None,
530
- adapter: Optional[str] = None,
524
+ ticket_id: str | None = None,
525
+ title: str | None = None,
526
+ description: str | None = None,
527
+ priority: Priority | None = None,
528
+ state: TicketState | None = None,
529
+ assignee: str | None = None,
530
+ tags: list[str] | None = None,
531
+ adapter: str | None = None,
531
532
  ):
532
533
  """Template for ticket commands."""
533
534
  # Build ticket data
@@ -568,8 +569,8 @@ class TicketCommands:
568
569
  @handle_adapter_errors
569
570
  async def list_tickets(
570
571
  adapter_instance,
571
- state: Optional[TicketState] = None,
572
- priority: Optional[Priority] = None,
572
+ state: TicketState | None = None,
573
+ priority: Priority | None = None,
573
574
  limit: int = 10,
574
575
  ):
575
576
  """List tickets with filters."""
@@ -603,11 +604,11 @@ class TicketCommands:
603
604
  @staticmethod
604
605
  def create_ticket(
605
606
  title: str,
606
- description: Optional[str] = None,
607
+ description: str | None = None,
607
608
  priority: Priority = Priority.MEDIUM,
608
- tags: Optional[list[str]] = None,
609
- assignee: Optional[str] = None,
610
- adapter: Optional[str] = None,
609
+ tags: list[str] | None = None,
610
+ assignee: str | None = None,
611
+ adapter: str | None = None,
611
612
  ) -> str:
612
613
  """Create a new ticket."""
613
614
  ticket_data = {
@@ -622,7 +623,7 @@ class TicketCommands:
622
623
 
623
624
  @staticmethod
624
625
  def update_ticket(
625
- ticket_id: str, updates: dict[str, Any], adapter: Optional[str] = None
626
+ ticket_id: str, updates: dict[str, Any], adapter: str | None = None
626
627
  ) -> str:
627
628
  """Update a ticket."""
628
629
  if not updates:
@@ -634,7 +635,7 @@ class TicketCommands:
634
635
 
635
636
  @staticmethod
636
637
  def transition_ticket(
637
- ticket_id: str, state: TicketState, adapter: Optional[str] = None
638
+ ticket_id: str, state: TicketState, adapter: str | None = None
638
639
  ) -> str:
639
640
  """Transition ticket state."""
640
641
  ticket_data = {
@@ -1,13 +1,15 @@
1
1
  """Core models and abstractions for MCP Ticketer."""
2
2
 
3
3
  from .adapter import BaseAdapter
4
- from .models import Comment, Epic, Priority, Task, TicketState, TicketType
4
+ from .models import (Attachment, Comment, Epic, Priority, Task, TicketState,
5
+ TicketType)
5
6
  from .registry import AdapterRegistry
6
7
 
7
8
  __all__ = [
8
9
  "Epic",
9
10
  "Task",
10
11
  "Comment",
12
+ "Attachment",
11
13
  "TicketState",
12
14
  "Priority",
13
15
  "TicketType",
@@ -1,11 +1,16 @@
1
1
  """Base adapter abstract class for ticket systems."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import builtins
4
6
  from abc import ABC, abstractmethod
5
- from typing import Any, Generic, Optional, TypeVar
7
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
6
8
 
7
9
  from .models import Comment, Epic, SearchQuery, Task, TicketState, TicketType
8
10
 
11
+ if TYPE_CHECKING:
12
+ from .models import Attachment
13
+
9
14
  # Generic type for tickets
10
15
  T = TypeVar("T", Epic, Task)
11
16
 
@@ -57,7 +62,7 @@ class BaseAdapter(ABC, Generic[T]):
57
62
  pass
58
63
 
59
64
  @abstractmethod
60
- async def read(self, ticket_id: str) -> Optional[T]:
65
+ async def read(self, ticket_id: str) -> T | None:
61
66
  """Read a ticket by ID.
62
67
 
63
68
  Args:
@@ -70,7 +75,7 @@ class BaseAdapter(ABC, Generic[T]):
70
75
  pass
71
76
 
72
77
  @abstractmethod
73
- async def update(self, ticket_id: str, updates: dict[str, Any]) -> Optional[T]:
78
+ async def update(self, ticket_id: str, updates: dict[str, Any]) -> T | None:
74
79
  """Update a ticket.
75
80
 
76
81
  Args:
@@ -98,7 +103,7 @@ class BaseAdapter(ABC, Generic[T]):
98
103
 
99
104
  @abstractmethod
100
105
  async def list(
101
- self, limit: int = 10, offset: int = 0, filters: Optional[dict[str, Any]] = None
106
+ self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
102
107
  ) -> list[T]:
103
108
  """List tickets with pagination and filters.
104
109
 
@@ -129,7 +134,7 @@ class BaseAdapter(ABC, Generic[T]):
129
134
  @abstractmethod
130
135
  async def transition_state(
131
136
  self, ticket_id: str, target_state: TicketState
132
- ) -> Optional[T]:
137
+ ) -> T | None:
133
138
  """Transition ticket to a new state.
134
139
 
135
140
  Args:
@@ -225,8 +230,8 @@ class BaseAdapter(ABC, Generic[T]):
225
230
  # Epic/Issue/Task Hierarchy Methods
226
231
 
227
232
  async def create_epic(
228
- self, title: str, description: Optional[str] = None, **kwargs
229
- ) -> Optional[Epic]:
233
+ self, title: str, description: str | None = None, **kwargs
234
+ ) -> Epic | None:
230
235
  """Create epic (top-level grouping).
231
236
 
232
237
  Args:
@@ -249,7 +254,7 @@ class BaseAdapter(ABC, Generic[T]):
249
254
  return result
250
255
  return None
251
256
 
252
- async def get_epic(self, epic_id: str) -> Optional[Epic]:
257
+ async def get_epic(self, epic_id: str) -> Epic | None:
253
258
  """Get epic by ID.
254
259
 
255
260
  Args:
@@ -284,10 +289,10 @@ class BaseAdapter(ABC, Generic[T]):
284
289
  async def create_issue(
285
290
  self,
286
291
  title: str,
287
- description: Optional[str] = None,
288
- epic_id: Optional[str] = None,
292
+ description: str | None = None,
293
+ epic_id: str | None = None,
289
294
  **kwargs,
290
- ) -> Optional[Task]:
295
+ ) -> Task | None:
291
296
  """Create issue, optionally linked to epic.
292
297
 
293
298
  Args:
@@ -325,8 +330,8 @@ class BaseAdapter(ABC, Generic[T]):
325
330
  return [r for r in results if isinstance(r, Task) and r.is_issue()]
326
331
 
327
332
  async def create_task(
328
- self, title: str, parent_id: str, description: Optional[str] = None, **kwargs
329
- ) -> Optional[Task]:
333
+ self, title: str, parent_id: str, description: str | None = None, **kwargs
334
+ ) -> Task | None:
330
335
  """Create task as sub-ticket of parent issue.
331
336
 
332
337
  Args:
@@ -375,6 +380,70 @@ class BaseAdapter(ABC, Generic[T]):
375
380
  results = await self.list(filters=filters)
376
381
  return [r for r in results if isinstance(r, Task) and r.is_task()]
377
382
 
383
+ # Attachment methods
384
+ async def add_attachment(
385
+ self,
386
+ ticket_id: str,
387
+ file_path: str,
388
+ description: str | None = None,
389
+ ) -> Attachment:
390
+ """Attach a file to a ticket.
391
+
392
+ Args:
393
+ ticket_id: Ticket identifier
394
+ file_path: Local file path to upload
395
+ description: Optional attachment description
396
+
397
+ Returns:
398
+ Created Attachment with metadata
399
+
400
+ Raises:
401
+ NotImplementedError: If adapter doesn't support attachments
402
+ FileNotFoundError: If file doesn't exist
403
+ ValueError: If ticket doesn't exist or upload fails
404
+
405
+ """
406
+ raise NotImplementedError(
407
+ f"{self.__class__.__name__} does not support file attachments. "
408
+ "Use comments to reference external files instead."
409
+ )
410
+
411
+ async def get_attachments(self, ticket_id: str) -> list[Attachment]:
412
+ """Get all attachments for a ticket.
413
+
414
+ Args:
415
+ ticket_id: Ticket identifier
416
+
417
+ Returns:
418
+ List of attachments (empty if none or not supported)
419
+
420
+ """
421
+ raise NotImplementedError(
422
+ f"{self.__class__.__name__} does not support file attachments."
423
+ )
424
+
425
+ async def delete_attachment(
426
+ self,
427
+ ticket_id: str,
428
+ attachment_id: str,
429
+ ) -> bool:
430
+ """Delete an attachment (optional implementation).
431
+
432
+ Args:
433
+ ticket_id: Ticket identifier
434
+ attachment_id: Attachment identifier
435
+
436
+ Returns:
437
+ True if deleted, False otherwise
438
+
439
+ Raises:
440
+ NotImplementedError: If adapter doesn't support deletion
441
+
442
+ """
443
+ raise NotImplementedError(
444
+ f"{self.__class__.__name__} does not support attachment deletion."
445
+ )
446
+
378
447
  async def close(self) -> None:
379
448
  """Close adapter and cleanup resources."""
380
449
  pass