mcp-ticketer 0.3.1__py3-none-any.whl → 0.3.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 (41) hide show
  1. mcp_ticketer/__version__.py +1 -1
  2. mcp_ticketer/adapters/aitrackdown.py +164 -36
  3. mcp_ticketer/adapters/github.py +11 -8
  4. mcp_ticketer/adapters/jira.py +29 -28
  5. mcp_ticketer/adapters/linear/__init__.py +1 -1
  6. mcp_ticketer/adapters/linear/adapter.py +105 -104
  7. mcp_ticketer/adapters/linear/client.py +78 -59
  8. mcp_ticketer/adapters/linear/mappers.py +93 -73
  9. mcp_ticketer/adapters/linear/queries.py +28 -7
  10. mcp_ticketer/adapters/linear/types.py +67 -60
  11. mcp_ticketer/adapters/linear.py +2 -2
  12. mcp_ticketer/cli/adapter_diagnostics.py +87 -52
  13. mcp_ticketer/cli/codex_configure.py +6 -6
  14. mcp_ticketer/cli/diagnostics.py +180 -88
  15. mcp_ticketer/cli/linear_commands.py +156 -113
  16. mcp_ticketer/cli/main.py +153 -82
  17. mcp_ticketer/cli/simple_health.py +74 -51
  18. mcp_ticketer/cli/utils.py +15 -10
  19. mcp_ticketer/core/config.py +23 -19
  20. mcp_ticketer/core/env_discovery.py +5 -4
  21. mcp_ticketer/core/env_loader.py +114 -91
  22. mcp_ticketer/core/exceptions.py +22 -20
  23. mcp_ticketer/core/models.py +9 -0
  24. mcp_ticketer/core/project_config.py +1 -1
  25. mcp_ticketer/mcp/constants.py +58 -0
  26. mcp_ticketer/mcp/dto.py +195 -0
  27. mcp_ticketer/mcp/response_builder.py +206 -0
  28. mcp_ticketer/mcp/server.py +361 -1182
  29. mcp_ticketer/queue/health_monitor.py +166 -135
  30. mcp_ticketer/queue/manager.py +70 -19
  31. mcp_ticketer/queue/queue.py +24 -5
  32. mcp_ticketer/queue/run_worker.py +1 -1
  33. mcp_ticketer/queue/ticket_registry.py +203 -145
  34. mcp_ticketer/queue/worker.py +79 -43
  35. {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.3.dist-info}/METADATA +1 -1
  36. mcp_ticketer-0.3.3.dist-info/RECORD +62 -0
  37. mcp_ticketer-0.3.1.dist-info/RECORD +0 -59
  38. {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.3.dist-info}/WHEEL +0 -0
  39. {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.3.dist-info}/entry_points.txt +0 -0
  40. {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.3.dist-info}/licenses/LICENSE +0 -0
  41. {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.3.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  """Version information for mcp-ticketer package."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.3.3"
4
4
  __version_info__ = tuple(int(part) for part in __version__.split("."))
5
5
 
6
6
  # Package metadata
@@ -35,14 +35,17 @@ class AITrackdownAdapter(BaseAdapter[Task]):
35
35
  super().__init__(config)
36
36
  self.base_path = Path(config.get("base_path", ".aitrackdown"))
37
37
  self.tickets_dir = self.base_path / "tickets"
38
+ self._comment_counter = 0 # Counter for unique comment IDs
38
39
 
39
40
  # Initialize AI-Trackdown if available
41
+ # Always create tickets directory (needed for both modes)
42
+ self.tickets_dir.mkdir(parents=True, exist_ok=True)
43
+
40
44
  if HAS_AITRACKDOWN:
41
45
  self.tracker = AITrackdown(str(self.base_path))
42
46
  else:
43
47
  # Fallback to direct file operations
44
48
  self.tracker = None
45
- self.tickets_dir.mkdir(parents=True, exist_ok=True)
46
49
 
47
50
  def validate_credentials(self) -> tuple[bool, str]:
48
51
  """Validate that required credentials are present.
@@ -60,10 +63,15 @@ class AITrackdownAdapter(BaseAdapter[Task]):
60
63
  return True, ""
61
64
 
62
65
  def _get_state_mapping(self) -> dict[TicketState, str]:
63
- """Map universal states to AI-Trackdown states."""
66
+ """Map universal states to AI-Trackdown states.
67
+
68
+ Note: We use the exact enum values (snake_case) to match what
69
+ Pydantic's use_enum_values=True produces. This ensures consistency
70
+ between what's written to files and what's read back.
71
+ """
64
72
  return {
65
73
  TicketState.OPEN: "open",
66
- TicketState.IN_PROGRESS: "in-progress",
74
+ TicketState.IN_PROGRESS: "in_progress", # snake_case, not kebab-case
67
75
  TicketState.READY: "ready",
68
76
  TicketState.TESTED: "tested",
69
77
  TicketState.DONE: "done",
@@ -87,6 +95,18 @@ class AITrackdownAdapter(BaseAdapter[Task]):
87
95
 
88
96
  def _task_from_ai_ticket(self, ai_ticket: dict[str, Any]) -> Task:
89
97
  """Convert AI-Trackdown ticket to universal Task."""
98
+ # Get user metadata from ticket file
99
+ user_metadata = ai_ticket.get("metadata", {})
100
+
101
+ # Create adapter metadata
102
+ adapter_metadata = {
103
+ "ai_ticket_id": ai_ticket.get("id"),
104
+ "source": "aitrackdown",
105
+ }
106
+
107
+ # Merge user metadata with adapter metadata (user takes priority)
108
+ combined_metadata = {**adapter_metadata, **user_metadata}
109
+
90
110
  return Task(
91
111
  id=ai_ticket.get("id"),
92
112
  title=ai_ticket.get("title", ""),
@@ -107,11 +127,23 @@ class AITrackdownAdapter(BaseAdapter[Task]):
107
127
  if "updated_at" in ai_ticket
108
128
  else None
109
129
  ),
110
- metadata={"ai_trackdown": ai_ticket},
130
+ metadata=combined_metadata, # Use merged metadata
111
131
  )
112
132
 
113
133
  def _epic_from_ai_ticket(self, ai_ticket: dict[str, Any]) -> Epic:
114
134
  """Convert AI-Trackdown ticket to universal Epic."""
135
+ # Get user metadata from ticket file
136
+ user_metadata = ai_ticket.get("metadata", {})
137
+
138
+ # Create adapter metadata
139
+ adapter_metadata = {
140
+ "ai_ticket_id": ai_ticket.get("id"),
141
+ "source": "aitrackdown",
142
+ }
143
+
144
+ # Merge user metadata with adapter metadata (user takes priority)
145
+ combined_metadata = {**adapter_metadata, **user_metadata}
146
+
115
147
  return Epic(
116
148
  id=ai_ticket.get("id"),
117
149
  title=ai_ticket.get("title", ""),
@@ -130,20 +162,20 @@ class AITrackdownAdapter(BaseAdapter[Task]):
130
162
  if "updated_at" in ai_ticket and ai_ticket["updated_at"]
131
163
  else None
132
164
  ),
133
- metadata={"ai_trackdown": ai_ticket},
165
+ metadata=combined_metadata, # Use merged metadata
134
166
  )
