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.

Files changed (109) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +3 -3
  3. mcp_ticketer/adapters/__init__.py +2 -0
  4. mcp_ticketer/adapters/aitrackdown.py +796 -46
  5. mcp_ticketer/adapters/asana/__init__.py +15 -0
  6. mcp_ticketer/adapters/asana/adapter.py +1416 -0
  7. mcp_ticketer/adapters/asana/client.py +292 -0
  8. mcp_ticketer/adapters/asana/mappers.py +348 -0
  9. mcp_ticketer/adapters/asana/types.py +146 -0
  10. mcp_ticketer/adapters/github.py +879 -129
  11. mcp_ticketer/adapters/hybrid.py +11 -11
  12. mcp_ticketer/adapters/jira.py +973 -73
  13. mcp_ticketer/adapters/linear/__init__.py +24 -0
  14. mcp_ticketer/adapters/linear/adapter.py +2732 -0
  15. mcp_ticketer/adapters/linear/client.py +344 -0
  16. mcp_ticketer/adapters/linear/mappers.py +420 -0
  17. mcp_ticketer/adapters/linear/queries.py +479 -0
  18. mcp_ticketer/adapters/linear/types.py +360 -0
  19. mcp_ticketer/adapters/linear.py +10 -2315
  20. mcp_ticketer/analysis/__init__.py +23 -0
  21. mcp_ticketer/analysis/orphaned.py +218 -0
  22. mcp_ticketer/analysis/similarity.py +224 -0
  23. mcp_ticketer/analysis/staleness.py +266 -0
  24. mcp_ticketer/cache/memory.py +9 -8
  25. mcp_ticketer/cli/adapter_diagnostics.py +421 -0
  26. mcp_ticketer/cli/auggie_configure.py +116 -15
  27. mcp_ticketer/cli/codex_configure.py +274 -82
  28. mcp_ticketer/cli/configure.py +888 -151
  29. mcp_ticketer/cli/diagnostics.py +400 -157
  30. mcp_ticketer/cli/discover.py +297 -26
  31. mcp_ticketer/cli/gemini_configure.py +119 -26
  32. mcp_ticketer/cli/init_command.py +880 -0
  33. mcp_ticketer/cli/instruction_commands.py +435 -0
  34. mcp_ticketer/cli/linear_commands.py +616 -0
  35. mcp_ticketer/cli/main.py +203 -1165
  36. mcp_ticketer/cli/mcp_configure.py +474 -90
  37. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  38. mcp_ticketer/cli/migrate_config.py +12 -8
  39. mcp_ticketer/cli/platform_commands.py +123 -0
  40. mcp_ticketer/cli/platform_detection.py +418 -0
  41. mcp_ticketer/cli/platform_installer.py +513 -0
  42. mcp_ticketer/cli/python_detection.py +126 -0
  43. mcp_ticketer/cli/queue_commands.py +15 -15
  44. mcp_ticketer/cli/setup_command.py +639 -0
  45. mcp_ticketer/cli/simple_health.py +90 -65
  46. mcp_ticketer/cli/ticket_commands.py +1013 -0
  47. mcp_ticketer/cli/update_checker.py +313 -0
  48. mcp_ticketer/cli/utils.py +114 -66
  49. mcp_ticketer/core/__init__.py +24 -1
  50. mcp_ticketer/core/adapter.py +250 -16
  51. mcp_ticketer/core/config.py +145 -37
  52. mcp_ticketer/core/env_discovery.py +101 -22
  53. mcp_ticketer/core/env_loader.py +349 -0
  54. mcp_ticketer/core/exceptions.py +160 -0
  55. mcp_ticketer/core/http_client.py +26 -26
  56. mcp_ticketer/core/instructions.py +405 -0
  57. mcp_ticketer/core/label_manager.py +732 -0
  58. mcp_ticketer/core/mappers.py +42 -30
  59. mcp_ticketer/core/models.py +280 -28
  60. mcp_ticketer/core/onepassword_secrets.py +379 -0
  61. mcp_ticketer/core/project_config.py +183 -49
  62. mcp_ticketer/core/registry.py +3 -3
  63. mcp_ticketer/core/session_state.py +171 -0
  64. mcp_ticketer/core/state_matcher.py +592 -0
  65. mcp_ticketer/core/url_parser.py +425 -0
  66. mcp_ticketer/core/validators.py +69 -0
  67. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  68. mcp_ticketer/mcp/__init__.py +29 -1
  69. mcp_ticketer/mcp/__main__.py +60 -0
  70. mcp_ticketer/mcp/server/__init__.py +25 -0
  71. mcp_ticketer/mcp/server/__main__.py +60 -0
  72. mcp_ticketer/mcp/server/constants.py +58 -0
  73. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  74. mcp_ticketer/mcp/server/dto.py +195 -0
  75. mcp_ticketer/mcp/server/main.py +1343 -0
  76. mcp_ticketer/mcp/server/response_builder.py +206 -0
  77. mcp_ticketer/mcp/server/routing.py +655 -0
  78. mcp_ticketer/mcp/server/server_sdk.py +151 -0
  79. mcp_ticketer/mcp/server/tools/__init__.py +56 -0
  80. mcp_ticketer/mcp/server/tools/analysis_tools.py +495 -0
  81. mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
  82. mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
  83. mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
  84. mcp_ticketer/mcp/server/tools/config_tools.py +1439 -0
  85. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  86. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +921 -0
  87. mcp_ticketer/mcp/server/tools/instruction_tools.py +300 -0
  88. mcp_ticketer/mcp/server/tools/label_tools.py +948 -0
  89. mcp_ticketer/mcp/server/tools/pr_tools.py +152 -0
  90. mcp_ticketer/mcp/server/tools/search_tools.py +215 -0
  91. mcp_ticketer/mcp/server/tools/session_tools.py +170 -0
  92. mcp_ticketer/mcp/server/tools/ticket_tools.py +1268 -0
  93. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +547 -0
  94. mcp_ticketer/queue/__init__.py +1 -0
  95. mcp_ticketer/queue/health_monitor.py +168 -136
  96. mcp_ticketer/queue/manager.py +95 -25
  97. mcp_ticketer/queue/queue.py +40 -21
  98. mcp_ticketer/queue/run_worker.py +6 -1
  99. mcp_ticketer/queue/ticket_registry.py +213 -155
  100. mcp_ticketer/queue/worker.py +109 -49
  101. mcp_ticketer-1.2.11.dist-info/METADATA +792 -0
  102. mcp_ticketer-1.2.11.dist-info/RECORD +110 -0
  103. mcp_ticketer/mcp/server.py +0 -1895
  104. mcp_ticketer-0.1.30.dist-info/METADATA +0 -413
  105. mcp_ticketer-0.1.30.dist-info/RECORD +0 -49
  106. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/WHEEL +0 -0
  107. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/entry_points.txt +0 -0
  108. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/licenses/LICENSE +0 -0
  109. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/top_level.txt +0 -0
