mcp-ticketer 0.4.11__py3-none-any.whl → 2.0.1__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 (111) 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 +394 -9
  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 +836 -105
  11. mcp_ticketer/adapters/hybrid.py +47 -5
  12. mcp_ticketer/adapters/jira.py +772 -1
  13. mcp_ticketer/adapters/linear/adapter.py +2293 -108
  14. mcp_ticketer/adapters/linear/client.py +146 -12
  15. mcp_ticketer/adapters/linear/mappers.py +105 -11
  16. mcp_ticketer/adapters/linear/queries.py +168 -1
  17. mcp_ticketer/adapters/linear/types.py +80 -4
  18. mcp_ticketer/analysis/__init__.py +56 -0
  19. mcp_ticketer/analysis/dependency_graph.py +255 -0
  20. mcp_ticketer/analysis/health_assessment.py +304 -0
  21. mcp_ticketer/analysis/orphaned.py +218 -0
  22. mcp_ticketer/analysis/project_status.py +594 -0
  23. mcp_ticketer/analysis/similarity.py +224 -0
  24. mcp_ticketer/analysis/staleness.py +266 -0
  25. mcp_ticketer/automation/__init__.py +11 -0
  26. mcp_ticketer/automation/project_updates.py +378 -0
  27. mcp_ticketer/cache/memory.py +3 -3
  28. mcp_ticketer/cli/adapter_diagnostics.py +4 -2
  29. mcp_ticketer/cli/auggie_configure.py +18 -6
  30. mcp_ticketer/cli/codex_configure.py +175 -60
  31. mcp_ticketer/cli/configure.py +884 -146
  32. mcp_ticketer/cli/cursor_configure.py +314 -0
  33. mcp_ticketer/cli/diagnostics.py +31 -28
  34. mcp_ticketer/cli/discover.py +293 -21
  35. mcp_ticketer/cli/gemini_configure.py +18 -6
  36. mcp_ticketer/cli/init_command.py +880 -0
  37. mcp_ticketer/cli/instruction_commands.py +435 -0
  38. mcp_ticketer/cli/linear_commands.py +99 -15
  39. mcp_ticketer/cli/main.py +109 -2055
  40. mcp_ticketer/cli/mcp_configure.py +673 -99
  41. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  42. mcp_ticketer/cli/migrate_config.py +12 -8
  43. mcp_ticketer/cli/platform_commands.py +6 -6
  44. mcp_ticketer/cli/platform_detection.py +477 -0
  45. mcp_ticketer/cli/platform_installer.py +536 -0
  46. mcp_ticketer/cli/project_update_commands.py +350 -0
  47. mcp_ticketer/cli/queue_commands.py +15 -15
  48. mcp_ticketer/cli/setup_command.py +639 -0
  49. mcp_ticketer/cli/simple_health.py +13 -11
  50. mcp_ticketer/cli/ticket_commands.py +277 -36
  51. mcp_ticketer/cli/update_checker.py +313 -0
  52. mcp_ticketer/cli/utils.py +45 -41
  53. mcp_ticketer/core/__init__.py +35 -1
  54. mcp_ticketer/core/adapter.py +170 -5
  55. mcp_ticketer/core/config.py +38 -31
  56. mcp_ticketer/core/env_discovery.py +33 -3
  57. mcp_ticketer/core/env_loader.py +7 -6
  58. mcp_ticketer/core/exceptions.py +10 -4
  59. mcp_ticketer/core/http_client.py +10 -10
  60. mcp_ticketer/core/instructions.py +405 -0
  61. mcp_ticketer/core/label_manager.py +732 -0
  62. mcp_ticketer/core/mappers.py +32 -20
  63. mcp_ticketer/core/models.py +136 -1
  64. mcp_ticketer/core/onepassword_secrets.py +379 -0
  65. mcp_ticketer/core/priority_matcher.py +463 -0
  66. mcp_ticketer/core/project_config.py +148 -14
  67. mcp_ticketer/core/registry.py +1 -1
  68. mcp_ticketer/core/session_state.py +171 -0
  69. mcp_ticketer/core/state_matcher.py +592 -0
  70. mcp_ticketer/core/url_parser.py +425 -0
  71. mcp_ticketer/core/validators.py +69 -0
  72. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  73. mcp_ticketer/mcp/__init__.py +2 -2
  74. mcp_ticketer/mcp/server/__init__.py +2 -2
  75. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  76. mcp_ticketer/mcp/server/main.py +187 -93
  77. mcp_ticketer/mcp/server/routing.py +655 -0
  78. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  79. mcp_ticketer/mcp/server/tools/__init__.py +37 -9
  80. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  81. mcp_ticketer/mcp/server/tools/attachment_tools.py +65 -20
  82. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  83. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  84. mcp_ticketer/mcp/server/tools/config_tools.py +1429 -0
  85. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  86. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +878 -319
  87. mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
  88. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  89. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  90. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  91. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  92. mcp_ticketer/mcp/server/tools/search_tools.py +180 -97
  93. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  94. mcp_ticketer/mcp/server/tools/ticket_tools.py +1182 -82
  95. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
  96. mcp_ticketer/queue/health_monitor.py +1 -0
  97. mcp_ticketer/queue/manager.py +4 -4
  98. mcp_ticketer/queue/queue.py +3 -3
  99. mcp_ticketer/queue/run_worker.py +1 -1
  100. mcp_ticketer/queue/ticket_registry.py +2 -2
  101. mcp_ticketer/queue/worker.py +15 -13
  102. mcp_ticketer/utils/__init__.py +5 -0
  103. mcp_ticketer/utils/token_utils.py +246 -0
  104. mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
  105. mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
  106. mcp_ticketer-0.4.11.dist-info/METADATA +0 -496
  107. mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
  108. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
  109. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
  110. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
  111. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/top_level.txt +0 -0