135
167
 
136
168
  def _task_to_ai_ticket(self, task: Task) -> dict[str, Any]:
137
169
  """Convert universal Task to AI-Trackdown ticket."""
138
170
  # Handle enum values that may be stored as strings due to use_enum_values=True
171
+ # Note: task.state is always a string due to ConfigDict(use_enum_values=True)
139
172
  state_value = task.state
140
173
  if isinstance(task.state, TicketState):
141
174
  state_value = self._get_state_mapping()[task.state]
142
175
  elif isinstance(task.state, str):
143
- # Already a string, map to AI-Trackdown format if needed
144
- state_value = task.state.replace(
145
- "_", "-"
146
- ) # Convert snake_case to kebab-case
176
+ # Already a string - keep as-is (don't convert to kebab-case)
177
+ # The state is already in snake_case format from the enum value
178
+ state_value = task.state
147
179
 
148
180
  return {
149
181
  "id": task.id,
@@ -157,20 +189,21 @@ class AITrackdownAdapter(BaseAdapter[Task]):
157
189
  "assignee": task.assignee,
158
190
  "created_at": task.created_at.isoformat() if task.created_at else None,
159
191
  "updated_at": task.updated_at.isoformat() if task.updated_at else None,
192
+ "metadata": task.metadata or {}, # Serialize user metadata
160
193
  "type": "task",
161
194
  }