@@ -3,7 +3,7 @@
3
3
  import logging
4
4
  from abc import ABC, abstractmethod
5
5
  from functools import lru_cache
6
- from typing import Any, Generic, Optional, TypeVar
6
+ from typing import Any, Generic, TypeVar
7
7
 
8
8
  from .models import Priority, TicketState
9
9
 
@@ -27,11 +27,11 @@ class BiDirectionalDict(Generic[T, U]):
27
27
  self._reverse: dict[U, T] = {v: k for k, v in mapping.items()}
28
28
  self._cache: dict[str, Any] = {}
29
29
 
30
- def get_forward(self, key: T, default: Optional[U] = None) -> Optional[U]:
30
+ def get_forward(self, key: T, default: U | None = None) -> U | None:
31
31
  """Get value by forward key."""
32
32
  return self._forward.get(key, default)
33
33
 
34
- def get_reverse(self, key: U, default: Optional[T] = None) -> Optional[T]:
34
+ def get_reverse(self, key: U, default: T | None = None) -> T | None:
35
35
  """Get value by reverse key."""
36
36
  return self._reverse.get(key, default)
37
37
 
@@ -83,7 +83,7 @@ class StateMapper(BaseMapper):
83
83
  """Universal state mapping utility."""
84
84
 
85
85
  def __init__(
86
- self, adapter_type: str, custom_mappings: Optional[dict[str, Any]] = None
86
+ self, adapter_type: str, custom_mappings: dict[str, Any] | None = None
87
87
  ):
