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.
Files changed (146) hide show
  1. titan_cli/__init__.py +3 -0
  2. titan_cli/__main__.py +4 -0
  3. titan_cli/ai/__init__.py +0 -0
  4. titan_cli/ai/agents/__init__.py +15 -0
  5. titan_cli/ai/agents/base.py +152 -0
  6. titan_cli/ai/client.py +170 -0
  7. titan_cli/ai/constants.py +56 -0
  8. titan_cli/ai/exceptions.py +48 -0
  9. titan_cli/ai/models.py +34 -0
  10. titan_cli/ai/oauth_helper.py +120 -0
  11. titan_cli/ai/providers/__init__.py +9 -0
  12. titan_cli/ai/providers/anthropic.py +117 -0
  13. titan_cli/ai/providers/base.py +75 -0
  14. titan_cli/ai/providers/gemini.py +278 -0
  15. titan_cli/cli.py +59 -0
  16. titan_cli/clients/__init__.py +1 -0
  17. titan_cli/clients/gcloud_client.py +52 -0
  18. titan_cli/core/__init__.py +3 -0
  19. titan_cli/core/config.py +274 -0
  20. titan_cli/core/discovery.py +51 -0
  21. titan_cli/core/errors.py +81 -0
  22. titan_cli/core/models.py +52 -0
  23. titan_cli/core/plugins/available.py +36 -0
  24. titan_cli/core/plugins/models.py +67 -0
  25. titan_cli/core/plugins/plugin_base.py +108 -0
  26. titan_cli/core/plugins/plugin_registry.py +163 -0
  27. titan_cli/core/secrets.py +141 -0
  28. titan_cli/core/workflows/__init__.py +22 -0
  29. titan_cli/core/workflows/models.py +88 -0
  30. titan_cli/core/workflows/project_step_source.py +86 -0
  31. titan_cli/core/workflows/workflow_exceptions.py +17 -0
  32. titan_cli/core/workflows/workflow_filter_service.py +137 -0
  33. titan_cli/core/workflows/workflow_registry.py +419 -0
  34. titan_cli/core/workflows/workflow_sources.py +307 -0
  35. titan_cli/engine/__init__.py +39 -0
  36. titan_cli/engine/builder.py +159 -0
  37. titan_cli/engine/context.py +82 -0
  38. titan_cli/engine/mock_context.py +176 -0
  39. titan_cli/engine/results.py +91 -0
  40. titan_cli/engine/steps/ai_assistant_step.py +185 -0
  41. titan_cli/engine/steps/command_step.py +93 -0
  42. titan_cli/engine/utils/__init__.py +3 -0
  43. titan_cli/engine/utils/venv.py +31 -0
  44. titan_cli/engine/workflow_executor.py +187 -0
  45. titan_cli/external_cli/__init__.py +0 -0
  46. titan_cli/external_cli/configs.py +17 -0
  47. titan_cli/external_cli/launcher.py +65 -0
  48. titan_cli/messages.py +121 -0
  49. titan_cli/ui/tui/__init__.py +205 -0
  50. titan_cli/ui/tui/__previews__/statusbar_preview.py +88 -0
  51. titan_cli/ui/tui/app.py +113 -0
  52. titan_cli/ui/tui/icons.py +70 -0
  53. titan_cli/ui/tui/screens/__init__.py +24 -0
  54. titan_cli/ui/tui/screens/ai_config.py +498 -0
  55. titan_cli/ui/tui/screens/ai_config_wizard.py +882 -0
  56. titan_cli/ui/tui/screens/base.py +110 -0
  57. titan_cli/ui/tui/screens/cli_launcher.py +151 -0
  58. titan_cli/ui/tui/screens/global_setup_wizard.py +363 -0
  59. titan_cli/ui/tui/screens/main_menu.py +162 -0
  60. titan_cli/ui/tui/screens/plugin_config_wizard.py +550 -0
  61. titan_cli/ui/tui/screens/plugin_management.py +377 -0
  62. titan_cli/ui/tui/screens/project_setup_wizard.py +686 -0
  63. titan_cli/ui/tui/screens/workflow_execution.py +592 -0
  64. titan_cli/ui/tui/screens/workflows.py +249 -0
  65. titan_cli/ui/tui/textual_components.py +537 -0
  66. titan_cli/ui/tui/textual_workflow_executor.py +405 -0
  67. titan_cli/ui/tui/theme.py +102 -0
  68. titan_cli/ui/tui/widgets/__init__.py +40 -0
  69. titan_cli/ui/tui/widgets/button.py +108 -0
  70. titan_cli/ui/tui/widgets/header.py +116 -0
  71. titan_cli/ui/tui/widgets/panel.py +81 -0
  72. titan_cli/ui/tui/widgets/status_bar.py +115 -0
  73. titan_cli/ui/tui/widgets/table.py +77 -0
  74. titan_cli/ui/tui/widgets/text.py +177 -0
  75. titan_cli/utils/__init__.py +0 -0
  76. titan_cli/utils/autoupdate.py +155 -0
  77. titan_cli-0.1.0.dist-info/METADATA +149 -0
  78. titan_cli-0.1.0.dist-info/RECORD +146 -0
  79. titan_cli-0.1.0.dist-info/WHEEL +4 -0
  80. titan_cli-0.1.0.dist-info/entry_points.txt +9 -0
  81. titan_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  82. titan_plugin_git/__init__.py +1 -0
  83. titan_plugin_git/clients/__init__.py +8 -0
  84. titan_plugin_git/clients/git_client.py +772 -0
  85. titan_plugin_git/exceptions.py +40 -0
  86. titan_plugin_git/messages.py +112 -0
  87. titan_plugin_git/models.py +39 -0
  88. titan_plugin_git/plugin.py +118 -0
  89. titan_plugin_git/steps/__init__.py +1 -0
  90. titan_plugin_git/steps/ai_commit_message_step.py +171 -0
  91. titan_plugin_git/steps/branch_steps.py +104 -0
  92. titan_plugin_git/steps/commit_step.py +80 -0
  93. titan_plugin_git/steps/push_step.py +63 -0
  94. titan_plugin_git/steps/status_step.py +59 -0
  95. titan_plugin_git/workflows/__previews__/__init__.py +1 -0
  96. titan_plugin_git/workflows/__previews__/commit_ai_preview.py +124 -0
  97. titan_plugin_git/workflows/commit-ai.yaml +28 -0
  98. titan_plugin_github/__init__.py +11 -0
  99. titan_plugin_github/agents/__init__.py +6 -0
  100. titan_plugin_github/agents/config_loader.py +130 -0
  101. titan_plugin_github/agents/issue_generator.py +353 -0
  102. titan_plugin_github/agents/pr_agent.py +528 -0
  103. titan_plugin_github/clients/__init__.py +8 -0
  104. titan_plugin_github/clients/github_client.py +1105 -0
  105. titan_plugin_github/config/__init__.py +0 -0
  106. titan_plugin_github/config/pr_agent.toml +85 -0
  107. titan_plugin_github/exceptions.py +28 -0
  108. titan_plugin_github/messages.py +88 -0
  109. titan_plugin_github/models.py +330 -0
  110. titan_plugin_github/plugin.py +131 -0
  111. titan_plugin_github/steps/__init__.py +12 -0
  112. titan_plugin_github/steps/ai_pr_step.py +172 -0
  113. titan_plugin_github/steps/create_pr_step.py +86 -0
  114. titan_plugin_github/steps/github_prompt_steps.py +171 -0
  115. titan_plugin_github/steps/issue_steps.py +143 -0
  116. titan_plugin_github/steps/preview_step.py +40 -0
  117. titan_plugin_github/utils.py +82 -0
  118. titan_plugin_github/workflows/__previews__/__init__.py +1 -0
  119. titan_plugin_github/workflows/__previews__/create_pr_ai_preview.py +140 -0
  120. titan_plugin_github/workflows/create-issue-ai.yaml +32 -0
  121. titan_plugin_github/workflows/create-pr-ai.yaml +49 -0
  122. titan_plugin_jira/__init__.py +8 -0
  123. titan_plugin_jira/agents/__init__.py +6 -0
  124. titan_plugin_jira/agents/config_loader.py +154 -0
  125. titan_plugin_jira/agents/jira_agent.py +553 -0
  126. titan_plugin_jira/agents/prompts.py +364 -0
  127. titan_plugin_jira/agents/response_parser.py +435 -0
  128. titan_plugin_jira/agents/token_tracker.py +223 -0
  129. titan_plugin_jira/agents/validators.py +246 -0
  130. titan_plugin_jira/clients/jira_client.py +745 -0
  131. titan_plugin_jira/config/jira_agent.toml +92 -0
  132. titan_plugin_jira/config/templates/issue_analysis.md.j2 +78 -0
  133. titan_plugin_jira/exceptions.py +37 -0
  134. titan_plugin_jira/formatters/__init__.py +6 -0
  135. titan_plugin_jira/formatters/markdown_formatter.py +245 -0
  136. titan_plugin_jira/messages.py +115 -0
  137. titan_plugin_jira/models.py +89 -0
  138. titan_plugin_jira/plugin.py +264 -0
  139. titan_plugin_jira/steps/ai_analyze_issue_step.py +105 -0
  140. titan_plugin_jira/steps/get_issue_step.py +82 -0
  141. titan_plugin_jira/steps/prompt_select_issue_step.py +80 -0
  142. titan_plugin_jira/steps/search_saved_query_step.py +238 -0
  143. titan_plugin_jira/utils/__init__.py +13 -0
  144. titan_plugin_jira/utils/issue_sorter.py +140 -0
  145. titan_plugin_jira/utils/saved_queries.py +150 -0
  146. 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
+ ]