162
195
 
163
196
  def _epic_to_ai_ticket(self, epic: Epic) -> dict[str, Any]:
164
197
  """Convert universal Epic to AI-Trackdown ticket."""
165
198
  # Handle enum values that may be stored as strings due to use_enum_values=True
199
+ # Note: epic.state is always a string due to ConfigDict(use_enum_values=True)
166
200
  state_value = epic.state
167
201
  if isinstance(epic.state, TicketState):
168
202
  state_value = self._get_state_mapping()[epic.state]
169
203
  elif isinstance(epic.state, str):
170
- # Already a string, map to AI-Trackdown format if needed
171
- state_value = epic.state.replace(
172
- "_", "-"
173
- ) # Convert snake_case to kebab-case
204
+ # Already a string - keep as-is (don't convert to kebab-case)
205
+ # The state is already in snake_case format from the enum value
206
+ state_value = epic.state
174
207
 
175
208
  return {
176
209
  "id": epic.id,
@@ -182,6 +215,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
182
215
  "child_issues": epic.child_issues,
183
216
  "created_at": epic.created_at.isoformat() if epic.created_at else None,
184
217
  "updated_at": epic.updated_at.isoformat() if epic.updated_at else None,
218
+ "metadata": epic.metadata or {}, # Serialize user metadata
185
219
  "type": "epic",
186
220
  }
187
221
 
@@ -245,15 +279,14 @@ class AITrackdownAdapter(BaseAdapter[Task]):
245
279
 
246
280
  Returns:
247
281
  Created Epic instance
282
+
248
283
  """
249
- epic = Epic(
250
- title=title,
251
- description=description,
252
- **kwargs
253
- )
284
+ epic = Epic(title=title, description=description, **kwargs)
254
285
  return await self.create(epic)
255
286
 
256
- async def create_issue(self, title: str, parent_epic: str = None, description: str = None, **kwargs) -> Task:
287
+ async def create_issue(
288
+ self, title: str, parent_epic: str = None, description: str = None, **kwargs
289
+ ) -> Task:
257
290
  """Create a new issue.
258
291
 
259
292
  Args:
@@ -264,16 +297,16 @@ class AITrackdownAdapter(BaseAdapter[Task]):
264
297
 
265
298
  Returns:
266
299
  Created Task instance (representing an issue)
300
+
267
301
  """
268
302
  task = Task(
269
- title=title,
270
- description=description,
271
- parent_epic=parent_epic,
272
- **kwargs
303
+ title=title, description=description, parent_epic=parent_epic, **kwargs
273
304
  )
274
305
  return await self.create(task)
275
306
 
276
- async def create_task(self, title: str, parent_id: str, description: str = None, **kwargs) -> Task:
307
+ async def create_task(
308
+ self, title: str, parent_id: str, description: str = None, **kwargs
309
+ ) -> Task:
277
310
  """Create a new task under an issue.
278
311
 
279
312
  Args:
@@ -284,12 +317,10 @@ class AITrackdownAdapter(BaseAdapter[Task]):
284
317
 
285
318
  Returns:
286
319
  Created Task instance
