titan-cli 0.1.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.
- titan_cli/__init__.py +3 -0
- titan_cli/__main__.py +4 -0
- titan_cli/ai/__init__.py +0 -0
- titan_cli/ai/agents/__init__.py +15 -0
- titan_cli/ai/agents/base.py +152 -0
- titan_cli/ai/client.py +170 -0
- titan_cli/ai/constants.py +56 -0
- titan_cli/ai/exceptions.py +48 -0
- titan_cli/ai/models.py +34 -0
- titan_cli/ai/oauth_helper.py +120 -0
- titan_cli/ai/providers/__init__.py +9 -0
- titan_cli/ai/providers/anthropic.py +117 -0
- titan_cli/ai/providers/base.py +75 -0
- titan_cli/ai/providers/gemini.py +278 -0
- titan_cli/cli.py +59 -0
- titan_cli/clients/__init__.py +1 -0
- titan_cli/clients/gcloud_client.py +52 -0
- titan_cli/core/__init__.py +3 -0
- titan_cli/core/config.py +274 -0
- titan_cli/core/discovery.py +51 -0
- titan_cli/core/errors.py +81 -0
- titan_cli/core/models.py +52 -0
- titan_cli/core/plugins/available.py +36 -0
- titan_cli/core/plugins/models.py +67 -0
- titan_cli/core/plugins/plugin_base.py +108 -0
- titan_cli/core/plugins/plugin_registry.py +163 -0
- titan_cli/core/secrets.py +141 -0
- titan_cli/core/workflows/__init__.py +22 -0
- titan_cli/core/workflows/models.py +88 -0
- titan_cli/core/workflows/project_step_source.py +86 -0
- titan_cli/core/workflows/workflow_exceptions.py +17 -0
- titan_cli/core/workflows/workflow_filter_service.py +137 -0
- titan_cli/core/workflows/workflow_registry.py +419 -0
- titan_cli/core/workflows/workflow_sources.py +307 -0
- titan_cli/engine/__init__.py +39 -0
- titan_cli/engine/builder.py +159 -0
- titan_cli/engine/context.py +82 -0
- titan_cli/engine/mock_context.py +176 -0
- titan_cli/engine/results.py +91 -0
- titan_cli/engine/steps/ai_assistant_step.py +185 -0
- titan_cli/engine/steps/command_step.py +93 -0
- titan_cli/engine/utils/__init__.py +3 -0
- titan_cli/engine/utils/venv.py +31 -0
- titan_cli/engine/workflow_executor.py +187 -0
- titan_cli/external_cli/__init__.py +0 -0
- titan_cli/external_cli/configs.py +17 -0
- titan_cli/external_cli/launcher.py +65 -0
- titan_cli/messages.py +121 -0
- titan_cli/ui/tui/__init__.py +205 -0
- titan_cli/ui/tui/__previews__/statusbar_preview.py +88 -0
- titan_cli/ui/tui/app.py +113 -0
- titan_cli/ui/tui/icons.py +70 -0
- titan_cli/ui/tui/screens/__init__.py +24 -0
- titan_cli/ui/tui/screens/ai_config.py +498 -0
- titan_cli/ui/tui/screens/ai_config_wizard.py +882 -0
- titan_cli/ui/tui/screens/base.py +110 -0
- titan_cli/ui/tui/screens/cli_launcher.py +151 -0
- titan_cli/ui/tui/screens/global_setup_wizard.py +363 -0
- titan_cli/ui/tui/screens/main_menu.py +162 -0
- titan_cli/ui/tui/screens/plugin_config_wizard.py +550 -0
- titan_cli/ui/tui/screens/plugin_management.py +377 -0
- titan_cli/ui/tui/screens/project_setup_wizard.py +686 -0
- titan_cli/ui/tui/screens/workflow_execution.py +592 -0
- titan_cli/ui/tui/screens/workflows.py +249 -0
- titan_cli/ui/tui/textual_components.py +537 -0
- titan_cli/ui/tui/textual_workflow_executor.py +405 -0
- titan_cli/ui/tui/theme.py +102 -0
- titan_cli/ui/tui/widgets/__init__.py +40 -0
- titan_cli/ui/tui/widgets/button.py +108 -0
- titan_cli/ui/tui/widgets/header.py +116 -0
- titan_cli/ui/tui/widgets/panel.py +81 -0
- titan_cli/ui/tui/widgets/status_bar.py +115 -0
- titan_cli/ui/tui/widgets/table.py +77 -0
- titan_cli/ui/tui/widgets/text.py +177 -0
- titan_cli/utils/__init__.py +0 -0
- titan_cli/utils/autoupdate.py +155 -0
- titan_cli-0.1.0.dist-info/METADATA +149 -0
- titan_cli-0.1.0.dist-info/RECORD +146 -0
- titan_cli-0.1.0.dist-info/WHEEL +4 -0
- titan_cli-0.1.0.dist-info/entry_points.txt +9 -0
- titan_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- titan_plugin_git/__init__.py +1 -0
- titan_plugin_git/clients/__init__.py +8 -0
- titan_plugin_git/clients/git_client.py +772 -0
- titan_plugin_git/exceptions.py +40 -0
- titan_plugin_git/messages.py +112 -0
- titan_plugin_git/models.py +39 -0
- titan_plugin_git/plugin.py +118 -0
- titan_plugin_git/steps/__init__.py +1 -0
- titan_plugin_git/steps/ai_commit_message_step.py +171 -0
- titan_plugin_git/steps/branch_steps.py +104 -0
- titan_plugin_git/steps/commit_step.py +80 -0
- titan_plugin_git/steps/push_step.py +63 -0
- titan_plugin_git/steps/status_step.py +59 -0
- titan_plugin_git/workflows/__previews__/__init__.py +1 -0
- titan_plugin_git/workflows/__previews__/commit_ai_preview.py +124 -0
- titan_plugin_git/workflows/commit-ai.yaml +28 -0
- titan_plugin_github/__init__.py +11 -0
- titan_plugin_github/agents/__init__.py +6 -0
- titan_plugin_github/agents/config_loader.py +130 -0
- titan_plugin_github/agents/issue_generator.py +353 -0
- titan_plugin_github/agents/pr_agent.py +528 -0
- titan_plugin_github/clients/__init__.py +8 -0
- titan_plugin_github/clients/github_client.py +1105 -0
- titan_plugin_github/config/__init__.py +0 -0
- titan_plugin_github/config/pr_agent.toml +85 -0
- titan_plugin_github/exceptions.py +28 -0
- titan_plugin_github/messages.py +88 -0
- titan_plugin_github/models.py +330 -0
- titan_plugin_github/plugin.py +131 -0
- titan_plugin_github/steps/__init__.py +12 -0
- titan_plugin_github/steps/ai_pr_step.py +172 -0
- titan_plugin_github/steps/create_pr_step.py +86 -0
- titan_plugin_github/steps/github_prompt_steps.py +171 -0
- titan_plugin_github/steps/issue_steps.py +143 -0
- titan_plugin_github/steps/preview_step.py +40 -0
- titan_plugin_github/utils.py +82 -0
- titan_plugin_github/workflows/__previews__/__init__.py +1 -0
- titan_plugin_github/workflows/__previews__/create_pr_ai_preview.py +140 -0
- titan_plugin_github/workflows/create-issue-ai.yaml +32 -0
- titan_plugin_github/workflows/create-pr-ai.yaml +49 -0
- titan_plugin_jira/__init__.py +8 -0
- titan_plugin_jira/agents/__init__.py +6 -0
- titan_plugin_jira/agents/config_loader.py +154 -0
- titan_plugin_jira/agents/jira_agent.py +553 -0
- titan_plugin_jira/agents/prompts.py +364 -0
- titan_plugin_jira/agents/response_parser.py +435 -0
- titan_plugin_jira/agents/token_tracker.py +223 -0
- titan_plugin_jira/agents/validators.py +246 -0
- titan_plugin_jira/clients/jira_client.py +745 -0
- titan_plugin_jira/config/jira_agent.toml +92 -0
- titan_plugin_jira/config/templates/issue_analysis.md.j2 +78 -0
- titan_plugin_jira/exceptions.py +37 -0
- titan_plugin_jira/formatters/__init__.py +6 -0
- titan_plugin_jira/formatters/markdown_formatter.py +245 -0
- titan_plugin_jira/messages.py +115 -0
- titan_plugin_jira/models.py +89 -0
- titan_plugin_jira/plugin.py +264 -0
- titan_plugin_jira/steps/ai_analyze_issue_step.py +105 -0
- titan_plugin_jira/steps/get_issue_step.py +82 -0
- titan_plugin_jira/steps/prompt_select_issue_step.py +80 -0
- titan_plugin_jira/steps/search_saved_query_step.py +238 -0
- titan_plugin_jira/utils/__init__.py +13 -0
- titan_plugin_jira/utils/issue_sorter.py +140 -0
- titan_plugin_jira/utils/saved_queries.py +150 -0
- titan_plugin_jira/workflows/analyze-jira-issues.yaml +34 -0
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
JIRA REST API Client
|
|
4
|
+
|
|
5
|
+
Complete Python SDK for JIRA REST API v2 (JIRA Server compatible)
|
|
6
|
+
No external dependencies beyond requests.
|
|
7
|
+
|
|
8
|
+
Migrated from: /Users/rpedraza/MultiAgentClaude/src/jira/api_client.py
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Dict, List, Optional, Any, Union
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from ..models import (
|
|
17
|
+
JiraProject,
|
|
18
|
+
JiraIssueType,
|
|
19
|
+
JiraTransition,
|
|
20
|
+
JiraComment,
|
|
21
|
+
JiraTicket,
|
|
22
|
+
)
|
|
23
|
+
from ..exceptions import JiraAPIError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class JiraClient:
|
|
27
|
+
"""
|
|
28
|
+
JIRA REST API v2 Client (for JIRA Server)
|
|
29
|
+
|
|
30
|
+
Provides direct HTTP access to JIRA without external CLI dependencies.
|
|
31
|
+
Supports JIRA Server 9.x with Personal Access Token (Bearer) authentication.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, base_url: str, email: str, api_token: str,
|
|
35
|
+
project_key: Optional[str] = None, timeout: int = 30,
|
|
36
|
+
enable_cache: bool = True, cache_ttl: int = 300):
|
|
37
|
+
"""
|
|
38
|
+
Initialize JIRA client
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
base_url: JIRA instance URL
|
|
42
|
+
email: User email for authentication
|
|
43
|
+
api_token: JIRA API token (Personal Access Token)
|
|
44
|
+
project_key: Default project key (optional)
|
|
45
|
+
timeout: Request timeout in seconds
|
|
46
|
+
enable_cache: Enable in-memory caching (default: True)
|
|
47
|
+
cache_ttl: Cache time-to-live in seconds (default: 300 = 5 minutes)
|
|
48
|
+
|
|
49
|
+
Note:
|
|
50
|
+
JIRA Server/Next uses Basic Auth with Personal Access Token.
|
|
51
|
+
For JIRA Cloud: API tokens can be created at https://id.atlassian.com/manage/api-tokens
|
|
52
|
+
"""
|
|
53
|
+
self.base_url = base_url.rstrip("/")
|
|
54
|
+
self.email = email
|
|
55
|
+
self.api_token = api_token
|
|
56
|
+
self.project_key = project_key
|
|
57
|
+
self.timeout = timeout
|
|
58
|
+
|
|
59
|
+
if not self.api_token:
|
|
60
|
+
raise JiraAPIError("JIRA API token not provided")
|
|
61
|
+
|
|
62
|
+
if not self.base_url:
|
|
63
|
+
raise JiraAPIError("JIRA base URL not provided")
|
|
64
|
+
|
|
65
|
+
if not self.email:
|
|
66
|
+
raise JiraAPIError("JIRA user email not provided")
|
|
67
|
+
|
|
68
|
+
self.session = requests.Session()
|
|
69
|
+
# Use Bearer Auth for JIRA Server/Next with Personal Access Token
|
|
70
|
+
self.session.headers.update({
|
|
71
|
+
"Accept": "application/json",
|
|
72
|
+
"Authorization": f"Bearer {self.api_token}"
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
# Cache disabled for now (TODO: implement JiraCache)
|
|
76
|
+
self._cache = None
|
|
77
|
+
|
|
78
|
+
def _make_request(self, method: str, endpoint: str, **kwargs) -> Union[Dict, List]:
|
|
79
|
+
"""Make HTTP request to JIRA API"""
|
|
80
|
+
# JIRA Server uses API v2
|
|
81
|
+
url = f"{self.base_url}/rest/api/2/{endpoint.lstrip('/')}"
|
|
82
|
+
|
|
83
|
+
# Add Content-Type only for POST/PUT/PATCH (not GET/DELETE)
|
|
84
|
+
if method.upper() in ('POST', 'PUT', 'PATCH') and 'json' in kwargs:
|
|
85
|
+
headers = kwargs.get('headers', {})
|
|
86
|
+
headers['Content-Type'] = 'application/json'
|
|
87
|
+
kwargs['headers'] = headers
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
response = self.session.request(method, url, timeout=self.timeout, **kwargs)
|
|
91
|
+
|
|
92
|
+
if response.status_code == 204:
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
response.raise_for_status()
|
|
96
|
+
return response.json() if response.content else {}
|
|
97
|
+
|
|
98
|
+
except requests.exceptions.HTTPError as e:
|
|
99
|
+
error_msg = f"JIRA API error: {e}"
|
|
100
|
+
try:
|
|
101
|
+
error_detail = e.response.json()
|
|
102
|
+
error_msg = f"{error_msg}\nDetails: {json.dumps(error_detail, indent=2)}"
|
|
103
|
+
except (ValueError, AttributeError):
|
|
104
|
+
# If not JSON, show raw text
|
|
105
|
+
error_msg = f"{error_msg}\nResponse: {e.response.text[:500]}"
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
response_json = e.response.json() if e.response.content else None
|
|
109
|
+
except (ValueError, AttributeError):
|
|
110
|
+
response_json = None
|
|
111
|
+
|
|
112
|
+
raise JiraAPIError(error_msg, status_code=e.response.status_code, response=response_json)
|
|
113
|
+
|
|
114
|
+
except requests.exceptions.RequestException as e:
|
|
115
|
+
raise JiraAPIError(f"Request failed: {e}")
|
|
116
|
+
|
|
117
|
+
# ==================== USER OPERATIONS ====================
|
|
118
|
+
|
|
119
|
+
def get_current_user(self) -> Dict[str, Any]:
|
|
120
|
+
"""
|
|
121
|
+
Get current authenticated user information.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
User information including displayName, emailAddress, accountId, etc.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
JiraAPIError: If authentication fails or API request fails
|
|
128
|
+
"""
|
|
129
|
+
return self._make_request('GET', 'myself')
|
|
130
|
+
|
|
131
|
+
# ==================== TICKET OPERATIONS ====================
|
|
132
|
+
|
|
133
|
+
def get_ticket(self, ticket_key: str, expand: Optional[List[str]] = None) -> JiraTicket:
|
|
134
|
+
"""
|
|
135
|
+
Get ticket details
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
ticket_key: Ticket key (e.g., "PROJ-123")
|
|
139
|
+
expand: Additional fields to expand (e.g., ["changelog", "renderedFields"])
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
JiraTicket object
|
|
143
|
+
"""
|
|
144
|
+
params = {}
|
|
145
|
+
if expand:
|
|
146
|
+
params["expand"] = ",".join(expand)
|
|
147
|
+
|
|
148
|
+
data = self._make_request("GET", f"issue/{ticket_key}", params=params)
|
|
149
|
+
|
|
150
|
+
fields = data.get("fields", {})
|
|
151
|
+
|
|
152
|
+
return JiraTicket(
|
|
153
|
+
key=data["key"],
|
|
154
|
+
id=data["id"],
|
|
155
|
+
summary=fields.get("summary", ""),
|
|
156
|
+
description=fields.get("description"),
|
|
157
|
+
status=fields.get("status", {}).get("name", "Unknown"),
|
|
158
|
+
issue_type=fields.get("issuetype", {}).get("name", "Unknown"),
|
|
159
|
+
assignee=fields.get("assignee", {}).get("displayName") if fields.get("assignee") else None,
|
|
160
|
+
reporter=fields.get("reporter", {}).get("displayName", "Unknown"),
|
|
161
|
+
priority=fields.get("priority", {}).get("name", "Unknown"),
|
|
162
|
+
created=fields.get("created", ""),
|
|
163
|
+
updated=fields.get("updated", ""),
|
|
164
|
+
labels=fields.get("labels", []),
|
|
165
|
+
components=[c.get("name", "") for c in fields.get("components", [])],
|
|
166
|
+
fix_versions=[v.get("name", "") for v in fields.get("fixVersions", [])],
|
|
167
|
+
raw=data
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def search_tickets(self, jql: str, max_results: int = 50, fields: Optional[List[str]] = None) -> List[JiraTicket]:
|
|
171
|
+
"""
|
|
172
|
+
Search tickets using JQL
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
jql: JQL query string
|
|
176
|
+
max_results: Maximum number of results
|
|
177
|
+
fields: List of fields to return
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
List of JiraTicket objects
|
|
181
|
+
"""
|
|
182
|
+
payload = {
|
|
183
|
+
"jql": jql,
|
|
184
|
+
"maxResults": max_results,
|
|
185
|
+
"fields": fields or ["summary", "status", "assignee", "priority", "created", "updated"]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
data = self._make_request("POST", "search", json=payload)
|
|
189
|
+
|
|
190
|
+
tickets = []
|
|
191
|
+
for issue in data.get("issues", []):
|
|
192
|
+
fields_data = issue.get("fields", {})
|
|
193
|
+
tickets.append(JiraTicket(
|
|
194
|
+
key=issue["key"],
|
|
195
|
+
id=issue["id"],
|
|
196
|
+
summary=fields_data.get("summary", ""),
|
|
197
|
+
description=fields_data.get("description"),
|
|
198
|
+
status=(fields_data.get("status") or {}).get("name", "Unknown"),
|
|
199
|
+
issue_type=(fields_data.get("issuetype") or {}).get("name", "Unknown"),
|
|
200
|
+
assignee=(fields_data.get("assignee") or {}).get("displayName") if fields_data.get("assignee") else None,
|
|
201
|
+
reporter=(fields_data.get("reporter") or {}).get("displayName", "Unknown"),
|
|
202
|
+
priority=(fields_data.get("priority") or {}).get("name", "Unknown"),
|
|
203
|
+
created=fields_data.get("created", ""),
|
|
204
|
+
updated=fields_data.get("updated", ""),
|
|
205
|
+
labels=fields_data.get("labels", []),
|
|
206
|
+
components=[c.get("name", "") for c in fields_data.get("components", [])],
|
|
207
|
+
fix_versions=[v.get("name", "") for v in fields_data.get("fixVersions", [])],
|
|
208
|
+
raw=issue
|
|
209
|
+
))
|
|
210
|
+
|
|
211
|
+
return tickets
|
|
212
|
+
|
|
213
|
+
def update_ticket_status(self, ticket_key: str, new_status: str, comment: Optional[str] = None) -> Dict[str, Any]:
|
|
214
|
+
"""
|
|
215
|
+
Update ticket status using transitions
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
ticket_key: Ticket key
|
|
219
|
+
new_status: Target status name
|
|
220
|
+
comment: Optional comment to add with transition
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
Result dictionary
|
|
224
|
+
"""
|
|
225
|
+
# Get available transitions
|
|
226
|
+
transitions = self.get_transitions(ticket_key)
|
|
227
|
+
|
|
228
|
+
# Find transition to target status
|
|
229
|
+
transition_id = None
|
|
230
|
+
for trans in transitions:
|
|
231
|
+
if trans.to_status.lower() == new_status.lower():
|
|
232
|
+
transition_id = trans.id
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
if not transition_id:
|
|
236
|
+
available = [t.to_status for t in transitions]
|
|
237
|
+
raise JiraAPIError(
|
|
238
|
+
f"Cannot transition to '{new_status}'. Available transitions: {', '.join(available)}"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
payload = {
|
|
242
|
+
"transition": {"id": transition_id}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
# Add comment if provided
|
|
246
|
+
if comment:
|
|
247
|
+
payload["update"] = {
|
|
248
|
+
"comment": [{
|
|
249
|
+
"add": {
|
|
250
|
+
"body": {
|
|
251
|
+
"type": "doc",
|
|
252
|
+
"version": 1,
|
|
253
|
+
"content": [{
|
|
254
|
+
"type": "paragraph",
|
|
255
|
+
"content": [{"type": "text", "text": comment}]
|
|
256
|
+
}]
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}]
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
self._make_request("POST", f"issue/{ticket_key}/transitions", json=payload)
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
"ticket_key": ticket_key,
|
|
266
|
+
"new_status": new_status,
|
|
267
|
+
"transition_id": transition_id
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
def get_transitions(self, ticket_key: str) -> List[JiraTransition]:
|
|
271
|
+
"""
|
|
272
|
+
Get available transitions for a ticket
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
ticket_key: Ticket key
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
List of available transitions
|
|
279
|
+
"""
|
|
280
|
+
data = self._make_request("GET", f"issue/{ticket_key}/transitions")
|
|
281
|
+
|
|
282
|
+
transitions = []
|
|
283
|
+
for trans in data.get("transitions", []):
|
|
284
|
+
transitions.append(JiraTransition(
|
|
285
|
+
id=trans["id"],
|
|
286
|
+
name=trans["name"],
|
|
287
|
+
to_status=trans.get("to", {}).get("name", trans["name"])
|
|
288
|
+
))
|
|
289
|
+
|
|
290
|
+
return transitions
|
|
291
|
+
|
|
292
|
+
# ==================== COMMENT OPERATIONS ====================
|
|
293
|
+
|
|
294
|
+
def add_comment(self, ticket_key: str, body: str) -> JiraComment:
|
|
295
|
+
"""
|
|
296
|
+
Add comment to ticket
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
ticket_key: Ticket key
|
|
300
|
+
body: Comment text
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Created comment
|
|
304
|
+
"""
|
|
305
|
+
payload = {
|
|
306
|
+
"body": {
|
|
307
|
+
"type": "doc",
|
|
308
|
+
"version": 1,
|
|
309
|
+
"content": [
|
|
310
|
+
{
|
|
311
|
+
"type": "paragraph",
|
|
312
|
+
"content": [
|
|
313
|
+
{
|
|
314
|
+
"type": "text",
|
|
315
|
+
"text": body
|
|
316
|
+
}
|
|
317
|
+
]
|
|
318
|
+
}
|
|
319
|
+
]
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
data = self._make_request("POST", f"issue/{ticket_key}/comment", json=payload)
|
|
324
|
+
|
|
325
|
+
return JiraComment(
|
|
326
|
+
id=data["id"],
|
|
327
|
+
author=data.get("author", {}).get("displayName", "Unknown"),
|
|
328
|
+
body=body,
|
|
329
|
+
created=data.get("created", ""),
|
|
330
|
+
updated=data.get("updated")
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def get_comments(self, ticket_key: str) -> List[JiraComment]:
|
|
334
|
+
"""
|
|
335
|
+
Get all comments for a ticket
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
ticket_key: Ticket key
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
List of comments
|
|
342
|
+
"""
|
|
343
|
+
data = self._make_request("GET", f"issue/{ticket_key}/comment")
|
|
344
|
+
|
|
345
|
+
comments = []
|
|
346
|
+
for comment in data.get("comments", []):
|
|
347
|
+
# Extract text from Atlassian Document Format
|
|
348
|
+
body_text = self._extract_text_from_adf(comment.get("body", {}))
|
|
349
|
+
|
|
350
|
+
comments.append(JiraComment(
|
|
351
|
+
id=comment["id"],
|
|
352
|
+
author=comment.get("author", {}).get("displayName", "Unknown"),
|
|
353
|
+
body=body_text,
|
|
354
|
+
created=comment.get("created", ""),
|
|
355
|
+
updated=comment.get("updated")
|
|
356
|
+
))
|
|
357
|
+
|
|
358
|
+
return comments
|
|
359
|
+
|
|
360
|
+
def _extract_text_from_adf(self, adf: Dict) -> str:
|
|
361
|
+
"""Extract plain text from Atlassian Document Format"""
|
|
362
|
+
if not adf:
|
|
363
|
+
return ""
|
|
364
|
+
|
|
365
|
+
text_parts = []
|
|
366
|
+
|
|
367
|
+
def extract_recursive(node):
|
|
368
|
+
if isinstance(node, dict):
|
|
369
|
+
if node.get("type") == "text":
|
|
370
|
+
text_parts.append(node.get("text", ""))
|
|
371
|
+
|
|
372
|
+
if "content" in node:
|
|
373
|
+
for child in node["content"]:
|
|
374
|
+
extract_recursive(child)
|
|
375
|
+
|
|
376
|
+
extract_recursive(adf)
|
|
377
|
+
return " ".join(text_parts)
|
|
378
|
+
|
|
379
|
+
# ==================== LINK OPERATIONS ====================
|
|
380
|
+
|
|
381
|
+
def link_issue(self, inward_issue: str, outward_issue: str, link_type: str = "Relates") -> Dict[str, Any]:
|
|
382
|
+
"""
|
|
383
|
+
Create link between two issues
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
inward_issue: Source issue key
|
|
387
|
+
outward_issue: Target issue key
|
|
388
|
+
link_type: Link type name (e.g., "Relates", "Blocks", "Duplicate")
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
Result dictionary
|
|
392
|
+
"""
|
|
393
|
+
payload = {
|
|
394
|
+
"type": {"name": link_type},
|
|
395
|
+
"inwardIssue": {"key": inward_issue},
|
|
396
|
+
"outwardIssue": {"key": outward_issue}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
self._make_request("POST", "issueLink", json=payload)
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
"inward_issue": inward_issue,
|
|
403
|
+
"outward_issue": outward_issue,
|
|
404
|
+
"link_type": link_type
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
def add_remote_link(self, ticket_key: str, url: str, title: str, relationship: str = "relates to") -> Dict[str, Any]:
|
|
408
|
+
"""
|
|
409
|
+
Add remote link (e.g., GitHub PR) to ticket
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
ticket_key: Ticket key
|
|
413
|
+
url: URL to link
|
|
414
|
+
title: Link title
|
|
415
|
+
relationship: Relationship description
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
Created link info
|
|
419
|
+
"""
|
|
420
|
+
payload = {
|
|
421
|
+
"object": {
|
|
422
|
+
"url": url,
|
|
423
|
+
"title": title
|
|
424
|
+
},
|
|
425
|
+
"relationship": relationship
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
data = self._make_request("POST", f"issue/{ticket_key}/remotelink", json=payload)
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
"ticket_key": ticket_key,
|
|
432
|
+
"url": url,
|
|
433
|
+
"title": title,
|
|
434
|
+
"link_id": data.get("id")
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
# ==================== PROJECT OPERATIONS ====================
|
|
438
|
+
|
|
439
|
+
def get_project(self, project_key: Optional[str] = None) -> Dict[str, Any]:
|
|
440
|
+
"""
|
|
441
|
+
Get project details
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
project_key: Project key (uses default if not provided)
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
Project details
|
|
448
|
+
"""
|
|
449
|
+
key = project_key or self.project_key
|
|
450
|
+
if not key:
|
|
451
|
+
raise JiraAPIError("Project key not provided")
|
|
452
|
+
|
|
453
|
+
return self._make_request("GET", f"project/{key}")
|
|
454
|
+
|
|
455
|
+
def get_issue_types(self, project_key: Optional[str] = None) -> List[JiraIssueType]:
|
|
456
|
+
"""
|
|
457
|
+
Get available issue types for project.
|
|
458
|
+
|
|
459
|
+
Results are cached for 5 minutes by default.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
project_key: Project key (uses default if not provided)
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
List of issue types
|
|
466
|
+
"""
|
|
467
|
+
key = project_key or self.project_key
|
|
468
|
+
if not key:
|
|
469
|
+
raise JiraAPIError("Project key not provided")
|
|
470
|
+
|
|
471
|
+
# Check cache
|
|
472
|
+
cache_key = f"issue_types:{key}"
|
|
473
|
+
if self._cache:
|
|
474
|
+
cached = self._cache.get(cache_key)
|
|
475
|
+
if cached is not None:
|
|
476
|
+
return cached
|
|
477
|
+
|
|
478
|
+
# Fetch from API
|
|
479
|
+
project = self.get_project(key)
|
|
480
|
+
issue_types = []
|
|
481
|
+
|
|
482
|
+
for issue_type in project.get("issueTypes", []):
|
|
483
|
+
issue_types.append(JiraIssueType(
|
|
484
|
+
id=issue_type["id"],
|
|
485
|
+
name=issue_type["name"],
|
|
486
|
+
description=issue_type.get("description"),
|
|
487
|
+
subtask=issue_type.get("subtask", False)
|
|
488
|
+
))
|
|
489
|
+
|
|
490
|
+
# Cache result
|
|
491
|
+
if self._cache:
|
|
492
|
+
self._cache.set(cache_key, issue_types)
|
|
493
|
+
|
|
494
|
+
return issue_types
|
|
495
|
+
|
|
496
|
+
def list_projects(self) -> List[JiraProject]:
|
|
497
|
+
"""
|
|
498
|
+
List all accessible JIRA projects.
|
|
499
|
+
|
|
500
|
+
Results are cached for 5 minutes by default.
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
List of JiraProject objects
|
|
504
|
+
"""
|
|
505
|
+
# Check cache
|
|
506
|
+
cache_key = "projects"
|
|
507
|
+
if self._cache:
|
|
508
|
+
cached = self._cache.get(cache_key)
|
|
509
|
+
if cached is not None:
|
|
510
|
+
return cached
|
|
511
|
+
|
|
512
|
+
# Fetch from API
|
|
513
|
+
data = self._make_request("GET", "project")
|
|
514
|
+
|
|
515
|
+
projects = []
|
|
516
|
+
for project_data in data:
|
|
517
|
+
projects.append(JiraProject(
|
|
518
|
+
id=project_data["id"],
|
|
519
|
+
key=project_data["key"],
|
|
520
|
+
name=project_data["name"],
|
|
521
|
+
description=project_data.get("description"),
|
|
522
|
+
project_type=project_data.get("projectTypeKey"),
|
|
523
|
+
lead=project_data.get("lead", {}).get("displayName")
|
|
524
|
+
))
|
|
525
|
+
|
|
526
|
+
# Cache result
|
|
527
|
+
if self._cache:
|
|
528
|
+
self._cache.set(cache_key, projects)
|
|
529
|
+
|
|
530
|
+
return projects
|
|
531
|
+
|
|
532
|
+
def get_project_by_key(self, project_key: str) -> Optional[JiraProject]:
|
|
533
|
+
"""
|
|
534
|
+
Get a specific project by key.
|
|
535
|
+
|
|
536
|
+
Results are cached for 5 minutes by default.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
project_key: Project key (e.g., "ECAPP", "JAZZ")
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
JiraProject object or None if not found
|
|
543
|
+
"""
|
|
544
|
+
# Check cache
|
|
545
|
+
cache_key = f"project:{project_key}"
|
|
546
|
+
if self._cache:
|
|
547
|
+
cached = self._cache.get(cache_key)
|
|
548
|
+
if cached is not None:
|
|
549
|
+
return cached
|
|
550
|
+
|
|
551
|
+
# Fetch from API
|
|
552
|
+
try:
|
|
553
|
+
data = self._make_request("GET", f"project/{project_key}")
|
|
554
|
+
project = JiraProject(
|
|
555
|
+
id=data["id"],
|
|
556
|
+
key=data["key"],
|
|
557
|
+
name=data["name"],
|
|
558
|
+
description=data.get("description"),
|
|
559
|
+
project_type=data.get("projectTypeKey"),
|
|
560
|
+
lead=data.get("lead", {}).get("displayName")
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
# Cache result
|
|
564
|
+
if self._cache:
|
|
565
|
+
self._cache.set(cache_key, project)
|
|
566
|
+
|
|
567
|
+
return project
|
|
568
|
+
except JiraAPIError:
|
|
569
|
+
return None
|
|
570
|
+
|
|
571
|
+
def list_statuses(self, project_key: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
572
|
+
"""
|
|
573
|
+
List all available statuses for a project.
|
|
574
|
+
|
|
575
|
+
Results are cached for 5 minutes by default.
|
|
576
|
+
|
|
577
|
+
Args:
|
|
578
|
+
project_key: Project key (uses default if not provided)
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
List of status dictionaries
|
|
582
|
+
"""
|
|
583
|
+
key = project_key or self.project_key
|
|
584
|
+
if not key:
|
|
585
|
+
raise JiraAPIError("Project key not provided")
|
|
586
|
+
|
|
587
|
+
# Check cache
|
|
588
|
+
cache_key = f"statuses:{key}"
|
|
589
|
+
if self._cache:
|
|
590
|
+
cached = self._cache.get(cache_key)
|
|
591
|
+
if cached is not None:
|
|
592
|
+
return cached
|
|
593
|
+
|
|
594
|
+
# Get all statuses for the project
|
|
595
|
+
data = self._make_request("GET", f"project/{key}/statuses")
|
|
596
|
+
|
|
597
|
+
# Extract unique statuses
|
|
598
|
+
statuses = []
|
|
599
|
+
seen_names = set()
|
|
600
|
+
|
|
601
|
+
for issue_type_data in data:
|
|
602
|
+
for status in issue_type_data.get("statuses", []):
|
|
603
|
+
status_name = status.get("name")
|
|
604
|
+
if status_name and status_name not in seen_names:
|
|
605
|
+
statuses.append({
|
|
606
|
+
"id": status.get("id"),
|
|
607
|
+
"name": status_name,
|
|
608
|
+
"description": status.get("description"),
|
|
609
|
+
"category": status.get("statusCategory", {}).get("name")
|
|
610
|
+
})
|
|
611
|
+
seen_names.add(status_name)
|
|
612
|
+
|
|
613
|
+
# Cache result
|
|
614
|
+
if self._cache:
|
|
615
|
+
self._cache.set(cache_key, statuses)
|
|
616
|
+
|
|
617
|
+
return statuses
|
|
618
|
+
|
|
619
|
+
# ==================== SUBTASK OPERATIONS ====================
|
|
620
|
+
|
|
621
|
+
def create_subtask(self, parent_key: str, summary: str, description: Optional[str] = None) -> JiraTicket:
|
|
622
|
+
"""
|
|
623
|
+
Create subtask under parent issue
|
|
624
|
+
|
|
625
|
+
Args:
|
|
626
|
+
parent_key: Parent issue key
|
|
627
|
+
summary: Subtask summary
|
|
628
|
+
description: Subtask description
|
|
629
|
+
|
|
630
|
+
Returns:
|
|
631
|
+
Created subtask
|
|
632
|
+
|
|
633
|
+
Raises:
|
|
634
|
+
JiraAPIError: If no default project is configured
|
|
635
|
+
"""
|
|
636
|
+
if not self.project_key:
|
|
637
|
+
raise JiraAPIError(
|
|
638
|
+
"No default project configured. "
|
|
639
|
+
"Please set default_project in JIRA plugin configuration."
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
# Get subtask issue type
|
|
643
|
+
issue_types = self.get_issue_types()
|
|
644
|
+
subtask_type = next((it for it in issue_types if it.subtask), None)
|
|
645
|
+
|
|
646
|
+
if not subtask_type:
|
|
647
|
+
raise JiraAPIError("No subtask issue type found for project")
|
|
648
|
+
|
|
649
|
+
payload = {
|
|
650
|
+
"fields": {
|
|
651
|
+
"project": {"key": self.project_key},
|
|
652
|
+
"parent": {"key": parent_key},
|
|
653
|
+
"summary": summary,
|
|
654
|
+
"issuetype": {"id": subtask_type.id}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if description:
|
|
659
|
+
payload["fields"]["description"] = {
|
|
660
|
+
"type": "doc",
|
|
661
|
+
"version": 1,
|
|
662
|
+
"content": [
|
|
663
|
+
{
|
|
664
|
+
"type": "paragraph",
|
|
665
|
+
"content": [{"type": "text", "text": description}]
|
|
666
|
+
}
|
|
667
|
+
]
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
data = self._make_request("POST", "issue", json=payload)
|
|
671
|
+
|
|
672
|
+
return self.get_ticket(data["key"])
|
|
673
|
+
|
|
674
|
+
def create_issue(self, issue_type: str, summary: str, description: Optional[str] = None,
|
|
675
|
+
project: Optional[str] = None, assignee: Optional[str] = None,
|
|
676
|
+
labels: Optional[List[str]] = None, priority: Optional[str] = None) -> JiraTicket:
|
|
677
|
+
"""
|
|
678
|
+
Create new JIRA issue
|
|
679
|
+
|
|
680
|
+
Args:
|
|
681
|
+
issue_type: Issue type name (Bug, Story, Task, etc.)
|
|
682
|
+
summary: Issue summary/title
|
|
683
|
+
description: Issue description
|
|
684
|
+
project: Project key (uses default if not provided)
|
|
685
|
+
assignee: Assignee username or email
|
|
686
|
+
labels: List of labels
|
|
687
|
+
priority: Priority name
|
|
688
|
+
|
|
689
|
+
Returns:
|
|
690
|
+
Created issue
|
|
691
|
+
"""
|
|
692
|
+
project_key = project or self.project_key
|
|
693
|
+
if not project_key:
|
|
694
|
+
raise JiraAPIError("Project key not provided")
|
|
695
|
+
|
|
696
|
+
# Get issue type ID
|
|
697
|
+
issue_types = self.get_issue_types(project_key)
|
|
698
|
+
issue_type_obj = next((it for it in issue_types if it.name.lower() == issue_type.lower()), None)
|
|
699
|
+
|
|
700
|
+
if not issue_type_obj:
|
|
701
|
+
available = [it.name for it in issue_types]
|
|
702
|
+
raise JiraAPIError(
|
|
703
|
+
f"Issue type '{issue_type}' not found. Available: {', '.join(available)}"
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
payload = {
|
|
707
|
+
"fields": {
|
|
708
|
+
"project": {"key": project_key},
|
|
709
|
+
"summary": summary,
|
|
710
|
+
"issuetype": {"id": issue_type_obj.id}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
# Add description if provided
|
|
715
|
+
if description:
|
|
716
|
+
payload["fields"]["description"] = {
|
|
717
|
+
"type": "doc",
|
|
718
|
+
"version": 1,
|
|
719
|
+
"content": [
|
|
720
|
+
{
|
|
721
|
+
"type": "paragraph",
|
|
722
|
+
"content": [{"type": "text", "text": description}]
|
|
723
|
+
}
|
|
724
|
+
]
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
# Add optional fields
|
|
728
|
+
if assignee:
|
|
729
|
+
payload["fields"]["assignee"] = {"name": assignee}
|
|
730
|
+
|
|
731
|
+
if labels:
|
|
732
|
+
payload["fields"]["labels"] = labels
|
|
733
|
+
|
|
734
|
+
if priority:
|
|
735
|
+
payload["fields"]["priority"] = {"name": priority}
|
|
736
|
+
|
|
737
|
+
data = self._make_request("POST", "issue", json=payload)
|
|
738
|
+
|
|
739
|
+
return self.get_ticket(data["key"])
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
# Export public API
|
|
743
|
+
__all__ = [
|
|
744
|
+
"JiraClient",
|
|
745
|
+
]
|