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.
- mcp_ticketer/__version__.py +1 -1
- mcp_ticketer/adapters/aitrackdown.py +164 -36
- mcp_ticketer/adapters/github.py +11 -8
- mcp_ticketer/adapters/jira.py +29 -28
- mcp_ticketer/adapters/linear/__init__.py +1 -1
- mcp_ticketer/adapters/linear/adapter.py +105 -104
- mcp_ticketer/adapters/linear/client.py +78 -59
- mcp_ticketer/adapters/linear/mappers.py +93 -73
- mcp_ticketer/adapters/linear/queries.py +28 -7
- mcp_ticketer/adapters/linear/types.py +67 -60
- mcp_ticketer/adapters/linear.py +2 -2
- mcp_ticketer/cli/adapter_diagnostics.py +87 -52
- mcp_ticketer/cli/codex_configure.py +6 -6
- mcp_ticketer/cli/diagnostics.py +180 -88
- mcp_ticketer/cli/linear_commands.py +156 -113
- mcp_ticketer/cli/main.py +153 -82
- mcp_ticketer/cli/simple_health.py +74 -51
- mcp_ticketer/cli/utils.py +15 -10
- mcp_ticketer/core/config.py +23 -19
- mcp_ticketer/core/env_discovery.py +5 -4
- mcp_ticketer/core/env_loader.py +114 -91
- mcp_ticketer/core/exceptions.py +22 -20
- mcp_ticketer/core/models.py +9 -0
- mcp_ticketer/core/project_config.py +1 -1
- mcp_ticketer/mcp/constants.py +58 -0
- mcp_ticketer/mcp/dto.py +195 -0
- mcp_ticketer/mcp/response_builder.py +206 -0
- mcp_ticketer/mcp/server.py +361 -1182
- mcp_ticketer/queue/health_monitor.py +166 -135
- mcp_ticketer/queue/manager.py +70 -19
- mcp_ticketer/queue/queue.py +24 -5
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +203 -145
- mcp_ticketer/queue/worker.py +79 -43
- {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.3.dist-info}/METADATA +1 -1
- mcp_ticketer-0.3.3.dist-info/RECORD +62 -0
- mcp_ticketer-0.3.1.dist-info/RECORD +0 -59
- {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.3.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.3.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.3.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.3.dist-info}/top_level.txt +0 -0
mcp_ticketer/__version__.py
CHANGED
|
@@ -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: "
|
|
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=
|
|
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=
|
|
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
|
|
144
|
-
|
|
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
|
|
171
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
mcp_ticketer/adapters/github.py
CHANGED
|
@@ -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,
|
|
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(
|
|
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")
|
|
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([
|
|
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) ->
|
|
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[
|
|
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()
|
mcp_ticketer/adapters/jira.py
CHANGED
|
@@ -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,
|
|
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(
|
|
38
|
-
return datetime.fromisoformat(date_str.replace(
|
|
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
|
|
39
|
+
if re.match(r".*[+-]\d{4}$", date_str):
|
|
42
40
|
# Insert colon in timezone: -0400 -> -04:00
|
|
43
|
-
date_str = re.sub(r
|
|
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,
|
|
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:
|
|
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(
|
|
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 =
|
|
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) ->
|
|
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(
|
|
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
|
|
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(
|
|
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[
|
|
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")
|