320
+
287
321
  """
288
322
  task = Task(
289
- title=title,
290
- description=description,
291
- parent_issue=parent_id,
292
- **kwargs
323
+ title=title, description=description, parent_issue=parent_id, **kwargs
293
324
  )
294
325
  return await self.create(task)
295
326
 
@@ -310,8 +341,20 @@ class AITrackdownAdapter(BaseAdapter[Task]):
310
341
 
311
342
  async def update(
312
343
  self, ticket_id: str, updates: Union[dict[str, Any], Task]
313
- ) -> Optional[Task]:
314
- """Update a task."""
344
+ ) -> Optional[Union[Task, Epic]]:
345
+ """Update a task or epic.
346
+
347
+ Args:
348
+ ticket_id: ID of ticket to update
349
+ updates: Dictionary of updates or Task object with new values
350
+
351
+ Returns:
352
+ Updated Task or Epic, or None if ticket not found
353
+
354
+ Raises:
355
+ AttributeError: If update fails due to invalid fields
356
+
357
+ """
315
358
  # Read existing ticket
316
359
  existing = await self.read(ticket_id)
317
360
  if not existing:
@@ -335,8 +378,12 @@ class AITrackdownAdapter(BaseAdapter[Task]):
335
378
 
336
379
  existing.updated_at = datetime.now()
337
380
 
338
- # Write back
339
- ai_ticket = self._task_to_ai_ticket(existing)
381
+ # Write back - use appropriate converter based on ticket type
382
+ if isinstance(existing, Epic):
383
+ ai_ticket = self._epic_to_ai_ticket(existing)
384
+ else:
385
+ ai_ticket = self._task_to_ai_ticket(existing)
386
+
340
387
  if self.tracker:
341
388
  self.tracker.update_ticket(ticket_id, **updates)
342
389
  else:
@@ -451,10 +498,11 @@ class AITrackdownAdapter(BaseAdapter[Task]):
451
498
 
452
499
  async def add_comment(self, comment: Comment) -> Comment:
453
500
  """Add comment to a task."""
454
- # Generate ID
501
+ # Generate ID with counter to ensure uniqueness
455
502
  if not comment.id:
456
503
  timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
457
- comment.id = f"comment-{timestamp}"
504
+ self._comment_counter += 1
505
+ comment.id = f"comment-{timestamp}-{self._comment_counter:04d}"
458
506
 
459
507
  comment.created_at = datetime.now()
460
508
 
@@ -475,14 +523,94 @@ class AITrackdownAdapter(BaseAdapter[Task]):
475
523
  comments_dir = self.base_path / "comments"
476
524
 
477
525
  if comments_dir.exists():
526
+ # Get all comment files and filter by ticket_id first
478
527
  comment_files = sorted(comments_dir.glob("*.json"))
479
- for comment_file in comment_files[offset : offset + limit]:
528
+ for comment_file in comment_files:
480
529
  with open(comment_file) as f:
481
530
  data = json.load(f)
482
531
  if data.get("ticket_id") == ticket_id:
483
532
  comments.append(Comment(**data))
484
533
 
485
- return comments[:limit]
534
+ # Apply limit and offset AFTER filtering
535
+ return comments[offset : offset + limit]
536
+
537
+ async def get_epic(self, epic_id: str) -> Optional[Epic]:
538
+ """Get epic by ID.
539
+
540
+ Args:
541
+ epic_id: Epic ID to retrieve
542
+
543
+ Returns:
544
+ Epic if found, None otherwise
545
+
546
+ """
547
+ ticket = await self.read(epic_id)
548
+ if ticket:
549
+ # Check if it's an Epic (can be Epic instance or have epic ticket_type)
550
+ if isinstance(ticket, Epic):
551
+ return ticket
552
+ # Check ticket_type (may be string or enum)
553
+ ticket_type_str = (
554
+ str(ticket.ticket_type).lower()
555
+ if hasattr(ticket, "ticket_type")
556
+ else None
557
+ )
558
+ if ticket_type_str and "epic" in ticket_type_str:
559
+ return Epic(**ticket.model_dump())
560
+ return None
561
+
562
+ async def list_epics(self, limit: int = 10, offset: int = 0) -> builtins.list[Epic]:
563
+ """List all epics.
564
+
565
+ Args:
566
+ limit: Maximum number of epics to return
567
+ offset: Number of epics to skip
568
+
569
+ Returns:
570
+ List of epics
571
+
572
+ """
573
+ all_tickets = await self.list(limit=100, offset=0, filters={"type": "epic"})
574
+ epics = []
575
+ for ticket in all_tickets:
576
+ if ticket.ticket_type == "epic":
577
+ epics.append(Epic(**ticket.model_dump()))
578
+ return epics[offset : offset + limit]
579
+
580
+ async def list_issues_by_epic(self, epic_id: str) -> builtins.list[Task]:
581
+ """List all issues belonging to an epic.
582
+
583
+ Args:
584
+ epic_id: Epic ID to get issues for
585
+
586
+ Returns:
587
+ List of issues (tasks with parent_epic set)
588
+
589
+ """
590
+ all_tickets = await self.list(limit=1000, offset=0, filters={})
591
+ issues = []
592
+ for ticket in all_tickets:
593
+ if hasattr(ticket, "parent_epic") and ticket.parent_epic == epic_id:
594
+ issues.append(ticket)
595
+ return issues
596
+
597
+ async def list_tasks_by_issue(self, issue_id: str) -> builtins.list[Task]:
598
+ """List all tasks belonging to an issue.
599
+
600
+ Args:
601
+ issue_id: Issue ID (parent task) to get child tasks for
602
+
603
+ Returns:
604
+ List of tasks
605
+
606
+ """
607
+ all_tickets = await self.list(limit=1000, offset=0, filters={})
608
+ tasks = []
609
+ for ticket in all_tickets:
610
+ # Check if this ticket has parent_issue matching the issue
611
+ if hasattr(ticket, "parent_issue") and ticket.parent_issue == issue_id:
612
+ tasks.append(ticket)
613
+ return tasks
486
614
 
487
615
 
488
616
  # Register the adapter
@@ -1,17 +1,16 @@
1
1
  """GitHub adapter implementation using REST API v3 and GraphQL API v4."""
2
2
 
3
3
  import builtins
4
- import os
5
4
  import re
6
5
  from datetime import datetime
7
- from typing import Any, Dict, List, Optional
6
+ from typing import Any, Optional
8
7
 
9
8
  import httpx
10
9
 
11
10
  from ..core.adapter import BaseAdapter
11
+ from ..core.env_loader import load_adapter_config, validate_adapter_config
12
12
  from ..core.models import Comment, Epic, Priority, SearchQuery, Task, TicketState
13
13
  from ..core.registry import AdapterRegistry
14
- from ..core.env_loader import load_adapter_config, validate_adapter_config
15
14
 
16
15
 
17
16
  class GitHubStateMapping:
@@ -158,11 +157,15 @@ class GitHubAdapter(BaseAdapter[Task]):
158
157
  # Validate required configuration
159
158
  missing_keys = validate_adapter_config("github", full_config)
160
159
  if missing_keys:
161
- raise ValueError(f"GitHub adapter missing required configuration: {', '.join(missing_keys)}")
160
+ raise ValueError(
161
+ f"GitHub adapter missing required configuration: {', '.join(missing_keys)}"
162
+ )
162
163
 
163
164
  # Get authentication token - support both 'api_key' and 'token' for compatibility
164
165
  self.token = (
165
- full_config.get("api_key") or full_config.get("token") or full_config.get("token")
166
+ full_config.get("api_key")
167
+ or full_config.get("token")
168
+ or full_config.get("token")
166
169
  )
167
170
 
168
171
  # Get repository information
@@ -600,7 +603,7 @@ class GitHubAdapter(BaseAdapter[Task]):
600
603
  labels_to_update = update_data.get("labels", current_labels)
601
604
  all_priority_labels = []
602
605
  for labels in GitHubStateMapping.PRIORITY_LABELS.values():
603
- all_priority_labels.extend([l.lower() for l in labels])
606
+ all_priority_labels.extend([label.lower() for label in labels])
604
607
 
605
608
  labels_to_update = [
606
609
  label
@@ -1331,7 +1334,7 @@ Fixes #{issue_number}
1331
1334
  "message": f"Successfully linked PR #{pr_number} to issue #{issue_number}",
1332
1335
  }
1333
1336
 
1334
- async def get_collaborators(self) -> List[Dict[str, Any]]:
1337
+ async def get_collaborators(self) -> builtins.list[dict[str, Any]]:
1335
1338
  """Get repository collaborators."""
1336
1339
  response = await self.client.get(
1337
1340
  f"/repos/{self.owner}/{self.repo}/collaborators"
@@ -1339,7 +1342,7 @@ Fixes #{issue_number}
1339
1342
  response.raise_for_status()
1340
1343
  return response.json()
1341
1344
 
1342
- async def get_current_user(self) -> Optional[Dict[str, Any]]:
1345
+ async def get_current_user(self) -> Optional[dict[str, Any]]:
1343
1346
  """Get current authenticated user information."""
1344
1347
  response = await self.client.get("/user")
1345
1348
  response.raise_for_status()
@@ -3,26 +3,24 @@
3
3
  import asyncio
4
4
  import builtins
5
5
  import logging
6
- import os
7
6
  import re
8
7
  from datetime import datetime
9
8
  from enum import Enum
10
- from typing import Any, Dict, List, Optional, Union
9
+ from typing import Any, Optional, Union
11
10
 
12
11
  import httpx
13
12
  from httpx import AsyncClient, HTTPStatusError, TimeoutException
14
13
 
15
14
  from ..core.adapter import BaseAdapter
15
+ from ..core.env_loader import load_adapter_config, validate_adapter_config
16
16
  from ..core.models import Comment, Epic, Priority, SearchQuery, Task, TicketState
17
17
  from ..core.registry import AdapterRegistry
18
- from ..core.env_loader import load_adapter_config, validate_adapter_config
19
18
 
20
19
  logger = logging.getLogger(__name__)
21
20
 
22
21
 
23
22
  def parse_jira_datetime(date_str: str) -> Optional[datetime]:
24
- """
25
- Parse JIRA datetime strings which can be in various formats.
23
+ """Parse JIRA datetime strings which can be in various formats.
26
24
 
27
25
  JIRA can return dates in formats like:
28
26
  - 2025-10-24T14:12:18.771-0400
@@ -34,13 +32,13 @@ def parse_jira_datetime(date_str: str) -> Optional[datetime]:
34
32
 
35
33
  try:
36
34
  # Handle Z timezone
37
- if date_str.endswith('Z'):
38
- return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
35
+ if date_str.endswith("Z"):
36
+ return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
39
37
 
40
38
  # Handle timezone formats like -0400, +0500 (need to add colon)
41
- if re.match(r'.*[+-]\d{4}$', date_str):
39
+ if re.match(r".*[+-]\d{4}$", date_str):
42
40
  # Insert colon in timezone: -0400 -> -04:00
43
- date_str = re.sub(r'([+-]\d{2})(\d{2})$', r'\1:\2', date_str)
41
+ date_str = re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", date_str)
44
42
 
45
43
  return datetime.fromisoformat(date_str)
46
44
 
@@ -49,15 +47,15 @@ def parse_jira_datetime(date_str: str) -> Optional[datetime]:
49
47
  return None
50
48
 
51
49
 
52
- def extract_text_from_adf(adf_content: Union[str, Dict[str, Any]]) -> str:
53
- """
54
- Extract plain text from Atlassian Document Format (ADF).
50
+ def extract_text_from_adf(adf_content: Union[str, dict[str, Any]]) -> str:
51
+ """Extract plain text from Atlassian Document Format (ADF).
55
52
 
56
53
  Args:
57
54
  adf_content: Either a string (already plain text) or ADF document dict
58
55
 
59
56
  Returns:
60
57
  Plain text string extracted from the ADF content
58
+
61
59
  """