88
88
  """Initialize state mapper.
89
89
 
@@ -95,16 +95,16 @@ class StateMapper(BaseMapper):
95
95
  super().__init__()
96
96
  self.adapter_type = adapter_type
97
97
  self.custom_mappings = custom_mappings or {}
98
- self._mapping: Optional[BiDirectionalDict] = None
98
+ self._mapping: BiDirectionalDict | None = None
99
99
 
100
100
  @lru_cache(maxsize=1)
101
- def get_mapping(self) -> BiDirectionalDict:
101
+ def get_mapping(self) -> BiDirectionalDict[TicketState, str]:
102
102
  """Get cached bidirectional state mapping."""
103
103
  if self._mapping is not None:
104
104
  return self._mapping
105
105
 
106
106
  # Default mappings by adapter type
107
- default_mappings = {
107
+ default_mappings: dict[str, dict[TicketState, str]] = {
108
108
  "github": {
109
109
  TicketState.OPEN: "open",
110
110
  TicketState.IN_PROGRESS: "open", # Uses labels
@@ -147,13 +147,16 @@ class StateMapper(BaseMapper):
147
147
  },
148
148
  }
149
149
 
150
- mapping = default_mappings.get(self.adapter_type, {})
150
+ mapping: dict[TicketState, str] = default_mappings.get(self.adapter_type, {})
151
151
 
152
- # Apply custom mappings
152
+ # Apply custom mappings (cast to proper type)
153
153
  if self.custom_mappings:
154
- mapping.update(self.custom_mappings)
154
+ # custom_mappings might have str keys, need to convert to TicketState
155
+ for key, value in self.custom_mappings.items():
156
+ if isinstance(key, TicketState):
157
+ mapping[key] = value
155
158
 
156
- self._mapping = BiDirectionalDict(mapping)
159
+ self._mapping = BiDirectionalDict[TicketState, str](mapping)
157
160
  return self._mapping
158
161
 
159
162
  def to_system_state(self, adapter_state: str) -> TicketState:
@@ -168,7 +171,9 @@ class StateMapper(BaseMapper):
168
171
  """
169
172
  cache_key = f"to_system_{adapter_state}"
170
173
  if cache_key in self._cache:
171
- return self._cache[cache_key]
174
+ cached = self._cache[cache_key]
175
+ if isinstance(cached, TicketState):
176
+ return cached
172
177
 
173
178
  mapping = self.get_mapping()
174
179
  result = mapping.get_reverse(adapter_state)
@@ -205,7 +210,9 @@ class StateMapper(BaseMapper):
205
210
  """
206
211
  cache_key = f"from_system_{system_state.value}"
207
212
  if cache_key in self._cache:
208
- return self._cache[cache_key]
213
+ cached = self._cache[cache_key]
214
+ if isinstance(cached, str):
215
+ return cached
209
216
 
210
217
  mapping = self.get_mapping()
211
218
  result = mapping.get_forward(system_state)
@@ -229,7 +236,7 @@ class StateMapper(BaseMapper):
229
236
  """Check if adapter uses labels for extended states."""
230
237
  return self.adapter_type in ["github", "linear"]
231
238
 
232
- def get_state_label(self, state: TicketState) -> Optional[str]:
239
+ def get_state_label(self, state: TicketState) -> str | None:
233
240
  """Get label name for extended states that require labels.
234
241
 
235
242
  Args:
@@ -258,7 +265,7 @@ class PriorityMapper(BaseMapper):
258
265
  """Universal priority mapping utility."""
259
266
 
260
267
  def __init__(
261
- self, adapter_type: str, custom_mappings: Optional[dict[str, Any]] = None
268
+ self, adapter_type: str, custom_mappings: dict[str, Any] | None = None
262
269
  ):
