mcp-ticketer 2.0.1__py3-none-any.whl → 2.2.13__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/_version_scm.py +1 -0
- mcp_ticketer/adapters/aitrackdown.py +122 -0
- mcp_ticketer/adapters/asana/adapter.py +121 -0
- mcp_ticketer/adapters/github/__init__.py +26 -0
- mcp_ticketer/adapters/{github.py → github/adapter.py} +1506 -365
- mcp_ticketer/adapters/github/client.py +335 -0
- mcp_ticketer/adapters/github/mappers.py +797 -0
- mcp_ticketer/adapters/github/queries.py +692 -0
- mcp_ticketer/adapters/github/types.py +460 -0
- mcp_ticketer/adapters/jira/__init__.py +35 -0
- mcp_ticketer/adapters/{jira.py → jira/adapter.py} +250 -678
- mcp_ticketer/adapters/jira/client.py +271 -0
- mcp_ticketer/adapters/jira/mappers.py +246 -0
- mcp_ticketer/adapters/jira/queries.py +216 -0
- mcp_ticketer/adapters/jira/types.py +304 -0
- mcp_ticketer/adapters/linear/adapter.py +1000 -92
- mcp_ticketer/adapters/linear/client.py +91 -1
- mcp_ticketer/adapters/linear/mappers.py +107 -0
- mcp_ticketer/adapters/linear/queries.py +112 -2
- mcp_ticketer/adapters/linear/types.py +50 -10
- mcp_ticketer/cli/configure.py +524 -89
- mcp_ticketer/cli/install_mcp_server.py +418 -0
- mcp_ticketer/cli/main.py +10 -0
- mcp_ticketer/cli/mcp_configure.py +177 -49
- mcp_ticketer/cli/platform_installer.py +9 -0
- mcp_ticketer/cli/setup_command.py +157 -1
- mcp_ticketer/cli/ticket_commands.py +443 -81
- mcp_ticketer/cli/utils.py +113 -0
- mcp_ticketer/core/__init__.py +28 -0
- mcp_ticketer/core/adapter.py +367 -1
- mcp_ticketer/core/milestone_manager.py +252 -0
- mcp_ticketer/core/models.py +345 -0
- mcp_ticketer/core/project_utils.py +281 -0
- mcp_ticketer/core/project_validator.py +376 -0
- mcp_ticketer/core/session_state.py +6 -1
- mcp_ticketer/core/state_matcher.py +36 -3
- mcp_ticketer/mcp/server/__main__.py +2 -1
- mcp_ticketer/mcp/server/routing.py +68 -0
- mcp_ticketer/mcp/server/tools/__init__.py +7 -4
- mcp_ticketer/mcp/server/tools/attachment_tools.py +3 -1
- mcp_ticketer/mcp/server/tools/config_tools.py +233 -35
- mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +30 -1
- mcp_ticketer/mcp/server/tools/ticket_tools.py +37 -1
- mcp_ticketer/queue/queue.py +68 -0
- {mcp_ticketer-2.0.1.dist-info → mcp_ticketer-2.2.13.dist-info}/METADATA +33 -3
- {mcp_ticketer-2.0.1.dist-info → mcp_ticketer-2.2.13.dist-info}/RECORD +72 -36
- mcp_ticketer-2.2.13.dist-info/top_level.txt +2 -0
- py_mcp_installer/examples/phase3_demo.py +178 -0
- py_mcp_installer/scripts/manage_version.py +54 -0
- py_mcp_installer/setup.py +6 -0
- py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
- py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
- py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
- py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
- py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
- py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
- py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
- py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
- py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
- py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
- py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
- py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
- py_mcp_installer/src/py_mcp_installer/types.py +222 -0
- py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
- py_mcp_installer/tests/__init__.py +0 -0
- py_mcp_installer/tests/platforms/__init__.py +0 -0
- py_mcp_installer/tests/test_platform_detector.py +17 -0
- mcp_ticketer-2.0.1.dist-info/top_level.txt +0 -1
- {mcp_ticketer-2.0.1.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
- {mcp_ticketer-2.0.1.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-2.0.1.dist-info → mcp_ticketer-2.2.13.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""JQL query builders and query utilities for Jira adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ...core.models import SearchQuery
|
|
8
|
+
from .types import map_priority_to_jira
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_list_jql(
|
|
12
|
+
project_key: str,
|
|
13
|
+
filters: dict[str, Any] | None = None,
|
|
14
|
+
state_mapper: callable | None = None,
|
|
15
|
+
) -> str:
|
|
16
|
+
"""Build JQL query for listing issues.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
----
|
|
20
|
+
project_key: JIRA project key
|
|
21
|
+
filters: Optional filters dictionary with keys:
|
|
22
|
+
- state: TicketState value
|
|
23
|
+
- priority: Priority value
|
|
24
|
+
- assignee: User identifier
|
|
25
|
+
- ticket_type: Issue type name
|
|
26
|
+
state_mapper: Function to map TicketState to JIRA status string
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
-------
|
|
30
|
+
JQL query string
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
jql_parts = []
|
|
34
|
+
|
|
35
|
+
if project_key:
|
|
36
|
+
jql_parts.append(f"project = {project_key}")
|
|
37
|
+
|
|
38
|
+
if filters:
|
|
39
|
+
if "state" in filters and state_mapper:
|
|
40
|
+
status = state_mapper(filters["state"])
|
|
41
|
+
jql_parts.append(f'status = "{status}"')
|
|
42
|
+
if "priority" in filters:
|
|
43
|
+
priority = map_priority_to_jira(filters["priority"])
|
|
44
|
+
jql_parts.append(f'priority = "{priority}"')
|
|
45
|
+
if "assignee" in filters:
|
|
46
|
+
jql_parts.append(f'assignee = "{filters["assignee"]}"')
|
|
47
|
+
if "ticket_type" in filters:
|
|
48
|
+
jql_parts.append(f'issuetype = "{filters["ticket_type"]}"')
|
|
49
|
+
|
|
50
|
+
return " AND ".join(jql_parts) if jql_parts else "ORDER BY created DESC"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_search_jql(
|
|
54
|
+
project_key: str,
|
|
55
|
+
query: SearchQuery,
|
|
56
|
+
state_mapper: callable | None = None,
|
|
57
|
+
) -> str:
|
|
58
|
+
"""Build JQL query for searching issues.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
----
|
|
62
|
+
project_key: JIRA project key
|
|
63
|
+
query: SearchQuery object with search parameters
|
|
64
|
+
state_mapper: Function to map TicketState to JIRA status string
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
-------
|
|
68
|
+
JQL query string
|
|
69
|
+
|
|
70
|
+
"""
|
|
71
|
+
jql_parts = []
|
|
72
|
+
|
|
73
|
+
if project_key:
|
|
74
|
+
jql_parts.append(f"project = {project_key}")
|
|
75
|
+
|
|
76
|
+
# Text search
|
|
77
|
+
if query.query:
|
|
78
|
+
jql_parts.append(f'text ~ "{query.query}"')
|
|
79
|
+
|
|
80
|
+
# State filter
|
|
81
|
+
if query.state and state_mapper:
|
|
82
|
+
status = state_mapper(query.state)
|
|
83
|
+
jql_parts.append(f'status = "{status}"')
|
|
84
|
+
|
|
85
|
+
# Priority filter
|
|
86
|
+
if query.priority:
|
|
87
|
+
priority = map_priority_to_jira(query.priority)
|
|
88
|
+
jql_parts.append(f'priority = "{priority}"')
|
|
89
|
+
|
|
90
|
+
# Assignee filter
|
|
91
|
+
if query.assignee:
|
|
92
|
+
jql_parts.append(f'assignee = "{query.assignee}"')
|
|
93
|
+
|
|
94
|
+
# Tags/labels filter
|
|
95
|
+
if query.tags:
|
|
96
|
+
label_conditions = [f'labels = "{tag}"' for tag in query.tags]
|
|
97
|
+
jql_parts.append(f"({' OR '.join(label_conditions)})")
|
|
98
|
+
|
|
99
|
+
return " AND ".join(jql_parts) if jql_parts else "ORDER BY created DESC"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_epic_list_jql(
|
|
103
|
+
project_key: str,
|
|
104
|
+
state: str | None = None,
|
|
105
|
+
) -> str:
|
|
106
|
+
"""Build JQL query for listing epics.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
----
|
|
110
|
+
project_key: JIRA project key
|
|
111
|
+
state: Optional status name to filter by
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
-------
|
|
115
|
+
JQL query string
|
|
116
|
+
|
|
117
|
+
"""
|
|
118
|
+
jql_parts = [f"project = {project_key}", 'issuetype = "Epic"']
|
|
119
|
+
|
|
120
|
+
# Add state filter if provided
|
|
121
|
+
if state:
|
|
122
|
+
jql_parts.append(f'status = "{state}"')
|
|
123
|
+
|
|
124
|
+
return " AND ".join(jql_parts) + " ORDER BY updated DESC"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_labels_list_jql(
|
|
128
|
+
project_key: str,
|
|
129
|
+
max_results: int = 100,
|
|
130
|
+
) -> str:
|
|
131
|
+
"""Build JQL query for listing labels from recent issues.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
----
|
|
135
|
+
project_key: JIRA project key
|
|
136
|
+
max_results: Maximum number of issues to sample
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
-------
|
|
140
|
+
JQL query string
|
|
141
|
+
|
|
142
|
+
"""
|
|
143
|
+
return f"project = {project_key} ORDER BY updated DESC"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_project_labels_jql(
|
|
147
|
+
project_key: str,
|
|
148
|
+
max_results: int = 500,
|
|
149
|
+
) -> str:
|
|
150
|
+
"""Build JQL query for listing all project labels.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
----
|
|
154
|
+
project_key: JIRA project key
|
|
155
|
+
max_results: Maximum number of issues to sample
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
-------
|
|
159
|
+
JQL query string
|
|
160
|
+
|
|
161
|
+
"""
|
|
162
|
+
return f"project = {project_key} ORDER BY updated DESC"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_search_params(
|
|
166
|
+
jql: str,
|
|
167
|
+
start_at: int = 0,
|
|
168
|
+
max_results: int = 50,
|
|
169
|
+
fields: str = "*all",
|
|
170
|
+
expand: str = "renderedFields",
|
|
171
|
+
) -> dict[str, Any]:
|
|
172
|
+
"""Get standard search query parameters.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
----
|
|
176
|
+
jql: JQL query string
|
|
177
|
+
start_at: Pagination offset
|
|
178
|
+
max_results: Maximum number of results
|
|
179
|
+
fields: Fields to include in response
|
|
180
|
+
expand: Additional data to expand
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
-------
|
|
184
|
+
Dictionary of query parameters
|
|
185
|
+
|
|
186
|
+
"""
|
|
187
|
+
return {
|
|
188
|
+
"jql": jql,
|
|
189
|
+
"startAt": start_at,
|
|
190
|
+
"maxResults": max_results,
|
|
191
|
+
"fields": fields,
|
|
192
|
+
"expand": expand,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def get_labels_search_params(
|
|
197
|
+
jql: str,
|
|
198
|
+
max_results: int = 100,
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
"""Get search parameters for label listing.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
----
|
|
204
|
+
jql: JQL query string
|
|
205
|
+
max_results: Maximum number of results
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
-------
|
|
209
|
+
Dictionary of query parameters
|
|
210
|
+
|
|
211
|
+
"""
|
|
212
|
+
return {
|
|
213
|
+
"jql": jql,
|
|
214
|
+
"maxResults": max_results,
|
|
215
|
+
"fields": "labels",
|
|
216
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Type definitions and mappings for Jira adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ...core.models import Priority, TicketState
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JiraIssueType(str, Enum):
|
|
17
|
+
"""Common JIRA issue types."""
|
|
18
|
+
|
|
19
|
+
EPIC = "Epic"
|
|
20
|
+
STORY = "Story"
|
|
21
|
+
TASK = "Task"
|
|
22
|
+
BUG = "Bug"
|
|
23
|
+
SUBTASK = "Sub-task"
|
|
24
|
+
IMPROVEMENT = "Improvement"
|
|
25
|
+
NEW_FEATURE = "New Feature"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class JiraPriority(str, Enum):
|
|
29
|
+
"""Standard JIRA priority levels."""
|
|
30
|
+
|
|
31
|
+
HIGHEST = "Highest"
|
|
32
|
+
HIGH = "High"
|
|
33
|
+
MEDIUM = "Medium"
|
|
34
|
+
LOW = "Low"
|
|
35
|
+
LOWEST = "Lowest"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_state_mapping() -> dict[TicketState, str]:
|
|
39
|
+
"""Map universal states to common JIRA workflow states.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
-------
|
|
43
|
+
Dictionary mapping TicketState enum values to JIRA status names
|
|
44
|
+
|
|
45
|
+
"""
|
|
46
|
+
return {
|
|
47
|
+
TicketState.OPEN: "To Do",
|
|
48
|
+
TicketState.IN_PROGRESS: "In Progress",
|
|
49
|
+
TicketState.READY: "In Review",
|
|
50
|
+
TicketState.TESTED: "Testing",
|
|
51
|
+
TicketState.DONE: "Done",
|
|
52
|
+
TicketState.WAITING: "Waiting",
|
|
53
|
+
TicketState.BLOCKED: "Blocked",
|
|
54
|
+
TicketState.CLOSED: "Closed",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def map_priority_to_jira(priority: Priority) -> str:
|
|
59
|
+
"""Map universal priority to JIRA priority.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
----
|
|
63
|
+
priority: Universal Priority enum value
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
-------
|
|
67
|
+
JIRA priority string
|
|
68
|
+
|
|
69
|
+
"""
|
|
70
|
+
mapping = {
|
|
71
|
+
Priority.CRITICAL: JiraPriority.HIGHEST,
|
|
72
|
+
Priority.HIGH: JiraPriority.HIGH,
|
|
73
|
+
Priority.MEDIUM: JiraPriority.MEDIUM,
|
|
74
|
+
Priority.LOW: JiraPriority.LOW,
|
|
75
|
+
}
|
|
76
|
+
return mapping.get(priority, JiraPriority.MEDIUM)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def map_priority_from_jira(jira_priority: dict[str, Any] | None) -> Priority:
|
|
80
|
+
"""Map JIRA priority to universal priority.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
----
|
|
84
|
+
jira_priority: JIRA priority dictionary with 'name' field
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
-------
|
|
88
|
+
Universal Priority enum value
|
|
89
|
+
|
|
90
|
+
"""
|
|
91
|
+
if not jira_priority:
|
|
92
|
+
return Priority.MEDIUM
|
|
93
|
+
|
|
94
|
+
name = jira_priority.get("name", "").lower()
|
|
95
|
+
|
|
96
|
+
if "highest" in name or "urgent" in name or "critical" in name:
|
|
97
|
+
return Priority.CRITICAL
|
|
98
|
+
elif "high" in name:
|
|
99
|
+
return Priority.HIGH
|
|
100
|
+
elif "low" in name:
|
|
101
|
+
return Priority.LOW
|
|
102
|
+
else:
|
|
103
|
+
return Priority.MEDIUM
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def map_state_from_jira(status: dict[str, Any]) -> TicketState:
|
|
107
|
+
"""Map JIRA status to universal state.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
----
|
|
111
|
+
status: JIRA status dictionary with 'name' and 'statusCategory' fields
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
-------
|
|
115
|
+
Universal TicketState enum value
|
|
116
|
+
|
|
117
|
+
"""
|
|
118
|
+
if not status:
|
|
119
|
+
return TicketState.OPEN
|
|
120
|
+
|
|
121
|
+
name = status.get("name", "").lower()
|
|
122
|
+
category = status.get("statusCategory", {}).get("key", "").lower()
|
|
123
|
+
|
|
124
|
+
# Try to match by category first (more reliable)
|
|
125
|
+
if category == "new":
|
|
126
|
+
return TicketState.OPEN
|
|
127
|
+
elif category == "indeterminate":
|
|
128
|
+
return TicketState.IN_PROGRESS
|
|
129
|
+
elif category == "done":
|
|
130
|
+
return TicketState.DONE
|
|
131
|
+
|
|
132
|
+
# Fall back to name matching
|
|
133
|
+
if "block" in name:
|
|
134
|
+
return TicketState.BLOCKED
|
|
135
|
+
elif "wait" in name:
|
|
136
|
+
return TicketState.WAITING
|
|
137
|
+
elif "progress" in name or "doing" in name:
|
|
138
|
+
return TicketState.IN_PROGRESS
|
|
139
|
+
elif "review" in name:
|
|
140
|
+
return TicketState.READY
|
|
141
|
+
elif "test" in name:
|
|
142
|
+
return TicketState.TESTED
|
|
143
|
+
elif "done" in name or "resolved" in name:
|
|
144
|
+
return TicketState.DONE
|
|
145
|
+
elif "closed" in name:
|
|
146
|
+
return TicketState.CLOSED
|
|
147
|
+
else:
|
|
148
|
+
return TicketState.OPEN
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_jira_datetime(date_str: str) -> datetime | None:
|
|
152
|
+
"""Parse JIRA datetime strings which can be in various formats.
|
|
153
|
+
|
|
154
|
+
JIRA can return dates in formats like:
|
|
155
|
+
- 2025-10-24T14:12:18.771-0400
|
|
156
|
+
- 2025-10-24T14:12:18.771Z
|
|
157
|
+
- 2025-10-24T14:12:18.771+00:00
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
----
|
|
161
|
+
date_str: JIRA datetime string
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
-------
|
|
165
|
+
datetime object or None if parsing fails
|
|
166
|
+
|
|
167
|
+
"""
|
|
168
|
+
if not date_str:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
# Handle Z timezone
|
|
173
|
+
if date_str.endswith("Z"):
|
|
174
|
+
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
|
175
|
+
|
|
176
|
+
# Handle timezone formats like -0400, +0500 (need to add colon)
|
|
177
|
+
if re.match(r".*[+-]\d{4}$", date_str):
|
|
178
|
+
# Insert colon in timezone: -0400 -> -04:00
|
|
179
|
+
date_str = re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", date_str)
|
|
180
|
+
|
|
181
|
+
return datetime.fromisoformat(date_str)
|
|
182
|
+
|
|
183
|
+
except (ValueError, TypeError) as e:
|
|
184
|
+
logger.warning(f"Failed to parse JIRA datetime '{date_str}': {e}")
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def extract_text_from_adf(adf_content: str | dict[str, Any]) -> str:
|
|
189
|
+
"""Extract plain text from Atlassian Document Format (ADF).
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
----
|
|
193
|
+
adf_content: Either a string (already plain text) or ADF document dict
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
-------
|
|
197
|
+
Plain text string extracted from the ADF content
|
|
198
|
+
|
|
199
|
+
"""
|
|
200
|
+
if isinstance(adf_content, str):
|
|
201
|
+
return adf_content
|
|
202
|
+
|
|
203
|
+
if not isinstance(adf_content, dict):
|
|
204
|
+
return str(adf_content) if adf_content else ""
|
|
205
|
+
|
|
206
|
+
def extract_text_recursive(node: dict[str, Any]) -> str:
|
|
207
|
+
"""Recursively extract text from ADF nodes."""
|
|
208
|
+
if not isinstance(node, dict):
|
|
209
|
+
return ""
|
|
210
|
+
|
|
211
|
+
# If this is a text node, return its text
|
|
212
|
+
if node.get("type") == "text":
|
|
213
|
+
return node.get("text", "")
|
|
214
|
+
|
|
215
|
+
# If this node has content, process it recursively
|
|
216
|
+
content = node.get("content", [])
|
|
217
|
+
if isinstance(content, list):
|
|
218
|
+
return "".join(extract_text_recursive(child) for child in content)
|
|
219
|
+
|
|
220
|
+
return ""
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
return extract_text_recursive(adf_content)
|
|
224
|
+
except Exception as e:
|
|
225
|
+
logger.warning(f"Failed to extract text from ADF: {e}")
|
|
226
|
+
return str(adf_content) if adf_content else ""
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def convert_to_adf(text: str) -> dict[str, Any]:
|
|
230
|
+
"""Convert plain text to Atlassian Document Format (ADF).
|
|
231
|
+
|
|
232
|
+
ADF is required for JIRA Cloud description fields.
|
|
233
|
+
This creates a simple document with paragraphs for each line.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
----
|
|
237
|
+
text: Plain text to convert
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
-------
|
|
241
|
+
ADF document dictionary
|
|
242
|
+
|
|
243
|
+
"""
|
|
244
|
+
if not text:
|
|
245
|
+
return {"type": "doc", "version": 1, "content": []}
|
|
246
|
+
|
|
247
|
+
# Split text into lines and create paragraphs
|
|
248
|
+
lines = text.split("\n")
|
|
249
|
+
content = []
|
|
250
|
+
|
|
251
|
+
for line in lines:
|
|
252
|
+
if line.strip(): # Non-empty line
|
|
253
|
+
content.append(
|
|
254
|
+
{"type": "paragraph", "content": [{"type": "text", "text": line}]}
|
|
255
|
+
)
|
|
256
|
+
else: # Empty line becomes empty paragraph
|
|
257
|
+
content.append({"type": "paragraph", "content": []})
|
|
258
|
+
|
|
259
|
+
return {"type": "doc", "version": 1, "content": content}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def convert_from_adf(adf_content: Any) -> str:
|
|
263
|
+
"""Convert Atlassian Document Format (ADF) to plain text.
|
|
264
|
+
|
|
265
|
+
This extracts text content from ADF structure for display.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
----
|
|
269
|
+
adf_content: ADF document or plain string
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
-------
|
|
273
|
+
Plain text string
|
|
274
|
+
|
|
275
|
+
"""
|
|
276
|
+
if not adf_content:
|
|
277
|
+
return ""
|
|
278
|
+
|
|
279
|
+
# If it's already a string, return it (JIRA Server)
|
|
280
|
+
if isinstance(adf_content, str):
|
|
281
|
+
return adf_content
|
|
282
|
+
|
|
283
|
+
# Handle ADF structure
|
|
284
|
+
if not isinstance(adf_content, dict):
|
|
285
|
+
return str(adf_content)
|
|
286
|
+
|
|
287
|
+
content_nodes = adf_content.get("content", [])
|
|
288
|
+
lines = []
|
|
289
|
+
|
|
290
|
+
for node in content_nodes:
|
|
291
|
+
if node.get("type") == "paragraph":
|
|
292
|
+
paragraph_text = ""
|
|
293
|
+
for content_item in node.get("content", []):
|
|
294
|
+
if content_item.get("type") == "text":
|
|
295
|
+
paragraph_text += content_item.get("text", "")
|
|
296
|
+
lines.append(paragraph_text)
|
|
297
|
+
elif node.get("type") == "heading":
|
|
298
|
+
heading_text = ""
|
|
299
|
+
for content_item in node.get("content", []):
|
|
300
|
+
if content_item.get("type") == "text":
|
|
301
|
+
heading_text += content_item.get("text", "")
|
|
302
|
+
lines.append(heading_text)
|
|
303
|
+
|
|
304
|
+
return "\n".join(lines)
|