62
60
  if isinstance(adf_content, str):
63
61
  return adf_content
@@ -65,7 +63,7 @@ def extract_text_from_adf(adf_content: Union[str, Dict[str, Any]]) -> str:
65
63
  if not isinstance(adf_content, dict):
66
64
  return str(adf_content) if adf_content else ""
67
65
 
68
- def extract_text_recursive(node: Dict[str, Any]) -> str:
66
+ def extract_text_recursive(node: dict[str, Any]) -> str:
69
67
  """Recursively extract text from ADF nodes."""
70
68
  if not isinstance(node, dict):
71
69
  return ""
@@ -136,7 +134,9 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
136
134
  # Validate required configuration
137
135
  missing_keys = validate_adapter_config("jira", full_config)
138
136
  if missing_keys:
139
- raise ValueError(f"JIRA adapter missing required configuration: {', '.join(missing_keys)}")
137
+ raise ValueError(
138
+ f"JIRA adapter missing required configuration: {', '.join(missing_keys)}"
139
+ )
140
140
 
141
141
  # Configuration
142
142
  self.server = full_config.get("server", "")
@@ -803,14 +803,9 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
803
803
  "content": [
804
804
  {
805
805
  "type": "paragraph",
806
- "content": [
807
- {
808
- "type": "text",
809
- "text": comment.content
810
- }
811
- ]
806
+ "content": [{"type": "text", "text": comment.content}],
812
807
  }
813
- ]
808
+ ],
814
809
  }