mcp_ticketer/__init__.py CHANGED
@@ -1,16 +1,16 @@
1
1
  """MCP Ticketer - Universal ticket management interface."""
2
2
 
3
3
  from .__version__ import (
4
- __author__,
5
- __author_email__,
6
- __copyright__,
7
- __description__,
8
- __license__,
9
- __title__,
10
- __version__,
11
- __version_info__,
12
- get_user_agent,
13
- get_version,
4
+ __author__,
5
+ __author_email__,
6
+ __copyright__,
7
+ __description__,
8
+ __license__,
9
+ __title__,
10
+ __version__,
11
+ __version_info__,
12
+ get_user_agent,
13
+ get_version,
14
14
  )
15
15
 
16
16
  __all__ = [
@@ -1,6 +1,6 @@
1
1
  """Version information for mcp-ticketer package."""
2
2
 
3
- __version__ = "0.4.11"
3
+ __version__ = "2.0.1"
4
4
  __version_info__ = tuple(int(part) for part in __version__.split("."))
5
5
 
6
6
  # Package metadata
@@ -27,7 +27,7 @@ __features__ = {
27
27
  }
28
28
 
29
29
 
30
- def get_version():
30
+ def get_version() -> str:
31
31
  """Return the full version string with build metadata if available."""
32
32
  version = __version__
33
33
  if __build__:
@@ -37,6 +37,6 @@ def get_version():
37
37
  return version
38
38
 
39
39
 
40
- def get_user_agent():
40
+ def get_user_agent() -> str:
41
41
  """Return a user agent string for API requests."""
42
42
  return f"{__title__}/{__version__}"
@@ -1,6 +1,7 @@
1
1
  """Adapter implementations for various ticket systems."""
2
2
 
3
3
  from .aitrackdown import AITrackdownAdapter
4
+ from .asana import AsanaAdapter
4
5
  from .github import GitHubAdapter
5
6
  from .hybrid import HybridAdapter
6
7
  from .jira import JiraAdapter
@@ -8,6 +9,7 @@ from .linear import LinearAdapter
8
9
 
9
10
  __all__ = [
10
11
  "AITrackdownAdapter",
12
+ "AsanaAdapter",
11
13
  "LinearAdapter",
12
14
  "JiraAdapter",
13
15
  "GitHubAdapter",
@@ -23,14 +23,14 @@ logger = logging.getLogger(__name__)
23
23
 
24
24
  # Import ai-trackdown-pytools when available
25
25
  try:
26
- from ai_trackdown_pytools import AITrackdown
27
- from ai_trackdown_pytools import Ticket as AITicket
26
+ from ai_trackdown_pytools import AITrackdown # type: ignore[attr-defined]
27
+ from ai_trackdown_pytools import Ticket as AITicket # type: ignore[attr-defined]
28
28
 
29
29
  HAS_AITRACKDOWN = True
30
30
  except ImportError:
31
31
  HAS_AITRACKDOWN = False
32
- AITrackdown = None
33
- AITicket = None
32
+ AITrackdown = None # type: ignore[assignment]
33
+ AITicket = None # type: ignore[assignment]
34
34
 
35
35
 
36
36
  class AITrackdownAdapter(BaseAdapter[Task]):
@@ -40,6 +40,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
40
40
  """Initialize AI-Trackdown adapter.
41
41
 
42
42
  Args:
43
+ ----
43
44
  config: Configuration with 'base_path' for tickets directory
44
45
 
45
46
  """
@@ -64,6 +65,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
64
65
  AITrackdown is file-based and doesn't require credentials.
65
66
 
66
67
  Returns:
68
+ -------
67
69
  (is_valid, error_message) - Always returns (True, "") for AITrackdown
68
70
 
69
71
  """
@@ -128,6 +130,8 @@ class AITrackdownAdapter(BaseAdapter[Task]):
128
130
  parent_issue=ai_ticket.get("parent_issue"),
129
131
  parent_epic=ai_ticket.get("parent_epic"),
130
132
  assignee=ai_ticket.get("assignee"),
133
+ estimated_hours=ai_ticket.get("estimated_hours"),
134
+ actual_hours=ai_ticket.get("actual_hours"),
131
135
  created_at=(
132
136
  datetime.fromisoformat(ai_ticket["created_at"])
133
137
  if "created_at" in ai_ticket
@@ -180,13 +184,15 @@ class AITrackdownAdapter(BaseAdapter[Task]):
180
184
  """Convert universal Task to AI-Trackdown ticket."""
181
185
  # Handle enum values that may be stored as strings due to use_enum_values=True
182
186
  # Note: task.state is always a string due to ConfigDict(use_enum_values=True)
183
- state_value = task.state
187
+ state_value: str
184
188
  if isinstance(task.state, TicketState):
185
189
  state_value = self._get_state_mapping()[task.state]
186
190
  elif isinstance(task.state, str):
187
191
  # Already a string - keep as-is (don't convert to kebab-case)
188
192
  # The state is already in snake_case format from the enum value
189
193
  state_value = task.state
194
+ else:
195
+ state_value = str(task.state)
190
196
 
191
197
  return {
192
198
  "id": task.id,
@@ -208,13 +214,15 @@ class AITrackdownAdapter(BaseAdapter[Task]):
208
214
  """Convert universal Epic to AI-Trackdown ticket."""
209
215
  # Handle enum values that may be stored as strings due to use_enum_values=True
210
216
  # Note: epic.state is always a string due to ConfigDict(use_enum_values=True)
211
- state_value = epic.state
217
+ state_value: str
212
218
  if isinstance(epic.state, TicketState):
213
219
  state_value = self._get_state_mapping()[epic.state]
214
220
  elif isinstance(epic.state, str):
215
221
  # Already a string - keep as-is (don't convert to kebab-case)
216
222
  # The state is already in snake_case format from the enum value
217
223
  state_value = epic.state
224
+ else:
225
+ state_value = str(epic.state)
218
226
 
219
227
  return {
220
228
  "id": epic.id,
@@ -280,15 +288,19 @@ class AITrackdownAdapter(BaseAdapter[Task]):
280
288
 
281
289
  return ticket
282
290
 
283
- async def create_epic(self, title: str, description: str = None, **kwargs) -> Epic:
291
+ async def create_epic(
292
+ self, title: str, description: str = None, **kwargs: Any
293
+ ) -> Epic:
284
294
  """Create a new epic.
285
295
 
286
296
  Args:
297
+ ----
287
298
  title: Epic title
288
299
  description: Epic description
289
300
  **kwargs: Additional epic properties
290
301
 
291
302
  Returns:
303
+ -------
292
304
  Created Epic instance
293
305
 
294
306
  """
@@ -296,17 +308,23 @@ class AITrackdownAdapter(BaseAdapter[Task]):
296
308
  return await self.create(epic)
297
309
 
298
310
  async def create_issue(
299
- self, title: str, parent_epic: str = None, description: str = None, **kwargs
311
+ self,
312
+ title: str,
313
+ parent_epic: str = None,
314
+ description: str = None,
315
+ **kwargs: Any,
300
316
  ) -> Task:
301
317
  """Create a new issue.
302
318
 
303
319
  Args:
320
+ ----
304
321
  title: Issue title
305
322
  parent_epic: Parent epic ID
306
323
  description: Issue description
307
324
  **kwargs: Additional issue properties
308
325
 
309
326
  Returns:
327
+ -------
310
328
  Created Task instance (representing an issue)
311
329
 
312
330
  """
@@ -316,17 +334,19 @@ class AITrackdownAdapter(BaseAdapter[Task]):
316
334
  return await self.create(task)
317
335
 
318
336
  async def create_task(
319
- self, title: str, parent_id: str, description: str = None, **kwargs
337
+ self, title: str, parent_id: str, description: str = None, **kwargs: Any
320
338
  ) -> Task:
321
339
  """Create a new task under an issue.
322
340
 
323
341
  Args:
342
+ ----
324
343
  title: Task title
325
344
  parent_id: Parent issue ID
326
345
  description: Task description
327
346
  **kwargs: Additional task properties
328
347
 
329
348
  Returns:
349
+ -------
330
350
  Created Task instance
331
351
 
332
352
  """
@@ -356,13 +376,16 @@ class AITrackdownAdapter(BaseAdapter[Task]):
356
376
  """Update a task or epic.
357
377
 
358
378
  Args:
379
+ ----
359
380
  ticket_id: ID of ticket to update
360
381
  updates: Dictionary of updates or Task object with new values
361
382
 
362
383
  Returns:
384
+ -------
363
385
  Updated Task or Epic, or None if ticket not found
364
386
 
365
387
  Raises:
388
+ ------
366
389
  AttributeError: If update fails due to invalid fields
367
390
 
368
391
  """
@@ -549,9 +572,11 @@ class AITrackdownAdapter(BaseAdapter[Task]):
549
572
  """Get epic by ID.
550
573
 
551
574
  Args:
575
+ ----
552
576
  epic_id: Epic ID to retrieve
553
577
 
554
578
  Returns:
579
+ -------
555
580
  Epic if found, None otherwise
556
581
 
557
582
  """
@@ -574,10 +599,12 @@ class AITrackdownAdapter(BaseAdapter[Task]):
574
599
  """List all epics.
575
600
 
576
601
  Args:
602
+ ----
577
603
  limit: Maximum number of epics to return
578
604
  offset: Number of epics to skip
579
605
 
580
606
  Returns:
607
+ -------
581
608
  List of epics
582
609
 
583
610
  """
@@ -592,9 +619,11 @@ class AITrackdownAdapter(BaseAdapter[Task]):
592
619
  """List all issues belonging to an epic.
593
620
 
594
621
  Args:
622
+ ----
595
623
  epic_id: Epic ID to get issues for
596
624
 
597
625
  Returns:
626
+ -------
598
627
  List of issues (tasks with parent_epic set)
599
628
 
600
629
  """
@@ -609,9 +638,11 @@ class AITrackdownAdapter(BaseAdapter[Task]):
609
638
  """List all tasks belonging to an issue.
610
639
 
611
640
  Args:
641
+ ----
612
642
  issue_id: Issue ID (parent task) to get child tasks for
613
643
 
614
644
  Returns:
645
+ -------
615
646
  List of tasks
616
647
 
617
648
  """
@@ -627,9 +658,11 @@ class AITrackdownAdapter(BaseAdapter[Task]):
627
658
  """Sanitize filename to prevent security issues.
628
659
 
629
660
  Args:
661
+ ----
630
662
  filename: Original filename
631
663
 
632
664
  Returns:
665
+ -------
633
666
  Sanitized filename safe for filesystem
634
667
 
635
668
  """
@@ -649,9 +682,11 @@ class AITrackdownAdapter(BaseAdapter[Task]):
649
682
  """Guess MIME type from file extension.
650
683
 
651
684
  Args:
685
+ ----
652
686
  file_path: Path to file
653
687
 
654
688
  Returns:
689
+ -------
655
690
  MIME type string
656
691
 
657
692
  """
@@ -664,9 +699,11 @@ class AITrackdownAdapter(BaseAdapter[Task]):
664
699
  """Calculate SHA256 checksum of file.
665
700
 
666
701
  Args:
702
+ ----
667
703
  file_path: Path to file
668
704
 
669
705
  Returns:
706
+ -------
670
707
  Hexadecimal checksum string
671
708
 
672
709
  """
@@ -689,14 +726,17 @@ class AITrackdownAdapter(BaseAdapter[Task]):
689
726
  """Attach a file to a ticket (local filesystem storage).
690
727
 
691
728
  Args:
729
+ ----
692
730
  ticket_id: Ticket identifier
693
731
  file_path: Local file path to attach
694
732
  description: Optional attachment description
695
733
 
696
734
  Returns:
735
+ -------
697
736
  Attachment metadata
698
737
 
699
738
  Raises:
739
+ ------
700
740
  ValueError: If ticket doesn't exist
701
741
  FileNotFoundError: If file doesn't exist
702
742
 
@@ -761,9 +801,11 @@ class AITrackdownAdapter(BaseAdapter[Task]):
761
801
  """Get all attachments for a ticket with path traversal protection.
762
802
 
763
803
  Args:
804
+ ----
764
805
  ticket_id: Ticket identifier
765
806
 
766
807
  Returns:
808
+ -------
767
809
  List of attachments (empty if none)
768
810
 
769
811
  """
@@ -814,10 +856,12 @@ class AITrackdownAdapter(BaseAdapter[Task]):
814
856
  """Delete an attachment and its metadata with path traversal protection.
815
857
 
816
858
  Args:
859
+ ----
817
860
  ticket_id: Ticket identifier
818
861
  attachment_id: Attachment identifier
819
862
 
820
863
  Returns:
864
+ -------
821
865
  True if deleted, False if not found
822
866
 
823
867
  """
@@ -855,6 +899,347 @@ class AITrackdownAdapter(BaseAdapter[Task]):
855
899
 
856
900
  return deleted
857
901
 
902
+ async def update_epic(self, epic_id: str, updates: dict[str, Any]) -> Epic | None:
903
+ """Update an epic (project) in AITrackdown.
904
+
905
+ Args:
906
+ ----
907
+ epic_id: Epic identifier (filename without .json)
908
+ updates: Dictionary of fields to update. Supported fields:
909
+ - title: Epic title
910
+ - description: Epic description
911
+ - state: TicketState value
912
+ - priority: Priority value
913
+ - tags: List of tags
914
+ - target_date: Target completion date
915
+ - metadata: User metadata dictionary
916
+
917
+ Returns:
918
+ -------
919
+ Updated Epic object or None if epic not found
920
+
921
+ Raises:
922
+ ------
923
+ ValueError: If epic_id is invalid or epic not found
924
+
925
+ Note:
926
+ ----
927
+ AITrackdown stores epics as JSON files in {storage_path}/tickets/
928
+ Updates are applied as partial updates (only specified fields changed)
929
+
930
+ """
931
+ # Validate epic_id
932
+ if not epic_id:
933
+ raise ValueError("epic_id is required")
934
+
935
+ # Read existing epic
936
+ existing = await self.read(epic_id)
937
+ if not existing:
938
+ logger.warning("Epic %s not found for update", epic_id)
939
+ return None
940
+
941
+ # Ensure it's an epic, not a task
942
+ if not isinstance(existing, Epic):
943
+ logger.warning("Ticket %s is not an epic", epic_id)
944
+ return None
945
+
946
+ # Apply updates to the existing epic
947
+ for key, value in updates.items():
948
+ if hasattr(existing, key) and value is not None:
949
+ setattr(existing, key, value)
950
+
951
+ # Update timestamp
952
+ existing.updated_at = datetime.now()
953
+
954
+ # Write back to file
955
+ ai_ticket = self._epic_to_ai_ticket(existing)
956
+ self._write_ticket_file(epic_id, ai_ticket)
957
+
958
+ logger.info("Updated epic %s with fields: %s", epic_id, list(updates.keys()))
959
+ return existing
960
+
961
+ async def list_labels(self, limit: int = 100) -> builtins.list[dict[str, Any]]:
962
+ """List all tags (labels) used across tickets.
963
+
964
+ Args:
965
+ ----
966
+ limit: Maximum number of labels to return (default: 100)
967
+
968
+ Returns:
969
+ -------
970
+ List of label dictionaries sorted by usage count (descending).
971
+ Each dictionary contains:
972
+ - id: Tag name (same as name in AITrackdown)
973
+ - name: Tag name
974
+ - count: Number of tickets using this tag
975
+
976
+ Note:
977
+ ----
978
+ AITrackdown uses 'tags' terminology. This method scans
979
+ all task and epic files to extract unique tags.
980
+
981
+ """
982
+ # Initialize tag counter
983
+ tag_counts: dict[str, int] = {}
984
+
985
+ # Scan all ticket JSON files
986
+ if self.tickets_dir.exists():
987
+ for ticket_file in self.tickets_dir.glob("*.json"):
988
+ try:
989
+ with open(ticket_file) as f:
990
+ ticket_data = json.load(f)
991
+ tags = ticket_data.get("tags", [])
992
+ for tag in tags:
993
+ tag_counts[tag] = tag_counts.get(tag, 0) + 1
994
+ except (json.JSONDecodeError, OSError) as e:
995
+ logger.warning("Failed to read ticket file %s: %s", ticket_file, e)
996
+ continue
997
+
998
+ # Sort by usage count (descending)
999
+ sorted_tags = sorted(tag_counts.items(), key=lambda x: x[1], reverse=True)
1000
+
1001
+ # Return top N tags with standardized format
1002
+ return [
1003
+ {"id": tag, "name": tag, "count": count}
1004
+ for tag, count in sorted_tags[:limit]
1005
+ ]
1006
+
1007
+ async def create_issue_label(
1008
+ self, name: str, color: str | None = None
1009
+ ) -> dict[str, Any]:
1010
+ """Create/register a label (tag) in AITrackdown.
1011
+
1012
+ Args:
1013
+ ----
1014
+ name: Label name (alphanumeric, hyphens, underscores allowed)
1015
+ color: Optional color (not used in file-based storage)
1016
+
1017
+ Returns:
1018
+ -------
1019
+ Label dictionary with:
1020
+ - id: Label name
1021
+ - name: Label name
1022
+ - color: Color value (if provided)
1023
+ - created: True (always, as tags are created on use)
1024
+
1025
+ Raises:
1026
+ ------
1027
+ ValueError: If label name is invalid
1028
+
1029
+ Note:
1030
+ ----
1031
+ AITrackdown creates tags implicitly when used on tickets.
1032
+ This method validates the tag name and returns success.
1033
+ Tags are stored as arrays in ticket JSON files.
1034
+
1035
+ """
1036
+ # Validate tag name
1037
+ if not name:
1038
+ raise ValueError("Label name cannot be empty")
1039
+
1040
+ # Check for valid characters (alphanumeric, hyphens, underscores, spaces)
1041
+ import re
1042
+
1043
+ if not re.match(r"^[a-zA-Z0-9_\- ]+$", name):
1044
+ raise ValueError(
1045
+ "Label name must contain only alphanumeric characters, hyphens, underscores, or spaces"
1046
+ )
1047
+
1048
+ # Return success response
1049
+ logger.info("Label '%s' registered (created implicitly on use)", name)
1050
+ return {
1051
+ "id": name,
1052
+ "name": name,
1053
+ "color": color,
1054
+ "created": True,
1055
+ }
1056
+
1057
+ async def list_project_labels(
1058
+ self, epic_id: str, limit: int = 100
1059
+ ) -> builtins.list[dict[str, Any]]:
1060
+ """List labels (tags) used in a specific epic and its tasks.
1061
+
1062
+ Args:
1063
+ ----
1064
+ epic_id: Epic identifier
1065
+ limit: Maximum number of labels to return (default: 100)
1066
+
1067
+ Returns:
1068
+ -------
1069
+ List of label dictionaries used in the epic, sorted by usage count.
1070
+ Each dictionary contains:
1071
+ - id: Tag name
1072
+ - name: Tag name
1073
+ - count: Number of tickets using this tag within the epic
1074
+
1075
+ Raises:
1076
+ ------
1077
+ ValueError: If epic not found
1078
+
1079
+ Note:
1080
+ ----
1081
+ Scans the epic and all tasks with parent_epic == epic_id.
1082
+
1083
+ """
1084
+ # Validate epic exists
1085
+ epic = await self.get_epic(epic_id)
1086
+ if not epic:
1087
+ raise ValueError(f"Epic {epic_id} not found")
1088
+
1089
+ # Initialize tag counter
1090
+ tag_counts: dict[str, int] = {}
1091
+
1092
+ # Add tags from the epic itself
1093
+ if epic.tags:
1094
+ for tag in epic.tags:
1095
+ tag_counts[tag] = tag_counts.get(tag, 0) + 1
1096
+
1097
+ # Find all tasks with parent_epic == epic_id
1098
+ all_tasks = await self.list_issues_by_epic(epic_id)
1099
+ for task in all_tasks:
1100
+ if task.tags:
1101
+ for tag in task.tags:
1102
+ tag_counts[tag] = tag_counts.get(tag, 0) + 1
1103
+
1104
+ # Sort by usage count (descending)
1105
+ sorted_tags = sorted(tag_counts.items(), key=lambda x: x[1], reverse=True)
1106
+
1107
+ # Return top N tags
1108
+ return [
1109
+ {"id": tag, "name": tag, "count": count}
1110
+ for tag, count in sorted_tags[:limit]
1111
+ ]
1112
+
1113
+ async def list_cycles(self, limit: int = 50) -> builtins.list[dict[str, Any]]:
1114
+ """List cycles (sprints) - Not supported in file-based AITrackdown.
1115
+
1116
+ Args:
1117
+ ----
1118
+ limit: Maximum number of cycles to return (unused)
1119
+
1120
+ Returns:
1121
+ -------
1122
+ Empty list (cycles not supported)
1123
+
1124
+ Note:
1125
+ ----
1126
+ AITrackdown is a simple file-based system without
1127
+ cycle/sprint management. Returns empty list.
1128
+
1129
+ """
1130
+ logger.info("list_cycles called but cycles not supported in AITrackdown")
1131
+ return []
1132
+
1133
+ async def get_issue_status(self, ticket_id: str) -> dict[str, Any] | None:
1134
+ """Get status details for a ticket.
1135
+
1136
+ Args:
1137
+ ----
1138
+ ticket_id: Ticket identifier
1139
+
1140
+ Returns:
1141
+ -------
1142
+ Status dictionary with:
1143
+ - id: Ticket ID
1144
+ - state: Current state
1145
+ - priority: Current priority
1146
+ - updated_at: Last update timestamp
1147
+ - created_at: Creation timestamp
1148
+ - title: Ticket title
1149
+ - assignee: Assignee (if Task, None for Epic)
1150
+ Returns None if ticket not found
1151
+
1152
+ Raises:
1153
+ ------
1154
+ ValueError: If ticket_id is invalid
1155
+
1156
+ """
1157
+ if not ticket_id:
1158
+ raise ValueError("ticket_id is required")
1159
+
1160
+ # Read ticket
1161
+ ticket = await self.read(ticket_id)
1162
+ if not ticket:
1163
+ logger.warning("Ticket %s not found", ticket_id)
1164
+ return None
1165
+
1166
+ # Return comprehensive status object
1167
+ status = {
1168
+ "id": ticket.id,
1169
+ "state": ticket.state,
1170
+ "priority": ticket.priority,
1171
+ "updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
1172
+ "created_at": ticket.created_at.isoformat() if ticket.created_at else None,
1173
+ "title": ticket.title,
1174
+ }
1175
+
1176
+ # Add assignee only if ticket is a Task (Epic doesn't have assignee)
1177
+ if hasattr(ticket, "assignee"):
1178
+ status["assignee"] = ticket.assignee
1179
+
1180
+ return status
1181
+
1182
+ async def list_issue_statuses(self) -> builtins.list[dict[str, Any]]:
1183
+ """List available ticket statuses.
1184
+
1185
+ Returns:
1186
+ -------
1187
+ List of status dictionaries with:
1188
+ - id: State identifier
1189
+ - name: Human-readable state name
1190
+ - description: State description
1191
+
1192
+ Note:
1193
+ ----
1194
+ AITrackdown uses standard TicketState enum values:
1195
+ open, in_progress, ready, tested, done, closed, waiting, blocked
1196
+
1197
+ """
1198
+ # Return hardcoded list of TicketState values
1199
+ statuses = [
1200
+ {
1201
+ "id": "open",
1202
+ "name": "Open",
1203
+ "description": "Ticket is created and ready to be worked on",
1204
+ },
1205
+ {
1206
+ "id": "in_progress",
1207
+ "name": "In Progress",
1208
+ "description": "Ticket is actively being worked on",
1209
+ },
1210
+ {
1211
+ "id": "ready",
1212
+ "name": "Ready",
1213
+ "description": "Ticket is ready for review or testing",
1214
+ },
1215
+ {
1216
+ "id": "tested",
1217
+ "name": "Tested",
1218
+ "description": "Ticket has been tested and verified",
1219
+ },
1220
+ {
1221
+ "id": "done",
1222
+ "name": "Done",
1223
+ "description": "Ticket work is completed",
1224
+ },
1225
+ {
1226
+ "id": "closed",
1227
+ "name": "Closed",
1228
+ "description": "Ticket is closed and archived",
1229
+ },
1230
+ {
1231
+ "id": "waiting",
1232
+ "name": "Waiting",
1233
+ "description": "Ticket is waiting for external dependency",
1234
+ },
1235
+ {
1236
+ "id": "blocked",
1237
+ "name": "Blocked",
1238
+ "description": "Ticket is blocked by an issue or dependency",
1239
+ },
1240
+ ]
1241
+ return statuses
1242
+
858
1243
 
859
1244
  # Register the adapter
860
1245
  AdapterRegistry.register("aitrackdown", AITrackdownAdapter)