263
270
  """Initialize priority mapper.
264
271
 
@@ -270,16 +277,16 @@ class PriorityMapper(BaseMapper):
270
277
  super().__init__()
271
278
  self.adapter_type = adapter_type
272
279
  self.custom_mappings = custom_mappings or {}
273
- self._mapping: Optional[BiDirectionalDict] = None
280
+ self._mapping: BiDirectionalDict | None = None
274
281
 
275
282
  @lru_cache(maxsize=1)
276
- def get_mapping(self) -> BiDirectionalDict:
283
+ def get_mapping(self) -> BiDirectionalDict[Priority, Any]:
277
284
  """Get cached bidirectional priority mapping."""
278
285
  if self._mapping is not None:
279
286
  return self._mapping
280
287
 
281
288
  # Default mappings by adapter type
282
- default_mappings = {
289
+ default_mappings: dict[str, dict[Priority, Any]] = {
283
290
  "github": {
284
291
  Priority.CRITICAL: "P0",
285
292
  Priority.HIGH: "P1",
@@ -306,13 +313,16 @@ class PriorityMapper(BaseMapper):
306
313
  },
307
314
  }
308
315
 
309
- mapping = default_mappings.get(self.adapter_type, {})
316
+ mapping: dict[Priority, Any] = default_mappings.get(self.adapter_type, {})
310
317
 
311
- # Apply custom mappings
318
+ # Apply custom mappings (cast to proper type)
312
319
  if self.custom_mappings:
313
- mapping.update(self.custom_mappings)
320
+ # custom_mappings might have str keys, need to convert to Priority
321
+ for key, value in self.custom_mappings.items():
322
+ if isinstance(key, Priority):
323
+ mapping[key] = value
314
324
 
315
- self._mapping = BiDirectionalDict(mapping)
325
+ self._mapping = BiDirectionalDict[Priority, Any](mapping)
316
326
  return self._mapping
317
327
 
318
328
  def to_system_priority(self, adapter_priority: Any) -> Priority:
@@ -327,7 +337,9 @@ class PriorityMapper(BaseMapper):
327
337
  """
328
338
  cache_key = f"to_system_{adapter_priority}"
329
339
  if cache_key in self._cache:
330
- return self._cache[cache_key]
340
+ cached = self._cache[cache_key]
341
+ if isinstance(cached, Priority):
342
+ return cached
331
343
 
332
344
  mapping = self.get_mapping()
333
345
  result = mapping.get_reverse(adapter_priority)
@@ -365,7 +377,7 @@ class PriorityMapper(BaseMapper):
365
377
  ]:
366
378
  result = Priority.LOW
367
379
  break
368
- elif isinstance(adapter_priority, (int, float)):
380
+ elif isinstance(adapter_priority, int | float):
369
381
  # Handle numeric priorities (Linear-style)
370
382
  if adapter_priority <= 1:
371
383
  result = Priority.CRITICAL
@@ -483,7 +495,7 @@ class MapperRegistry:
483
495
 
484
496
  @classmethod
485
497
  def get_state_mapper(
486
- cls, adapter_type: str, custom_mappings: Optional[dict[str, Any]] = None
498
+ cls, adapter_type: str, custom_mappings: dict[str, Any] | None = None
487
499
  ) -> StateMapper:
488
500
  """Get or create state mapper for adapter type.
489
501
 
@@ -502,7 +514,7 @@ class MapperRegistry:
502
514
 
503
515
  @classmethod
504
516
  def get_priority_mapper(
505
- cls, adapter_type: str, custom_mappings: Optional[dict[str, Any]] = None
517
+ cls, adapter_type: str, custom_mappings: dict[str, Any] | None = None
506
518
  ) -> PriorityMapper:
507
519
  """Get or create priority mapper for adapter type.
508
520
 
@@ -524,10 +536,10 @@ class MapperRegistry:
524
536
  @classmethod
525
537
  def clear_cache(cls) -> None:
526
538
  """Clear all mapper caches."""
527
- for mapper in cls._state_mappers.values():
528
- mapper.clear_cache()
529
- for mapper in cls._priority_mappers.values():
530
- mapper.clear_cache()
539
+ for state_mapper in cls._state_mappers.values():
540
+ state_mapper.clear_cache()
541
+ for priority_mapper in cls._priority_mappers.values():
542
+ priority_mapper.clear_cache()
531
543
 
532
544
  @classmethod
533
545
  def reset(cls) -> None:
@@ -1,14 +1,50 @@
1
- """Simplified Universal Ticket models using Pydantic."""
1
+ """Universal Ticket models using Pydantic.
2
+
3
+ This module defines the core data models for the MCP Ticketer system, providing
4
+ a unified interface across different ticket management platforms (Linear, JIRA,
5
+ GitHub, etc.).
6
+
7
+ The models follow a hierarchical structure:
8
+ - Epic: Strategic level containers (Projects in Linear, Epics in JIRA)
9
+ - Issue: Standard work items (Issues in GitHub, Stories in JIRA)
10
+ - Task: Sub-work items (Sub-issues in Linear, Sub-tasks in JIRA)
11
+
12
+ All models use Pydantic v2 for validation and serialization, ensuring type safety
13
+ and consistent data handling across adapters.
14
+
15
+ Example:
16
+ >>> from mcp_ticketer.core.models import Task, Priority, TicketState
17
+ >>> task = Task(
18
+ ... title="Fix authentication bug",
19
+ ... priority=Priority.HIGH,
20
+ ... state=TicketState.IN_PROGRESS
21
+ ... )
22
+ >>> print(task.model_dump_json())
23
+
24
+ """
2
25
 