815
810
  }
816
811
 
@@ -821,7 +816,9 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
821
816
 
822
817
  # Update comment with JIRA data
823
818
  comment.id = result.get("id")
824
- comment.created_at = parse_jira_datetime(result.get("created")) or datetime.now()
819
+ comment.created_at = (
820
+ parse_jira_datetime(result.get("created")) or datetime.now()
821
+ )
825
822
  comment.author = result.get("author", {}).get("displayName", comment.author)
826
823
  comment.metadata["jira"] = result
827
824
 
@@ -943,23 +940,27 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
943
940
 
944
941
  return sprints_data.get("values", [])
945
942
 
946
- async def get_project_users(self) -> List[Dict[str, Any]]:
943
+ async def get_project_users(self) -> builtins.list[dict[str, Any]]:
947
944
  """Get users who have access to the project."""
948
945
  if not self.project_key:
949
946
  return []
950
947
 
951
948
  try:
952
949
  # Get project role users
953
- project_data = await self._make_request("GET", f"project/{self.project_key}")
950
+ project_data = await self._make_request(
951
+ "GET", f"project/{self.project_key}"
952
+ )
954
953
 
955
954
  # Get users from project roles
956
955
  users = []
957
956
  if "roles" in project_data:
958
- for role_name, role_url in project_data["roles"].items():
957
+ for _role_name, role_url in project_data["roles"].items():
959
958
  # Extract role ID from URL
