mcp-ticketer 0.3.5__py3-none-any.whl → 0.12.0__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 +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +263 -14
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1308 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +334 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github.py +326 -109
- mcp_ticketer/adapters/hybrid.py +11 -11
- mcp_ticketer/adapters/jira.py +271 -25
- mcp_ticketer/adapters/linear/adapter.py +693 -39
- mcp_ticketer/adapters/linear/client.py +61 -9
- mcp_ticketer/adapters/linear/mappers.py +9 -3
- mcp_ticketer/adapters/linear/queries.py +9 -7
- mcp_ticketer/cache/memory.py +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +1 -1
- mcp_ticketer/cli/auggie_configure.py +104 -15
- mcp_ticketer/cli/codex_configure.py +188 -32
- mcp_ticketer/cli/configure.py +37 -48
- mcp_ticketer/cli/diagnostics.py +20 -18
- mcp_ticketer/cli/discover.py +292 -26
- mcp_ticketer/cli/gemini_configure.py +107 -26
- mcp_ticketer/cli/instruction_commands.py +429 -0
- mcp_ticketer/cli/linear_commands.py +105 -22
- mcp_ticketer/cli/main.py +1830 -435
- mcp_ticketer/cli/mcp_configure.py +296 -89
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +412 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/simple_health.py +1 -1
- mcp_ticketer/cli/ticket_commands.py +773 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +67 -62
- mcp_ticketer/core/__init__.py +14 -1
- mcp_ticketer/core/adapter.py +84 -15
- mcp_ticketer/core/config.py +44 -39
- mcp_ticketer/core/env_discovery.py +42 -12
- mcp_ticketer/core/env_loader.py +15 -14
- mcp_ticketer/core/exceptions.py +3 -3
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/mappers.py +11 -11
- mcp_ticketer/core/models.py +50 -20
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/project_config.py +57 -35
- mcp_ticketer/core/registry.py +3 -3
- mcp_ticketer/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +29 -1
- mcp_ticketer/mcp/__main__.py +60 -0
- mcp_ticketer/mcp/server/__init__.py +25 -0
- mcp_ticketer/mcp/server/__main__.py +60 -0
- mcp_ticketer/mcp/{dto.py → server/dto.py} +32 -32
- mcp_ticketer/mcp/{server.py → server/main.py} +127 -74
- mcp_ticketer/mcp/{response_builder.py → server/response_builder.py} +2 -2
- mcp_ticketer/mcp/server/server_sdk.py +93 -0
- mcp_ticketer/mcp/server/tools/__init__.py +47 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +90 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +532 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +154 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +206 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +430 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +5 -4
- mcp_ticketer/queue/manager.py +15 -51
- mcp_ticketer/queue/queue.py +19 -19
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +14 -14
- mcp_ticketer/queue/worker.py +16 -14
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +168 -32
- mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
- mcp_ticketer-0.3.5.dist-info/RECORD +0 -62
- /mcp_ticketer/mcp/{constants.py → server/constants.py} +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/top_level.txt +0 -0
mcp_ticketer/adapters/jira.py
CHANGED
|
@@ -6,20 +6,28 @@ import logging
|
|
|
6
6
|
import re
|
|
7
7
|
from datetime import datetime
|
|
8
8
|
from enum import Enum
|
|
9
|
-
from typing import Any,
|
|
9
|
+
from typing import Any, Union
|
|
10
10
|
|
|
11
11
|
import httpx
|
|
12
12
|
from httpx import AsyncClient, HTTPStatusError, TimeoutException
|
|
13
13
|
|
|
14
14
|
from ..core.adapter import BaseAdapter
|
|
15
15
|
from ..core.env_loader import load_adapter_config, validate_adapter_config
|
|
16
|
-
from ..core.models import
|
|
16
|
+
from ..core.models import (
|
|
17
|
+
Attachment,
|
|
18
|
+
Comment,
|
|
19
|
+
Epic,
|
|
20
|
+
Priority,
|
|
21
|
+
SearchQuery,
|
|
22
|
+
Task,
|
|
23
|
+
TicketState,
|
|
24
|
+
)
|
|
17
25
|
from ..core.registry import AdapterRegistry
|
|
18
26
|
|
|
19
27
|
logger = logging.getLogger(__name__)
|
|
20
28
|
|
|
21
29
|
|
|
22
|
-
def parse_jira_datetime(date_str: str) ->
|
|
30
|
+
def parse_jira_datetime(date_str: str) -> datetime | None:
|
|
23
31
|
"""Parse JIRA datetime strings which can be in various formats.
|
|
24
32
|
|
|
25
33
|
JIRA can return dates in formats like:
|
|
@@ -47,7 +55,7 @@ def parse_jira_datetime(date_str: str) -> Optional[datetime]:
|
|
|
47
55
|
return None
|
|
48
56
|
|
|
49
57
|
|
|
50
|
-
def extract_text_from_adf(adf_content:
|
|
58
|
+
def extract_text_from_adf(adf_content: str | dict[str, Any]) -> str:
|
|
51
59
|
"""Extract plain text from Atlassian Document Format (ADF).
|
|
52
60
|
|
|
53
61
|
Args:
|
|
@@ -221,8 +229,8 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
221
229
|
self,
|
|
222
230
|
method: str,
|
|
223
231
|
endpoint: str,
|
|
224
|
-
data:
|
|
225
|
-
params:
|
|
232
|
+
data: dict[str, Any] | None = None,
|
|
233
|
+
params: dict[str, Any] | None = None,
|
|
226
234
|
retry_count: int = 0,
|
|
227
235
|
) -> dict[str, Any]:
|
|
228
236
|
"""Make HTTP request to JIRA API with retry logic.
|
|
@@ -287,7 +295,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
287
295
|
return self._priority_cache
|
|
288
296
|
|
|
289
297
|
async def _get_issue_types(
|
|
290
|
-
self, project_key:
|
|
298
|
+
self, project_key: str | None = None
|
|
291
299
|
) -> list[dict[str, Any]]:
|
|
292
300
|
"""Get available issue types for a project."""
|
|
293
301
|
key = project_key or self.project_key
|
|
@@ -380,9 +388,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
380
388
|
}
|
|
381
389
|
return mapping.get(priority, JiraPriority.MEDIUM)
|
|
382
390
|
|
|
383
|
-
def _map_priority_from_jira(
|
|
384
|
-
self, jira_priority: Optional[dict[str, Any]]
|
|
385
|
-
) -> Priority:
|
|
391
|
+
def _map_priority_from_jira(self, jira_priority: dict[str, Any] | None) -> Priority:
|
|
386
392
|
"""Map JIRA priority to universal priority."""
|
|
387
393
|
if not jira_priority:
|
|
388
394
|
return Priority.MEDIUM
|
|
@@ -432,7 +438,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
432
438
|
else:
|
|
433
439
|
return TicketState.OPEN
|
|
434
440
|
|
|
435
|
-
def _issue_to_ticket(self, issue: dict[str, Any]) ->
|
|
441
|
+
def _issue_to_ticket(self, issue: dict[str, Any]) -> Epic | Task:
|
|
436
442
|
"""Convert JIRA issue to universal ticket model."""
|
|
437
443
|
fields = issue.get("fields", {})
|
|
438
444
|
|
|
@@ -507,7 +513,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
507
513
|
)
|
|
508
514
|
|
|
509
515
|
def _ticket_to_issue_fields(
|
|
510
|
-
self, ticket:
|
|
516
|
+
self, ticket: Epic | Task, issue_type: str | None = None
|
|
511
517
|
) -> dict[str, Any]:
|
|
512
518
|
"""Convert universal ticket to JIRA issue fields."""
|
|
513
519
|
# Convert description to ADF format for JIRA Cloud
|
|
@@ -556,7 +562,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
556
562
|
|
|
557
563
|
return fields
|
|
558
564
|
|
|
559
|
-
async def create(self, ticket:
|
|
565
|
+
async def create(self, ticket: Epic | Task) -> Epic | Task:
|
|
560
566
|
"""Create a new JIRA issue."""
|
|
561
567
|
# Validate credentials before attempting operation
|
|
562
568
|
is_valid, error_message = self.validate_credentials()
|
|
@@ -576,7 +582,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
576
582
|
created_issue = await self._make_request("GET", f"issue/{ticket.id}")
|
|
577
583
|
return self._issue_to_ticket(created_issue)
|
|
578
584
|
|
|
579
|
-
async def read(self, ticket_id: str) ->
|
|
585
|
+
async def read(self, ticket_id: str) -> Epic | Task | None:
|
|
580
586
|
"""Read a JIRA issue by key."""
|
|
581
587
|
# Validate credentials before attempting operation
|
|
582
588
|
is_valid, error_message = self.validate_credentials()
|
|
@@ -595,7 +601,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
595
601
|
|
|
596
602
|
async def update(
|
|
597
603
|
self, ticket_id: str, updates: dict[str, Any]
|
|
598
|
-
) ->
|
|
604
|
+
) -> Epic | Task | None:
|
|
599
605
|
"""Update a JIRA issue."""
|
|
600
606
|
# Validate credentials before attempting operation
|
|
601
607
|
is_valid, error_message = self.validate_credentials()
|
|
@@ -652,8 +658,8 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
652
658
|
raise
|
|
653
659
|
|
|
654
660
|
async def list(
|
|
655
|
-
self, limit: int = 10, offset: int = 0, filters:
|
|
656
|
-
) -> list[
|
|
661
|
+
self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
|
|
662
|
+
) -> list[Epic | Task]:
|
|
657
663
|
"""List JIRA issues with pagination."""
|
|
658
664
|
# Build JQL query
|
|
659
665
|
jql_parts = []
|
|
@@ -692,7 +698,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
692
698
|
issues = data.get("issues", [])
|
|
693
699
|
return [self._issue_to_ticket(issue) for issue in issues]
|
|
694
700
|
|
|
695
|
-
async def search(self, query: SearchQuery) -> builtins.list[
|
|
701
|
+
async def search(self, query: SearchQuery) -> builtins.list[Epic | Task]:
|
|
696
702
|
"""Search JIRA issues using JQL."""
|
|
697
703
|
# Build JQL query
|
|
698
704
|
jql_parts = []
|
|
@@ -744,7 +750,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
744
750
|
|
|
745
751
|
async def transition_state(
|
|
746
752
|
self, ticket_id: str, target_state: TicketState
|
|
747
|
-
) ->
|
|
753
|
+
) -> Epic | Task | None:
|
|
748
754
|
"""Transition JIRA issue to a new state."""
|
|
749
755
|
# Get available transitions
|
|
750
756
|
transitions = await self._get_transitions(ticket_id)
|
|
@@ -858,9 +864,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
858
864
|
|
|
859
865
|
return comments
|
|
860
866
|
|
|
861
|
-
async def get_project_info(
|
|
862
|
-
self, project_key: Optional[str] = None
|
|
863
|
-
) -> dict[str, Any]:
|
|
867
|
+
async def get_project_info(self, project_key: str | None = None) -> dict[str, Any]:
|
|
864
868
|
"""Get JIRA project information including workflows and fields."""
|
|
865
869
|
key = project_key or self.project_key
|
|
866
870
|
if not key:
|
|
@@ -882,7 +886,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
882
886
|
|
|
883
887
|
async def execute_jql(
|
|
884
888
|
self, jql: str, limit: int = 50
|
|
885
|
-
) -> builtins.list[
|
|
889
|
+
) -> builtins.list[Epic | Task]:
|
|
886
890
|
"""Execute a raw JQL query.
|
|
887
891
|
|
|
888
892
|
Args:
|
|
@@ -908,7 +912,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
908
912
|
return [self._issue_to_ticket(issue) for issue in issues]
|
|
909
913
|
|
|
910
914
|
async def get_sprints(
|
|
911
|
-
self, board_id:
|
|
915
|
+
self, board_id: int | None = None
|
|
912
916
|
) -> builtins.list[dict[str, Any]]:
|
|
913
917
|
"""Get active sprints for a board (requires JIRA Software).
|
|
914
918
|
|
|
@@ -992,13 +996,255 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
992
996
|
except Exception:
|
|
993
997
|
return []
|
|
994
998
|
|
|
995
|
-
async def get_current_user(self) ->
|
|
999
|
+
async def get_current_user(self) -> dict[str, Any] | None:
|
|
996
1000
|
"""Get current authenticated user information."""
|
|
997
1001
|
try:
|
|
998
1002
|
return await self._make_request("GET", "myself")
|
|
999
1003
|
except Exception:
|
|
1000
1004
|
return None
|
|
1001
1005
|
|
|
1006
|
+
async def list_labels(self) -> builtins.list[dict[str, Any]]:
|
|
1007
|
+
"""List all labels used in the project.
|
|
1008
|
+
|
|
1009
|
+
JIRA doesn't have a direct "list all labels" endpoint, so we query
|
|
1010
|
+
recent issues and extract unique labels from them.
|
|
1011
|
+
|
|
1012
|
+
Returns:
|
|
1013
|
+
List of label dictionaries with 'id' and 'name' fields
|
|
1014
|
+
|
|
1015
|
+
"""
|
|
1016
|
+
try:
|
|
1017
|
+
# Query recent issues to get labels in use
|
|
1018
|
+
jql = f"project = {self.project_key} ORDER BY updated DESC"
|
|
1019
|
+
data = await self._make_request(
|
|
1020
|
+
"GET",
|
|
1021
|
+
"search/jql",
|
|
1022
|
+
params={
|
|
1023
|
+
"jql": jql,
|
|
1024
|
+
"maxResults": 100, # Sample from recent 100 issues
|
|
1025
|
+
"fields": "labels",
|
|
1026
|
+
},
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
# Collect unique labels
|
|
1030
|
+
unique_labels = set()
|
|
1031
|
+
for issue in data.get("issues", []):
|
|
1032
|
+
labels = issue.get("fields", {}).get("labels", [])
|
|
1033
|
+
for label in labels:
|
|
1034
|
+
if isinstance(label, dict):
|
|
1035
|
+
unique_labels.add(label.get("name", ""))
|
|
1036
|
+
else:
|
|
1037
|
+
unique_labels.add(str(label))
|
|
1038
|
+
|
|
1039
|
+
# Transform to standardized format
|
|
1040
|
+
return [
|
|
1041
|
+
{"id": label, "name": label} for label in sorted(unique_labels) if label
|
|
1042
|
+
]
|
|
1043
|
+
|
|
1044
|
+
except Exception:
|
|
1045
|
+
# Fallback: return empty list if query fails
|
|
1046
|
+
return []
|
|
1047
|
+
|
|
1048
|
+
async def update_epic(self, epic_id: str, updates: dict[str, Any]) -> Epic | None:
|
|
1049
|
+
"""Update a JIRA Epic with epic-specific field handling.
|
|
1050
|
+
|
|
1051
|
+
Args:
|
|
1052
|
+
epic_id: Epic identifier (key like PROJ-123 or ID)
|
|
1053
|
+
updates: Dictionary with fields to update:
|
|
1054
|
+
- title: Epic title (maps to summary)
|
|
1055
|
+
- description: Epic description (auto-converted to ADF)
|
|
1056
|
+
- state: TicketState value (transitions via workflow)
|
|
1057
|
+
- tags: List of labels
|
|
1058
|
+
- priority: Priority level
|
|
1059
|
+
|
|
1060
|
+
Returns:
|
|
1061
|
+
Updated Epic object or None if not found
|
|
1062
|
+
|
|
1063
|
+
Raises:
|
|
1064
|
+
ValueError: If no fields provided for update
|
|
1065
|
+
HTTPStatusError: If update fails
|
|
1066
|
+
|
|
1067
|
+
"""
|
|
1068
|
+
fields = {}
|
|
1069
|
+
|
|
1070
|
+
# Map title to summary
|
|
1071
|
+
if "title" in updates:
|
|
1072
|
+
fields["summary"] = updates["title"]
|
|
1073
|
+
|
|
1074
|
+
# Convert description to ADF format
|
|
1075
|
+
if "description" in updates:
|
|
1076
|
+
fields["description"] = self._convert_to_adf(updates["description"])
|
|
1077
|
+
|
|
1078
|
+
# Map tags to labels
|
|
1079
|
+
if "tags" in updates:
|
|
1080
|
+
fields["labels"] = updates["tags"]
|
|
1081
|
+
|
|
1082
|
+
# Map priority (some JIRA configs allow priority on Epics)
|
|
1083
|
+
if "priority" in updates:
|
|
1084
|
+
priority_value = updates["priority"]
|
|
1085
|
+
if isinstance(priority_value, Priority):
|
|
1086
|
+
fields["priority"] = {
|
|
1087
|
+
"name": self._map_priority_to_jira(priority_value)
|
|
1088
|
+
}
|
|
1089
|
+
else:
|
|
1090
|
+
# String priority passed directly
|
|
1091
|
+
fields["priority"] = {"name": priority_value}
|
|
1092
|
+
|
|
1093
|
+
if not fields and "state" not in updates:
|
|
1094
|
+
raise ValueError("At least one field must be updated")
|
|
1095
|
+
|
|
1096
|
+
# Apply field updates if any
|
|
1097
|
+
if fields:
|
|
1098
|
+
await self._make_request("PUT", f"issue/{epic_id}", data={"fields": fields})
|
|
1099
|
+
|
|
1100
|
+
# Handle state transitions separately (JIRA uses workflow transitions)
|
|
1101
|
+
if "state" in updates:
|
|
1102
|
+
await self.transition_state(epic_id, updates["state"])
|
|
1103
|
+
|
|
1104
|
+
# Fetch and return updated epic
|
|
1105
|
+
return await self.read(epic_id)
|
|
1106
|
+
|
|
1107
|
+
async def add_attachment(
|
|
1108
|
+
self, ticket_id: str, file_path: str, description: str | None = None
|
|
1109
|
+
) -> Attachment:
|
|
1110
|
+
"""Attach file to JIRA issue (including Epic).
|
|
1111
|
+
|
|
1112
|
+
Args:
|
|
1113
|
+
ticket_id: Issue key (e.g., PROJ-123) or ID
|
|
1114
|
+
file_path: Path to file to attach
|
|
1115
|
+
description: Optional description (stored in metadata, not used by JIRA directly)
|
|
1116
|
+
|
|
1117
|
+
Returns:
|
|
1118
|
+
Attachment object with metadata
|
|
1119
|
+
|
|
1120
|
+
Raises:
|
|
1121
|
+
FileNotFoundError: If file doesn't exist
|
|
1122
|
+
ValueError: If credentials invalid
|
|
1123
|
+
HTTPStatusError: If upload fails
|
|
1124
|
+
|
|
1125
|
+
"""
|
|
1126
|
+
from pathlib import Path
|
|
1127
|
+
|
|
1128
|
+
# Validate credentials before attempting operation
|
|
1129
|
+
is_valid, error_message = self.validate_credentials()
|
|
1130
|
+
if not is_valid:
|
|
1131
|
+
raise ValueError(error_message)
|
|
1132
|
+
|
|
1133
|
+
file_path_obj = Path(file_path)
|
|
1134
|
+
if not file_path_obj.exists():
|
|
1135
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
1136
|
+
|
|
1137
|
+
# JIRA requires special header for attachment upload
|
|
1138
|
+
headers = {
|
|
1139
|
+
"X-Atlassian-Token": "no-check",
|
|
1140
|
+
# Don't set Content-Type - let httpx handle multipart
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
# Prepare multipart file upload
|
|
1144
|
+
with open(file_path_obj, "rb") as f:
|
|
1145
|
+
files = {"file": (file_path_obj.name, f, "application/octet-stream")}
|
|
1146
|
+
|
|
1147
|
+
url = f"{self.api_base}/issue/{ticket_id}/attachments"
|
|
1148
|
+
|
|
1149
|
+
# Use existing client infrastructure
|
|
1150
|
+
async with await self._get_client() as client:
|
|
1151
|
+
response = await client.post(
|
|
1152
|
+
url, files=files, headers={**self.headers, **headers}
|
|
1153
|
+
)
|
|
1154
|
+
response.raise_for_status()
|
|
1155
|
+
|
|
1156
|
+
# JIRA returns array with single attachment
|
|
1157
|
+
attachment_data = response.json()[0]
|
|
1158
|
+
|
|
1159
|
+
return Attachment(
|
|
1160
|
+
id=attachment_data["id"],
|
|
1161
|
+
ticket_id=ticket_id,
|
|
1162
|
+
filename=attachment_data["filename"],
|
|
1163
|
+
url=attachment_data["content"],
|
|
1164
|
+
content_type=attachment_data["mimeType"],
|
|
1165
|
+
size_bytes=attachment_data["size"],
|
|
1166
|
+
created_at=parse_jira_datetime(attachment_data["created"]),
|
|
1167
|
+
created_by=attachment_data["author"]["displayName"],
|
|
1168
|
+
description=description,
|
|
1169
|
+
metadata={"jira": attachment_data},
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
async def get_attachments(self, ticket_id: str) -> builtins.list[Attachment]:
|
|
1173
|
+
"""Get all attachments for a JIRA issue.
|
|
1174
|
+
|
|
1175
|
+
Args:
|
|
1176
|
+
ticket_id: Issue key or ID
|
|
1177
|
+
|
|
1178
|
+
Returns:
|
|
1179
|
+
List of Attachment objects
|
|
1180
|
+
|
|
1181
|
+
Raises:
|
|
1182
|
+
ValueError: If credentials invalid
|
|
1183
|
+
HTTPStatusError: If request fails
|
|
1184
|
+
|
|
1185
|
+
"""
|
|
1186
|
+
# Validate credentials before attempting operation
|
|
1187
|
+
is_valid, error_message = self.validate_credentials()
|
|
1188
|
+
if not is_valid:
|
|
1189
|
+
raise ValueError(error_message)
|
|
1190
|
+
|
|
1191
|
+
# Fetch issue with attachment field
|
|
1192
|
+
issue = await self._make_request(
|
|
1193
|
+
"GET", f"issue/{ticket_id}", params={"fields": "attachment"}
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
attachments = []
|
|
1197
|
+
for att_data in issue.get("fields", {}).get("attachment", []):
|
|
1198
|
+
attachments.append(
|
|
1199
|
+
Attachment(
|
|
1200
|
+
id=att_data["id"],
|
|
1201
|
+
ticket_id=ticket_id,
|
|
1202
|
+
filename=att_data["filename"],
|
|
1203
|
+
url=att_data["content"],
|
|
1204
|
+
content_type=att_data["mimeType"],
|
|
1205
|
+
size_bytes=att_data["size"],
|
|
1206
|
+
created_at=parse_jira_datetime(att_data["created"]),
|
|
1207
|
+
created_by=att_data["author"]["displayName"],
|
|
1208
|
+
metadata={"jira": att_data},
|
|
1209
|
+
)
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
return attachments
|
|
1213
|
+
|
|
1214
|
+
async def delete_attachment(self, ticket_id: str, attachment_id: str) -> bool:
|
|
1215
|
+
"""Delete an attachment from a JIRA issue.
|
|
1216
|
+
|
|
1217
|
+
Args:
|
|
1218
|
+
ticket_id: Issue key or ID (for validation/context)
|
|
1219
|
+
attachment_id: Attachment ID to delete
|
|
1220
|
+
|
|
1221
|
+
Returns:
|
|
1222
|
+
True if deleted successfully, False otherwise
|
|
1223
|
+
|
|
1224
|
+
Raises:
|
|
1225
|
+
ValueError: If credentials invalid
|
|
1226
|
+
|
|
1227
|
+
"""
|
|
1228
|
+
# Validate credentials before attempting operation
|
|
1229
|
+
is_valid, error_message = self.validate_credentials()
|
|
1230
|
+
if not is_valid:
|
|
1231
|
+
raise ValueError(error_message)
|
|
1232
|
+
|
|
1233
|
+
try:
|
|
1234
|
+
await self._make_request("DELETE", f"attachment/{attachment_id}")
|
|
1235
|
+
return True
|
|
1236
|
+
except HTTPStatusError as e:
|
|
1237
|
+
if e.response.status_code == 404:
|
|
1238
|
+
logger.warning(f"Attachment {attachment_id} not found")
|
|
1239
|
+
return False
|
|
1240
|
+
logger.error(
|
|
1241
|
+
f"Failed to delete attachment {attachment_id}: {e.response.status_code} - {e.response.text}"
|
|
1242
|
+
)
|
|
1243
|
+
return False
|
|
1244
|
+
except Exception as e:
|
|
1245
|
+
logger.error(f"Unexpected error deleting attachment {attachment_id}: {e}")
|
|
1246
|
+
return False
|
|
1247
|
+
|
|
1002
1248
|
async def close(self) -> None:
|
|
1003
1249
|
"""Close the adapter and cleanup resources."""
|
|
1004
1250
|
# Clear caches
|