3
26
  from datetime import datetime
4
27
  from enum import Enum
5
- from typing import Any, Optional
28
+ from typing import Any
6
29
 
7
30
  from pydantic import BaseModel, ConfigDict, Field
8
31
 
9
32
 
10
33
  class Priority(str, Enum):
11
- """Universal priority levels."""
34
+ """Universal priority levels for tickets.
35
+
36
+ These priority levels are mapped to platform-specific priorities:
37
+ - Linear: 1 (Critical), 2 (High), 3 (Medium), 4 (Low)
38
+ - JIRA: Highest, High, Medium, Low
39
+ - GitHub: P0/critical, P1/high, P2/medium, P3/low labels
40
+
41
+ Attributes:
42
+ LOW: Low priority, non-urgent work
43
+ MEDIUM: Standard priority, default for most work
44
+ HIGH: High priority, should be addressed soon
45
+ CRITICAL: Critical priority, urgent work requiring immediate attention
46
+
47
+ """
12
48
 
13
49
  LOW = "low"
14
50
  MEDIUM = "medium"
@@ -17,7 +53,23 @@ class Priority(str, Enum):
17
53
 
18
54
 
19
55
  class TicketType(str, Enum):
20
- """Ticket type hierarchy."""
56
+ """Ticket type hierarchy for organizing work.
57
+
58
+ Defines the three-level hierarchy used across all platforms:
59
+
60
+ Platform Mappings:
61
+ - Linear: Project (Epic) → Issue (Issue) → Sub-issue (Task)
62
+ - JIRA: Epic (Epic) → Story/Task (Issue) → Sub-task (Task)
63
+ - GitHub: Milestone (Epic) → Issue (Issue) → Checklist item (Task)
64
+ - Aitrackdown: Epic file → Issue file → Task reference
65
+
66
+ Attributes:
67
+ EPIC: Strategic level containers for large features or initiatives
68
+ ISSUE: Standard work items, the primary unit of work
69
+ TASK: Sub-work items, smaller pieces of an issue
70
+ SUBTASK: Alias for TASK for backward compatibility
71
+
72
+ """
21
73
 
22
74
  EPIC = "epic" # Strategic level (Projects in Linear, Milestones in GitHub)
23
75
  ISSUE = "issue" # Work item level (standard issues/tasks)
@@ -26,7 +78,34 @@ class TicketType(str, Enum):
26
78
 
27
79
 
28
80
  class TicketState(str, Enum):
29
- """Universal ticket states with state machine abstraction."""
81
+ """Universal ticket states with workflow state machine.
82
+
83
+ Implements a standardized workflow that maps to different platform states:
84
+
85
+ State Flow:
86
+ OPEN → IN_PROGRESS → READY → TESTED → DONE → CLOSED
87
+ ↓ ↓ ↓
88
+ CLOSED WAITING BLOCKED
89
+ ↓ ↓
90
+ IN_PROGRESS ← IN_PROGRESS
91
+
92
+ Platform Mappings:
93
+ - Linear: Backlog (OPEN), Started (IN_PROGRESS), Completed (DONE), Canceled (CLOSED)
94
+ - JIRA: To Do (OPEN), In Progress (IN_PROGRESS), Done (DONE), etc.
95
+ - GitHub: open (OPEN), closed (CLOSED) + labels for extended states
96
+ - Aitrackdown: File-based state tracking
97
+
98
+ Attributes:
99
+ OPEN: Initial state, work not yet started
100
+ IN_PROGRESS: Work is actively being done
101
+ READY: Work is complete and ready for review/testing
102
+ TESTED: Work has been tested and verified
103
+ DONE: Work is complete and accepted
104
+ WAITING: Work is paused waiting for external dependency
105
+ BLOCKED: Work is blocked by an impediment
106
+ CLOSED: Final state, work is closed/archived
107
+
108
+ """
30
109
 
31
110
  OPEN = "open"