960
959
  role_id = role_url.split("/")[-1]
961
960
  try:
962
- role_data = await self._make_request("GET", f"project/{self.project_key}/role/{role_id}")
961
+ role_data = await self._make_request(
962
+ "GET", f"project/{self.project_key}/role/{role_id}"
963
+ )
963
964
  if "actors" in role_data:
964
965
  for actor in role_data["actors"]:
965
966
  if actor.get("type") == "atlassian-user-role-actor":
@@ -985,13 +986,13 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
985
986
  users_data = await self._make_request(
986
987
  "GET",
987
988
  "user/assignable/search",
988
- params={"project": self.project_key, "maxResults": 50}
989
+ params={"project": self.project_key, "maxResults": 50},
989
990
  )
990
991
  return users_data if isinstance(users_data, list) else []
991
992
  except Exception:
992
993
  return []
993
994
 
994
- async def get_current_user(self) -> Optional[Dict[str, Any]]:
995
+ async def get_current_user(self) -> Optional[dict[str, Any]]:
995
996
  """Get current authenticated user information."""
996
997
  try:
997
998
  return await self._make_request("GET", "myself")
@@ -11,7 +11,7 @@ The adapter is split into multiple modules for better organization:
11
11
 
12
12
  Usage:
13
13
  from mcp_ticketer.adapters.linear import LinearAdapter
14
-
14
+
15
15
  config = {
16
16
  "api_key": "your_linear_api_key",
17
17
  "team_id": "your_team_id"