32
111
  IN_PROGRESS = "in_progress"
@@ -39,7 +118,15 @@ class TicketState(str, Enum):
39
118
 
40
119
  @classmethod
41
120
  def valid_transitions(cls) -> dict[str, list[str]]:
42
- """Define valid state transitions."""
121
+ """Define valid state transitions for workflow enforcement.
122
+
123
+ Returns:
124
+ Dictionary mapping each state to list of valid target states
125
+
126
+ Note:
127
+ CLOSED is a terminal state with no valid transitions
128
+
129
+ """
43
130
  return {
44
131
  cls.OPEN: [cls.IN_PROGRESS, cls.WAITING, cls.BLOCKED, cls.CLOSED],
45
132
  cls.IN_PROGRESS: [cls.READY, cls.WAITING, cls.BLOCKED, cls.OPEN],
@@ -52,23 +139,100 @@ class TicketState(str, Enum):
52
139
  }
53
140
 
54
141
  def can_transition_to(self, target: "TicketState") -> bool:
55
- """Check if transition to target state is valid."""
142
+ """Check if transition to target state is valid.
143
+
144
+ Validates state transitions according to the defined workflow rules.
145
+ This prevents invalid state changes and ensures workflow integrity.
146
+
147
+ Args:
148
+ target: The state to transition to
149
+
150
+ Returns:
151
+ True if the transition is valid, False otherwise
152
+
153
+ Example:
154
+ >>> state = TicketState.OPEN
155
+ >>> state.can_transition_to(TicketState.IN_PROGRESS)
156
+ True
157
+ >>> state.can_transition_to(TicketState.DONE)
158
+ False
159
+
160
+ """
56
161
  return target.value in self.valid_transitions().get(self, [])
57
162
 
163
+ def completion_level(self) -> int:
164
+ """Get numeric completion level for state ordering.
165
+
166
+ Higher numbers indicate more complete states. Used for parent/child
167
+ state constraints where parents must be at least as complete as
168
+ their most complete child.
169
+
170
+ Returns:
171
+ Completion level (0-7)
172
+
173
+ Example:
174
+ >>> TicketState.OPEN.completion_level()
175
+ 0
176
+ >>> TicketState.DONE.completion_level()
177
+ 6
178
+ >>> TicketState.DONE.completion_level() > TicketState.IN_PROGRESS.completion_level()
179
+ True
180
+
181
+ """
182
+ levels = {
183
+ TicketState.OPEN: 0, # Not started
184
+ TicketState.BLOCKED: 1, # Blocked
185
+ TicketState.WAITING: 2, # Waiting
186
+ TicketState.IN_PROGRESS: 3, # In progress
187
+ TicketState.READY: 4, # Ready for review
188
+ TicketState.TESTED: 5, # Tested
189
+ TicketState.DONE: 6, # Done
190
+ TicketState.CLOSED: 7, # Closed (terminal)
191
+ }
192
+ return levels.get(self, 0)
193
+
58
194
 
59
195
  class BaseTicket(BaseModel):
60
- """Base model for all ticket types."""
196
+ """Base model for all ticket types with universal field mapping.
197
+
198
+ Provides common fields and functionality shared across all ticket types
199
+ (Epic, Task, Comment). Uses Pydantic v2 for validation and serialization.
200
+
201
+ The metadata field allows adapters to store platform-specific information
202
+ while maintaining the universal interface.
203
+
204
+ Attributes:
205
+ id: Unique identifier assigned by the platform
206
+ title: Human-readable title (required, min 1 character)
207
+ description: Optional detailed description or body text
208
+ state: Current workflow state (defaults to OPEN)
209
+ priority: Priority level (defaults to MEDIUM)
210
+ tags: List of tags/labels for categorization
211
+ created_at: Timestamp when ticket was created
212
+ updated_at: Timestamp when ticket was last modified
213
+ metadata: Platform-specific data and field mappings
214
+
215
+ Example:
216
+ >>> ticket = BaseTicket(
217
+ ... title="Fix login issue",
218
+ ... description="Users cannot log in with SSO",
219
+ ... priority=Priority.HIGH,
220
+ ... tags=["bug", "authentication"]
221
+ ... )
222
+ >>> ticket.state = TicketState.IN_PROGRESS
223
+
224
+ """
61
225
 
62
226
  model_config = ConfigDict(use_enum_values=True)
63
227
 
64
- id: Optional[str] = Field(None, description="Unique identifier")
228
+ id: str | None = Field(None, description="Unique identifier")
65
229
  title: str = Field(..., min_length=1, description="Ticket title")
66
- description: Optional[str] = Field(None, description="Detailed description")
230
+ description: str | None = Field(None, description="Detailed description")
67
231
  state: TicketState = Field(TicketState.OPEN, description="Current state")
68
232
  priority: Priority = Field(Priority.MEDIUM, description="Priority level")
69
233
  tags: list[str] = Field(default_factory=list, description="Tags/labels")
70
- created_at: Optional[datetime] = Field(None, description="Creation timestamp")
71
- updated_at: Optional[datetime] = Field(None, description="Last update timestamp")
234
+ created_at: datetime | None = Field(None, description="Creation timestamp")
235
+ updated_at: datetime | None = Field(None, description="Last update timestamp")
72
236
 
73
237
  # Metadata for field mapping to different systems
74
238
  metadata: dict[str, Any] = Field(
@@ -77,7 +241,33 @@ class BaseTicket(BaseModel):
77
241
 
78
242
 
79
243
  class Epic(BaseTicket):
80
- """Epic - highest level container for work (Projects in Linear, Milestones in GitHub)."""
244
+ """Epic - highest level container for strategic work initiatives.
245
+
246
+ Epics represent large features, projects, or initiatives that contain
247
+ multiple related issues. They map to different concepts across platforms:
248
+
249
+ Platform Mappings:
250
+ - Linear: Projects (with issues as children)
251
+ - JIRA: Epics (with stories/tasks as children)
252
+ - GitHub: Milestones (with issues as children)
253
+ - Aitrackdown: Epic files (with issue references)
254
+
255
+ Epics sit at the top of the hierarchy and cannot have parent epics.
256
+ They can contain multiple child issues, which in turn can contain tasks.
257
+
258
+ Attributes:
259
+ ticket_type: Always TicketType.EPIC (frozen field)
260
+ child_issues: List of issue IDs that belong to this epic
261
+
262
+ Example:
263
+ >>> epic = Epic(
264
+ ... title="User Authentication System",
265
+ ... description="Complete overhaul of authentication",
266
+ ... priority=Priority.HIGH
267
+ ... )
268
+ >>> epic.child_issues = ["ISSUE-123", "ISSUE-124"]
269
+
270
+ """
81
271
 
82
272
  ticket_type: TicketType = Field(
83
273
  default=TicketType.EPIC, frozen=True, description="Always EPIC type"
@@ -89,8 +279,11 @@ class Epic(BaseTicket):
89
279
  def validate_hierarchy(self) -> list[str]:
90
280
  """Validate epic hierarchy rules.
91
281
 
282
+ Epics are at the top of the hierarchy and have no parent constraints.
283
+ This method is provided for consistency with other ticket types.
284
+
92
285
  Returns:
93
- List of validation errors (empty if valid)
286
+ Empty list (epics have no hierarchy constraints)
94
287
 
95
288
  """
96
289
  # Epics don't have parents in our hierarchy
@@ -98,19 +291,47 @@ class Epic(BaseTicket):
98
291
 
99
292
 
100
293
  class Task(BaseTicket):
101
- """Task - individual work item (can be ISSUE or TASK type)."""
294
+ """Task - individual work item (can be ISSUE or TASK type).
295
+
296
+ Note: The `project` field is a synonym for `parent_epic` to provide
297
+ flexibility in CLI and API usage. Both fields map to the same underlying
298
+ value (the parent epic/project ID).
299
+ """
102
300
 
103
301
  ticket_type: TicketType = Field(
104
302
  default=TicketType.ISSUE, description="Ticket type in hierarchy"
105
303
  )
106
- parent_issue: Optional[str] = Field(None, description="Parent issue ID (for tasks)")
107
- parent_epic: Optional[str] = Field(None, description="Parent epic ID (for issues)")
108
- assignee: Optional[str] = Field(None, description="Assigned user")
304
+ parent_issue: str | None = Field(None, description="Parent issue ID (for tasks)")
305
+ parent_epic: str | None = Field(
306
+ None,
307
+ description="Parent epic/project ID (for issues). Synonym: 'project'",
308
+ )
309
+ assignee: str | None = Field(None, description="Assigned user")
109
310
  children: list[str] = Field(default_factory=list, description="Child task IDs")
110
311
 
111
312
  # Additional fields common across systems
112
- estimated_hours: Optional[float] = Field(None, description="Time estimate")
113
- actual_hours: Optional[float] = Field(None, description="Actual time spent")
313
+ estimated_hours: float | None = Field(None, description="Time estimate")
314
+ actual_hours: float | None = Field(None, description="Actual time spent")
315
+
316
+ @property
317
+ def project(self) -> str | None:
318
+ """Synonym for parent_epic.
319
+
320
+ Returns:
321
+ Parent epic/project ID
322
+
323
+ """
324
+ return self.parent_epic
325
+
326
+ @project.setter
327
+ def project(self, value: str | None) -> None:
328
+ """Set parent_epic via project synonym.
329
+
330
+ Args:
331
+ value: Parent epic/project ID
332
+
333
+ """
334
+ self.parent_epic = value
114
335
 
115
336
  def is_epic(self) -> bool:
116
337
  """Check if this is an epic (should use Epic class instead)."""
@@ -155,23 +376,54 @@ class Comment(BaseModel):
155
376
 
156
377
  model_config = ConfigDict(use_enum_values=True)
157
378
 
158
- id: Optional[str] = Field(None, description="Comment ID")
379
+ id: str | None = Field(None, description="Comment ID")
159
380
  ticket_id: str = Field(..., description="Parent ticket ID")
160
- author: Optional[str] = Field(None, description="Comment author")
381
+ author: str | None = Field(None, description="Comment author")
161
382
  content: str = Field(..., min_length=1, description="Comment text")
162
- created_at: Optional[datetime] = Field(None, description="Creation timestamp")
383
+ created_at: datetime | None = Field(None, description="Creation timestamp")
163
384
  metadata: dict[str, Any] = Field(
164
385
  default_factory=dict, description="System-specific metadata"
165
386
  )
166
387
 
167
388
 
389
+ class Attachment(BaseModel):
390
+ """File attachment metadata for tickets.
391
+
392
+ Represents a file attached to a ticket across all adapters.
393
+ Each adapter maps its native attachment format to this model.
394
+ """
395
+
396
+ model_config = ConfigDict(use_enum_values=True)
397
+
398
+ id: str | None = Field(None, description="Attachment unique identifier")
399
+ ticket_id: str = Field(..., description="Parent ticket identifier")
400
+ filename: str = Field(..., description="Original filename")
401
+ url: str | None = Field(None, description="Download URL or file path")
402
+ content_type: str | None = Field(
403
+ None, description="MIME type (e.g., 'application/pdf', 'image/png')"
404
+ )
405
+ size_bytes: int | None = Field(None, description="File size in bytes")
406
+ created_at: datetime | None = Field(None, description="Upload timestamp")
407
+ created_by: str | None = Field(None, description="User who uploaded the attachment")
408
+ description: str | None = Field(None, description="Attachment description or notes")
409
+ metadata: dict[str, Any] = Field(
410
+ default_factory=dict, description="Adapter-specific attachment metadata"
411
+ )
412
+
413
+ def __str__(self) -> str:
414
+ """Return string representation showing filename and size."""
415
+ size_str = f" ({self.size_bytes} bytes)" if self.size_bytes else ""
416
+ return f"Attachment({self.filename}{size_str})"
417
+
418
+
168
419
  class SearchQuery(BaseModel):
169
420
  """Search query parameters."""
170
421
 
171
- query: Optional[str] = Field(None, description="Text search query")
172
- state: Optional[TicketState] = Field(None, description="Filter by state")
173
- priority: Optional[Priority] = Field(None, description="Filter by priority")
174
- tags: Optional[list[str]] = Field(None, description="Filter by tags")
175
- assignee: Optional[str] = Field(None, description="Filter by assignee")
422
+ query: str | None = Field(None, description="Text search query")
423
+ state: TicketState | None = Field(None, description="Filter by state")
424
+ priority: Priority | None = Field(None, description="Filter by priority")
425
+ tags: list[str] | None = Field(None, description="Filter by tags")
426
+ assignee: str | None = Field(None, description="Filter by assignee")
427
+ project: str | None = Field(None, description="Filter by project/epic ID or name")
176
428
  limit: int = Field(10, gt=0, le=100, description="Maximum results")
177
429
  offset: int = Field(0, ge=0, description="Result offset for pagination")