pnd-jira-mcp 1.0.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.
- pnd_jira_mcp/__init__.py +28 -0
- pnd_jira_mcp/__main__.py +12 -0
- pnd_jira_mcp/client.py +792 -0
- pnd_jira_mcp/server.py +392 -0
- pnd_jira_mcp/tools.py +295 -0
- pnd_jira_mcp-1.0.0.dist-info/METADATA +226 -0
- pnd_jira_mcp-1.0.0.dist-info/RECORD +10 -0
- pnd_jira_mcp-1.0.0.dist-info/WHEEL +5 -0
- pnd_jira_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- pnd_jira_mcp-1.0.0.dist-info/top_level.txt +1 -0
pnd_jira_mcp/client.py
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Jira Client Module
|
|
3
|
+
|
|
4
|
+
Provides integration with Atlassian JIRA REST API v3 and Agile API.
|
|
5
|
+
Self-contained implementation with no external dependencies beyond httpx.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, List, Any, Optional, Callable
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("pnd_jira_mcp.client")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class JiraConfig:
|
|
24
|
+
"""Configuration for JIRA API connection."""
|
|
25
|
+
base_url: str
|
|
26
|
+
email: str
|
|
27
|
+
api_token: str
|
|
28
|
+
cloud_id: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
field_ai_used: str = "customfield_ai_used"
|
|
31
|
+
field_agent_name: str = "customfield_agent_name"
|
|
32
|
+
field_efficiency_score: str = "customfield_ai_efficiency_score"
|
|
33
|
+
field_duration: str = "customfield_ai_duration"
|
|
34
|
+
|
|
35
|
+
max_retries: int = 3
|
|
36
|
+
retry_delay_ms: int = 1000
|
|
37
|
+
timeout_ms: int = 30000
|
|
38
|
+
|
|
39
|
+
debug: bool = False
|
|
40
|
+
on_request: Optional[Callable[[str, str, Any], None]] = field(default=None, repr=False)
|
|
41
|
+
on_response: Optional[Callable[[str, str, int, int], None]] = field(default=None, repr=False)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_dict(cls, data: Dict[str, Any]) -> "JiraConfig":
|
|
45
|
+
"""Create config from dictionary."""
|
|
46
|
+
return cls(
|
|
47
|
+
base_url=data.get("base_url", ""),
|
|
48
|
+
email=data.get("email", ""),
|
|
49
|
+
api_token=data.get("api_token", ""),
|
|
50
|
+
cloud_id=data.get("cloud_id"),
|
|
51
|
+
field_ai_used=data.get("field_ai_used", "customfield_ai_used"),
|
|
52
|
+
field_agent_name=data.get("field_agent_name", "customfield_agent_name"),
|
|
53
|
+
field_efficiency_score=data.get("field_efficiency_score", "customfield_ai_efficiency_score"),
|
|
54
|
+
field_duration=data.get("field_duration", "customfield_ai_duration"),
|
|
55
|
+
max_retries=data.get("max_retries", 3),
|
|
56
|
+
retry_delay_ms=data.get("retry_delay_ms", 1000),
|
|
57
|
+
timeout_ms=data.get("timeout_ms", 30000),
|
|
58
|
+
debug=data.get("debug", False),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_env(cls) -> "JiraConfig":
|
|
63
|
+
"""Create config from environment variables."""
|
|
64
|
+
return cls(
|
|
65
|
+
base_url=os.environ.get("JIRA_BASE_URL", ""),
|
|
66
|
+
email=os.environ.get("JIRA_EMAIL", ""),
|
|
67
|
+
api_token=os.environ.get("JIRA_API_TOKEN", ""),
|
|
68
|
+
cloud_id=os.environ.get("JIRA_CLOUD_ID"),
|
|
69
|
+
field_ai_used=os.environ.get("JIRA_FIELD_AI_USED", "customfield_ai_used"),
|
|
70
|
+
field_agent_name=os.environ.get("JIRA_FIELD_AGENT_NAME", "customfield_agent_name"),
|
|
71
|
+
field_efficiency_score=os.environ.get("JIRA_FIELD_EFFICIENCY_SCORE", "customfield_ai_efficiency_score"),
|
|
72
|
+
field_duration=os.environ.get("JIRA_FIELD_DURATION", "customfield_ai_duration"),
|
|
73
|
+
max_retries=int(os.environ.get("JIRA_MAX_RETRIES", "3")),
|
|
74
|
+
retry_delay_ms=int(os.environ.get("JIRA_RETRY_DELAY_MS", "1000")),
|
|
75
|
+
timeout_ms=int(os.environ.get("JIRA_TIMEOUT_MS", "30000")),
|
|
76
|
+
debug=os.environ.get("JIRA_DEBUG", "").lower() in ("true", "1", "yes"),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
80
|
+
"""Convert config to dictionary (without sensitive data)."""
|
|
81
|
+
return {
|
|
82
|
+
"base_url": self.base_url,
|
|
83
|
+
"email": self.email,
|
|
84
|
+
"cloud_id": self.cloud_id,
|
|
85
|
+
"field_ai_used": self.field_ai_used,
|
|
86
|
+
"field_agent_name": self.field_agent_name,
|
|
87
|
+
"field_efficiency_score": self.field_efficiency_score,
|
|
88
|
+
"field_duration": self.field_duration,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class JiraIssue:
|
|
94
|
+
"""Represents a JIRA issue."""
|
|
95
|
+
key: str
|
|
96
|
+
id: str
|
|
97
|
+
summary: str
|
|
98
|
+
status: str
|
|
99
|
+
issue_type: str
|
|
100
|
+
description: Optional[str] = None
|
|
101
|
+
assignee: Optional[str] = None
|
|
102
|
+
priority: Optional[str] = None
|
|
103
|
+
labels: List[str] = None
|
|
104
|
+
|
|
105
|
+
def __post_init__(self):
|
|
106
|
+
if self.labels is None:
|
|
107
|
+
self.labels = []
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def from_api_response(cls, data: Dict[str, Any]) -> "JiraIssue":
|
|
111
|
+
"""Create issue from API response."""
|
|
112
|
+
fields = data.get("fields", {})
|
|
113
|
+
return cls(
|
|
114
|
+
key=data.get("key", ""),
|
|
115
|
+
id=data.get("id", ""),
|
|
116
|
+
summary=fields.get("summary", ""),
|
|
117
|
+
status=fields.get("status", {}).get("name", ""),
|
|
118
|
+
issue_type=fields.get("issuetype", {}).get("name", ""),
|
|
119
|
+
description=fields.get("description"),
|
|
120
|
+
assignee=fields.get("assignee", {}).get("displayName") if fields.get("assignee") else None,
|
|
121
|
+
priority=fields.get("priority", {}).get("name") if fields.get("priority") else None,
|
|
122
|
+
labels=fields.get("labels", []),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class JiraClient:
|
|
127
|
+
"""
|
|
128
|
+
Client for interacting with JIRA REST API v3 and Agile API.
|
|
129
|
+
|
|
130
|
+
Provides methods for:
|
|
131
|
+
- Adding comments to issues
|
|
132
|
+
- Updating custom fields
|
|
133
|
+
- Querying issues
|
|
134
|
+
- Managing sprints and boards
|
|
135
|
+
- Managing AI-related metadata
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
API_VERSION = "3"
|
|
139
|
+
QAIN_LABEL = "qAIn"
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
config: Optional[JiraConfig] = None,
|
|
144
|
+
config_path: Optional[str] = None
|
|
145
|
+
):
|
|
146
|
+
"""
|
|
147
|
+
Initialize the JIRA client.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
config: JiraConfig object
|
|
151
|
+
config_path: Path to jira.config.json file
|
|
152
|
+
"""
|
|
153
|
+
self.config = config or self._load_config(config_path)
|
|
154
|
+
self._client: Optional[httpx.Client] = None
|
|
155
|
+
|
|
156
|
+
if not self.config.base_url or not self.config.email or not self.config.api_token:
|
|
157
|
+
logger.warning("JIRA configuration incomplete - some features may not work")
|
|
158
|
+
|
|
159
|
+
def _load_config(self, config_path: Optional[str] = None) -> JiraConfig:
|
|
160
|
+
"""Load configuration from file or environment."""
|
|
161
|
+
if config_path and os.path.exists(config_path):
|
|
162
|
+
try:
|
|
163
|
+
with open(config_path, "r") as f:
|
|
164
|
+
data = json.load(f)
|
|
165
|
+
if not data.get("api_token"):
|
|
166
|
+
data["api_token"] = os.environ.get("JIRA_API_TOKEN", "")
|
|
167
|
+
return JiraConfig.from_dict(data)
|
|
168
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
169
|
+
logger.warning(f"Failed to load config from {config_path}: {e}")
|
|
170
|
+
|
|
171
|
+
default_path = Path.home() / ".jira" / "config.json"
|
|
172
|
+
if default_path.exists():
|
|
173
|
+
try:
|
|
174
|
+
with open(default_path, "r") as f:
|
|
175
|
+
data = json.load(f)
|
|
176
|
+
if not data.get("api_token"):
|
|
177
|
+
data["api_token"] = os.environ.get("JIRA_API_TOKEN", "")
|
|
178
|
+
return JiraConfig.from_dict(data)
|
|
179
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
180
|
+
logger.warning(f"Failed to load config from {default_path}: {e}")
|
|
181
|
+
|
|
182
|
+
return JiraConfig.from_env()
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def client(self) -> httpx.Client:
|
|
186
|
+
"""Get or create HTTP client."""
|
|
187
|
+
if self._client is None:
|
|
188
|
+
self._client = httpx.Client(
|
|
189
|
+
base_url=self._get_api_base_url(),
|
|
190
|
+
auth=(self.config.email, self.config.api_token),
|
|
191
|
+
headers={
|
|
192
|
+
"Accept": "application/json",
|
|
193
|
+
"Content-Type": "application/json",
|
|
194
|
+
},
|
|
195
|
+
timeout=self.config.timeout_ms / 1000.0,
|
|
196
|
+
)
|
|
197
|
+
return self._client
|
|
198
|
+
|
|
199
|
+
def _get_api_base_url(self) -> str:
|
|
200
|
+
"""Get the API base URL."""
|
|
201
|
+
base = self.config.base_url.rstrip("/")
|
|
202
|
+
return f"{base}/rest/api/{self.API_VERSION}/"
|
|
203
|
+
|
|
204
|
+
def _get_agile_base_url(self) -> str:
|
|
205
|
+
"""Get the Agile API base URL."""
|
|
206
|
+
base = self.config.base_url.rstrip("/")
|
|
207
|
+
return f"{base}/rest/agile/1.0/"
|
|
208
|
+
|
|
209
|
+
def _calculate_backoff(self, retry_count: int) -> float:
|
|
210
|
+
"""Calculate exponential backoff delay with jitter."""
|
|
211
|
+
import random
|
|
212
|
+
base_delay = self.config.retry_delay_ms / 1000.0
|
|
213
|
+
delay = min(base_delay * (2 ** retry_count) + random.random(), 30.0)
|
|
214
|
+
return delay
|
|
215
|
+
|
|
216
|
+
def _request_with_retry(
|
|
217
|
+
self,
|
|
218
|
+
method: str,
|
|
219
|
+
endpoint: str,
|
|
220
|
+
retry_count: int = 0,
|
|
221
|
+
**kwargs
|
|
222
|
+
) -> httpx.Response:
|
|
223
|
+
"""Make HTTP request with retry logic and rate limit handling."""
|
|
224
|
+
start_time = time.time()
|
|
225
|
+
url = f"{self._get_api_base_url()}{endpoint}"
|
|
226
|
+
|
|
227
|
+
if self.config.debug:
|
|
228
|
+
logger.debug(f"[JIRA] {method} {endpoint}")
|
|
229
|
+
if self.config.on_request:
|
|
230
|
+
self.config.on_request(method, url, kwargs.get("json"))
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
response = getattr(self.client, method.lower())(endpoint, **kwargs)
|
|
234
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
235
|
+
|
|
236
|
+
if self.config.debug:
|
|
237
|
+
logger.debug(f"[JIRA] {method} {endpoint} -> {response.status_code} ({duration_ms}ms)")
|
|
238
|
+
if self.config.on_response:
|
|
239
|
+
self.config.on_response(method, url, response.status_code, duration_ms)
|
|
240
|
+
|
|
241
|
+
if response.status_code == 429:
|
|
242
|
+
if retry_count < self.config.max_retries:
|
|
243
|
+
retry_after = response.headers.get("Retry-After")
|
|
244
|
+
if retry_after:
|
|
245
|
+
delay = int(retry_after)
|
|
246
|
+
else:
|
|
247
|
+
delay = self._calculate_backoff(retry_count)
|
|
248
|
+
logger.warning(f"Rate limited, retrying in {delay}s...")
|
|
249
|
+
time.sleep(delay)
|
|
250
|
+
return self._request_with_retry(method, endpoint, retry_count + 1, **kwargs)
|
|
251
|
+
raise httpx.HTTPStatusError(
|
|
252
|
+
f"Rate limit exceeded after {self.config.max_retries} retries",
|
|
253
|
+
request=response.request,
|
|
254
|
+
response=response
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if response.status_code >= 500 and retry_count < self.config.max_retries:
|
|
258
|
+
delay = self._calculate_backoff(retry_count)
|
|
259
|
+
logger.warning(f"Server error {response.status_code}, retrying in {delay}s...")
|
|
260
|
+
time.sleep(delay)
|
|
261
|
+
return self._request_with_retry(method, endpoint, retry_count + 1, **kwargs)
|
|
262
|
+
|
|
263
|
+
return response
|
|
264
|
+
except httpx.TimeoutException:
|
|
265
|
+
if retry_count < self.config.max_retries:
|
|
266
|
+
delay = self._calculate_backoff(retry_count)
|
|
267
|
+
logger.warning(f"Request timeout, retrying in {delay}s...")
|
|
268
|
+
time.sleep(delay)
|
|
269
|
+
return self._request_with_retry(method, endpoint, retry_count + 1, **kwargs)
|
|
270
|
+
raise
|
|
271
|
+
except httpx.RequestError as e:
|
|
272
|
+
if retry_count < self.config.max_retries:
|
|
273
|
+
delay = self._calculate_backoff(retry_count)
|
|
274
|
+
logger.warning(f"Request error: {e}, retrying in {delay}s...")
|
|
275
|
+
time.sleep(delay)
|
|
276
|
+
return self._request_with_retry(method, endpoint, retry_count + 1, **kwargs)
|
|
277
|
+
raise
|
|
278
|
+
|
|
279
|
+
def close(self):
|
|
280
|
+
"""Close the HTTP client."""
|
|
281
|
+
if self._client:
|
|
282
|
+
self._client.close()
|
|
283
|
+
self._client = None
|
|
284
|
+
|
|
285
|
+
def __enter__(self):
|
|
286
|
+
return self
|
|
287
|
+
|
|
288
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
289
|
+
self.close()
|
|
290
|
+
|
|
291
|
+
def get_issue(self, issue_key: str, fields: Optional[List[str]] = None) -> Optional[JiraIssue]:
|
|
292
|
+
"""Get a JIRA issue by key."""
|
|
293
|
+
try:
|
|
294
|
+
params = {}
|
|
295
|
+
if fields:
|
|
296
|
+
params["fields"] = ",".join(fields)
|
|
297
|
+
|
|
298
|
+
response = self.client.get(f"issue/{issue_key}", params=params)
|
|
299
|
+
response.raise_for_status()
|
|
300
|
+
|
|
301
|
+
return JiraIssue.from_api_response(response.json())
|
|
302
|
+
except httpx.HTTPStatusError as e:
|
|
303
|
+
if e.response.status_code == 404:
|
|
304
|
+
logger.warning(f"Issue {issue_key} not found")
|
|
305
|
+
return None
|
|
306
|
+
logger.error(f"Failed to get issue {issue_key}: {e}")
|
|
307
|
+
raise
|
|
308
|
+
except Exception as e:
|
|
309
|
+
logger.error(f"Failed to get issue {issue_key}: {e}")
|
|
310
|
+
raise
|
|
311
|
+
|
|
312
|
+
def search_issues(
|
|
313
|
+
self,
|
|
314
|
+
jql: str,
|
|
315
|
+
max_results: int = 50,
|
|
316
|
+
fields: Optional[List[str]] = None
|
|
317
|
+
) -> List[JiraIssue]:
|
|
318
|
+
"""Search for issues using JQL."""
|
|
319
|
+
try:
|
|
320
|
+
params = {
|
|
321
|
+
"jql": jql,
|
|
322
|
+
"maxResults": max_results,
|
|
323
|
+
}
|
|
324
|
+
if fields:
|
|
325
|
+
params["fields"] = ",".join(fields) if isinstance(fields, list) else fields
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
response = self._request_with_retry("GET", "search/jql", params=params)
|
|
329
|
+
response.raise_for_status()
|
|
330
|
+
data = response.json()
|
|
331
|
+
return [JiraIssue.from_api_response(issue) for issue in data.get("issues", [])]
|
|
332
|
+
except httpx.HTTPStatusError as e:
|
|
333
|
+
if e.response.status_code == 404:
|
|
334
|
+
logger.info("Falling back to legacy /search endpoint")
|
|
335
|
+
payload = {
|
|
336
|
+
"jql": jql,
|
|
337
|
+
"maxResults": max_results,
|
|
338
|
+
}
|
|
339
|
+
if fields:
|
|
340
|
+
payload["fields"] = fields
|
|
341
|
+
response = self._request_with_retry("POST", "search", json=payload)
|
|
342
|
+
response.raise_for_status()
|
|
343
|
+
data = response.json()
|
|
344
|
+
return [JiraIssue.from_api_response(issue) for issue in data.get("issues", [])]
|
|
345
|
+
raise
|
|
346
|
+
except Exception as e:
|
|
347
|
+
logger.error(f"Failed to search issues: {e}")
|
|
348
|
+
raise
|
|
349
|
+
|
|
350
|
+
def add_label(self, issue_key: str, label: str) -> bool:
|
|
351
|
+
"""Add a label to a JIRA issue if not already present."""
|
|
352
|
+
try:
|
|
353
|
+
payload = {
|
|
354
|
+
"update": {
|
|
355
|
+
"labels": [{"add": label}]
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
response = self.client.put(f"issue/{issue_key}", json=payload)
|
|
359
|
+
response.raise_for_status()
|
|
360
|
+
logger.info(f"Added label '{label}' to {issue_key}")
|
|
361
|
+
return True
|
|
362
|
+
except httpx.HTTPStatusError as e:
|
|
363
|
+
if e.response.status_code == 400:
|
|
364
|
+
logger.warning(f"Could not add label to {issue_key}: {e.response.text}")
|
|
365
|
+
return False
|
|
366
|
+
logger.error(f"Failed to add label to {issue_key}: {e}")
|
|
367
|
+
return False
|
|
368
|
+
except Exception as e:
|
|
369
|
+
logger.error(f"Failed to add label to {issue_key}: {e}")
|
|
370
|
+
return False
|
|
371
|
+
|
|
372
|
+
def ensure_qain_label(self, issue_key: str) -> bool:
|
|
373
|
+
"""Ensure the qAIn label is present on a JIRA issue."""
|
|
374
|
+
try:
|
|
375
|
+
issue = self.get_issue(issue_key, fields=["labels"])
|
|
376
|
+
if issue and self.QAIN_LABEL in issue.labels:
|
|
377
|
+
logger.debug(f"qAIn label already exists on {issue_key}")
|
|
378
|
+
return True
|
|
379
|
+
return self.add_label(issue_key, self.QAIN_LABEL)
|
|
380
|
+
except Exception as e:
|
|
381
|
+
logger.warning(f"Could not ensure qAIn label on {issue_key}: {e}")
|
|
382
|
+
return False
|
|
383
|
+
|
|
384
|
+
def add_comment(
|
|
385
|
+
self,
|
|
386
|
+
issue_key: str,
|
|
387
|
+
body: str,
|
|
388
|
+
visibility: Optional[Dict[str, str]] = None,
|
|
389
|
+
add_qain_label: bool = True,
|
|
390
|
+
) -> Dict[str, Any]:
|
|
391
|
+
"""Add a comment to a JIRA issue."""
|
|
392
|
+
try:
|
|
393
|
+
if add_qain_label:
|
|
394
|
+
self.ensure_qain_label(issue_key)
|
|
395
|
+
|
|
396
|
+
payload = {
|
|
397
|
+
"body": self._markdown_to_adf(body)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if visibility:
|
|
401
|
+
payload["visibility"] = visibility
|
|
402
|
+
|
|
403
|
+
response = self.client.post(f"issue/{issue_key}/comment", json=payload)
|
|
404
|
+
response.raise_for_status()
|
|
405
|
+
|
|
406
|
+
logger.info(f"Added comment to {issue_key} (qAIn label: {add_qain_label})")
|
|
407
|
+
return response.json()
|
|
408
|
+
except Exception as e:
|
|
409
|
+
logger.error(f"Failed to add comment to {issue_key}: {e}")
|
|
410
|
+
raise
|
|
411
|
+
|
|
412
|
+
def _markdown_to_adf(self, markdown: str) -> Dict[str, Any]:
|
|
413
|
+
"""Convert markdown text to Atlassian Document Format (ADF)."""
|
|
414
|
+
paragraphs = markdown.strip().split("\n\n")
|
|
415
|
+
content = []
|
|
416
|
+
|
|
417
|
+
for para in paragraphs:
|
|
418
|
+
lines = para.split("\n")
|
|
419
|
+
|
|
420
|
+
heading_match = re.match(r'^(#{1,6})\s+(.+)$', lines[0]) if lines else None
|
|
421
|
+
if heading_match and len(lines) == 1:
|
|
422
|
+
level = len(heading_match.group(1))
|
|
423
|
+
content.append({
|
|
424
|
+
"type": "heading",
|
|
425
|
+
"attrs": {"level": level},
|
|
426
|
+
"content": self._parse_inline_formatting(heading_match.group(2))
|
|
427
|
+
})
|
|
428
|
+
continue
|
|
429
|
+
|
|
430
|
+
if lines[0].startswith("```"):
|
|
431
|
+
language = lines[0][3:].strip() or None
|
|
432
|
+
code_lines = []
|
|
433
|
+
i = 1
|
|
434
|
+
while i < len(lines) and not lines[i].startswith("```"):
|
|
435
|
+
code_lines.append(lines[i])
|
|
436
|
+
i += 1
|
|
437
|
+
code_block: Dict[str, Any] = {
|
|
438
|
+
"type": "codeBlock",
|
|
439
|
+
"content": [{"type": "text", "text": "\n".join(code_lines)}]
|
|
440
|
+
}
|
|
441
|
+
if language:
|
|
442
|
+
code_block["attrs"] = {"language": language}
|
|
443
|
+
content.append(code_block)
|
|
444
|
+
continue
|
|
445
|
+
|
|
446
|
+
if all(line.strip().startswith("- ") for line in lines if line.strip()):
|
|
447
|
+
list_items = []
|
|
448
|
+
for line in lines:
|
|
449
|
+
if line.strip().startswith("- "):
|
|
450
|
+
text = line.strip()[2:]
|
|
451
|
+
list_items.append({
|
|
452
|
+
"type": "listItem",
|
|
453
|
+
"content": [{
|
|
454
|
+
"type": "paragraph",
|
|
455
|
+
"content": self._parse_inline_formatting(text)
|
|
456
|
+
}]
|
|
457
|
+
})
|
|
458
|
+
if list_items:
|
|
459
|
+
content.append({
|
|
460
|
+
"type": "bulletList",
|
|
461
|
+
"content": list_items
|
|
462
|
+
})
|
|
463
|
+
continue
|
|
464
|
+
|
|
465
|
+
if all(re.match(r'^\d+\.\s+', line.strip()) for line in lines if line.strip()):
|
|
466
|
+
list_items = []
|
|
467
|
+
for line in lines:
|
|
468
|
+
match = re.match(r'^\d+\.\s+(.+)$', line.strip())
|
|
469
|
+
if match:
|
|
470
|
+
list_items.append({
|
|
471
|
+
"type": "listItem",
|
|
472
|
+
"content": [{
|
|
473
|
+
"type": "paragraph",
|
|
474
|
+
"content": self._parse_inline_formatting(match.group(1))
|
|
475
|
+
}]
|
|
476
|
+
})
|
|
477
|
+
if list_items:
|
|
478
|
+
content.append({
|
|
479
|
+
"type": "orderedList",
|
|
480
|
+
"content": list_items
|
|
481
|
+
})
|
|
482
|
+
continue
|
|
483
|
+
|
|
484
|
+
if all(line.startswith(">") or not line.strip() for line in lines):
|
|
485
|
+
quote_content = []
|
|
486
|
+
for line in lines:
|
|
487
|
+
if line.strip():
|
|
488
|
+
quote_text = re.sub(r'^>\s?', '', line)
|
|
489
|
+
quote_content.append({
|
|
490
|
+
"type": "paragraph",
|
|
491
|
+
"content": self._parse_inline_formatting(quote_text)
|
|
492
|
+
})
|
|
493
|
+
if quote_content:
|
|
494
|
+
content.append({
|
|
495
|
+
"type": "blockquote",
|
|
496
|
+
"content": quote_content
|
|
497
|
+
})
|
|
498
|
+
continue
|
|
499
|
+
|
|
500
|
+
text_content: List[Dict[str, Any]] = []
|
|
501
|
+
for line in lines:
|
|
502
|
+
if text_content:
|
|
503
|
+
text_content.append({"type": "hardBreak"})
|
|
504
|
+
text_content.extend(self._parse_inline_formatting(line))
|
|
505
|
+
|
|
506
|
+
if text_content:
|
|
507
|
+
content.append({
|
|
508
|
+
"type": "paragraph",
|
|
509
|
+
"content": text_content
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
"type": "doc",
|
|
514
|
+
"version": 1,
|
|
515
|
+
"content": content
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
def _parse_inline_formatting(self, text: str) -> List[Dict[str, Any]]:
|
|
519
|
+
"""Parse inline markdown formatting (bold, italic, links, strikethrough, code)."""
|
|
520
|
+
nodes: List[Dict[str, Any]] = []
|
|
521
|
+
remaining = text
|
|
522
|
+
|
|
523
|
+
while remaining:
|
|
524
|
+
link_match = re.match(r'\[([^\]]+)\]\(([^)]+)\)', remaining)
|
|
525
|
+
if link_match:
|
|
526
|
+
nodes.append({
|
|
527
|
+
"type": "text",
|
|
528
|
+
"text": link_match.group(1),
|
|
529
|
+
"marks": [{"type": "link", "attrs": {"href": link_match.group(2)}}]
|
|
530
|
+
})
|
|
531
|
+
remaining = remaining[len(link_match.group(0)):]
|
|
532
|
+
continue
|
|
533
|
+
|
|
534
|
+
bold_match = re.match(r'\*\*(.+?)\*\*', remaining)
|
|
535
|
+
if bold_match:
|
|
536
|
+
nodes.append({
|
|
537
|
+
"type": "text",
|
|
538
|
+
"text": bold_match.group(1),
|
|
539
|
+
"marks": [{"type": "strong"}]
|
|
540
|
+
})
|
|
541
|
+
remaining = remaining[len(bold_match.group(0)):]
|
|
542
|
+
continue
|
|
543
|
+
|
|
544
|
+
italic_match = re.match(r'\*(.+?)\*', remaining)
|
|
545
|
+
if italic_match:
|
|
546
|
+
nodes.append({
|
|
547
|
+
"type": "text",
|
|
548
|
+
"text": italic_match.group(1),
|
|
549
|
+
"marks": [{"type": "em"}]
|
|
550
|
+
})
|
|
551
|
+
remaining = remaining[len(italic_match.group(0)):]
|
|
552
|
+
continue
|
|
553
|
+
|
|
554
|
+
strike_match = re.match(r'~~(.+?)~~', remaining)
|
|
555
|
+
if strike_match:
|
|
556
|
+
nodes.append({
|
|
557
|
+
"type": "text",
|
|
558
|
+
"text": strike_match.group(1),
|
|
559
|
+
"marks": [{"type": "strike"}]
|
|
560
|
+
})
|
|
561
|
+
remaining = remaining[len(strike_match.group(0)):]
|
|
562
|
+
continue
|
|
563
|
+
|
|
564
|
+
code_match = re.match(r'`([^`]+)`', remaining)
|
|
565
|
+
if code_match:
|
|
566
|
+
nodes.append({
|
|
567
|
+
"type": "text",
|
|
568
|
+
"text": code_match.group(1),
|
|
569
|
+
"marks": [{"type": "code"}]
|
|
570
|
+
})
|
|
571
|
+
remaining = remaining[len(code_match.group(0)):]
|
|
572
|
+
continue
|
|
573
|
+
|
|
574
|
+
next_special = len(remaining)
|
|
575
|
+
for pattern in [r'\[', r'\*\*', r'\*', r'~~', r'`']:
|
|
576
|
+
match = re.search(pattern, remaining[1:])
|
|
577
|
+
if match:
|
|
578
|
+
next_special = min(next_special, match.start() + 1)
|
|
579
|
+
|
|
580
|
+
if next_special > 0:
|
|
581
|
+
nodes.append({"type": "text", "text": remaining[:next_special]})
|
|
582
|
+
remaining = remaining[next_special:]
|
|
583
|
+
else:
|
|
584
|
+
nodes.append({"type": "text", "text": remaining})
|
|
585
|
+
break
|
|
586
|
+
|
|
587
|
+
return nodes if nodes else [{"type": "text", "text": ""}]
|
|
588
|
+
|
|
589
|
+
def update_fields(self, issue_key: str, fields: Dict[str, Any]) -> bool:
|
|
590
|
+
"""Update fields on a JIRA issue."""
|
|
591
|
+
try:
|
|
592
|
+
payload = {"fields": fields}
|
|
593
|
+
response = self.client.put(f"issue/{issue_key}", json=payload)
|
|
594
|
+
response.raise_for_status()
|
|
595
|
+
logger.info(f"Updated fields on {issue_key}")
|
|
596
|
+
return True
|
|
597
|
+
except httpx.HTTPStatusError as e:
|
|
598
|
+
if e.response.status_code == 400:
|
|
599
|
+
logger.warning(f"Failed to update fields on {issue_key}: {e.response.text}")
|
|
600
|
+
return False
|
|
601
|
+
raise
|
|
602
|
+
except Exception as e:
|
|
603
|
+
logger.error(f"Failed to update fields on {issue_key}: {e}")
|
|
604
|
+
raise
|
|
605
|
+
|
|
606
|
+
def update_ai_fields(
|
|
607
|
+
self,
|
|
608
|
+
issue_key: str,
|
|
609
|
+
ai_used: bool = True,
|
|
610
|
+
agent_name: Optional[str] = None,
|
|
611
|
+
efficiency_score: Optional[float] = None,
|
|
612
|
+
duration_ms: Optional[float] = None
|
|
613
|
+
) -> bool:
|
|
614
|
+
"""Update AI-related custom fields on a JIRA issue."""
|
|
615
|
+
fields: Dict[str, Any] = {}
|
|
616
|
+
|
|
617
|
+
if self.config.field_ai_used:
|
|
618
|
+
fields[self.config.field_ai_used] = ai_used
|
|
619
|
+
|
|
620
|
+
if agent_name and self.config.field_agent_name:
|
|
621
|
+
fields[self.config.field_agent_name] = agent_name
|
|
622
|
+
|
|
623
|
+
if efficiency_score is not None and self.config.field_efficiency_score:
|
|
624
|
+
fields[self.config.field_efficiency_score] = efficiency_score
|
|
625
|
+
|
|
626
|
+
if duration_ms is not None and self.config.field_duration:
|
|
627
|
+
fields[self.config.field_duration] = duration_ms / 1000
|
|
628
|
+
|
|
629
|
+
if not fields:
|
|
630
|
+
logger.warning("No AI fields configured to update")
|
|
631
|
+
return False
|
|
632
|
+
|
|
633
|
+
return self.update_fields(issue_key, fields)
|
|
634
|
+
|
|
635
|
+
def get_transitions(self, issue_key: str) -> List[Dict[str, Any]]:
|
|
636
|
+
"""Get available transitions for an issue."""
|
|
637
|
+
try:
|
|
638
|
+
response = self.client.get(f"issue/{issue_key}/transitions")
|
|
639
|
+
response.raise_for_status()
|
|
640
|
+
return response.json().get("transitions", [])
|
|
641
|
+
except Exception as e:
|
|
642
|
+
logger.error(f"Failed to get transitions for {issue_key}: {e}")
|
|
643
|
+
raise
|
|
644
|
+
|
|
645
|
+
def transition_issue(
|
|
646
|
+
self,
|
|
647
|
+
issue_key: str,
|
|
648
|
+
transition_id: str,
|
|
649
|
+
fields: Optional[Dict[str, Any]] = None,
|
|
650
|
+
comment: Optional[str] = None
|
|
651
|
+
) -> bool:
|
|
652
|
+
"""Transition an issue to a new status."""
|
|
653
|
+
try:
|
|
654
|
+
payload: Dict[str, Any] = {
|
|
655
|
+
"transition": {"id": transition_id}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if fields:
|
|
659
|
+
payload["fields"] = fields
|
|
660
|
+
|
|
661
|
+
if comment:
|
|
662
|
+
payload["update"] = {
|
|
663
|
+
"comment": [{
|
|
664
|
+
"add": {"body": self._markdown_to_adf(comment)}
|
|
665
|
+
}]
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
response = self.client.post(f"issue/{issue_key}/transitions", json=payload)
|
|
669
|
+
response.raise_for_status()
|
|
670
|
+
|
|
671
|
+
logger.info(f"Transitioned {issue_key}")
|
|
672
|
+
return True
|
|
673
|
+
except Exception as e:
|
|
674
|
+
logger.error(f"Failed to transition {issue_key}: {e}")
|
|
675
|
+
raise
|
|
676
|
+
|
|
677
|
+
def test_connection(self) -> bool:
|
|
678
|
+
"""Test the JIRA connection."""
|
|
679
|
+
try:
|
|
680
|
+
response = self.client.get("myself")
|
|
681
|
+
response.raise_for_status()
|
|
682
|
+
user = response.json()
|
|
683
|
+
logger.info(f"Connected to JIRA as {user.get('displayName', 'Unknown')}")
|
|
684
|
+
return True
|
|
685
|
+
except Exception as e:
|
|
686
|
+
logger.error(f"JIRA connection test failed: {e}")
|
|
687
|
+
return False
|
|
688
|
+
|
|
689
|
+
def get_boards(self, project_key: Optional[str] = None, board_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
690
|
+
"""Get all boards, optionally filtered by project or type."""
|
|
691
|
+
try:
|
|
692
|
+
params: Dict[str, Any] = {"maxResults": 100}
|
|
693
|
+
if project_key:
|
|
694
|
+
params["projectKeyOrId"] = project_key
|
|
695
|
+
if board_type:
|
|
696
|
+
params["type"] = board_type
|
|
697
|
+
|
|
698
|
+
url = f"{self._get_agile_base_url()}board"
|
|
699
|
+
response = httpx.get(
|
|
700
|
+
url,
|
|
701
|
+
params=params,
|
|
702
|
+
auth=(self.config.email, self.config.api_token),
|
|
703
|
+
headers={"Accept": "application/json"},
|
|
704
|
+
timeout=self.config.timeout_ms / 1000.0,
|
|
705
|
+
)
|
|
706
|
+
response.raise_for_status()
|
|
707
|
+
return response.json().get("values", [])
|
|
708
|
+
except Exception as e:
|
|
709
|
+
logger.error(f"Failed to get boards: {e}")
|
|
710
|
+
raise
|
|
711
|
+
|
|
712
|
+
def get_sprints(
|
|
713
|
+
self,
|
|
714
|
+
board_id: int,
|
|
715
|
+
state: Optional[str] = None,
|
|
716
|
+
max_results: int = 50
|
|
717
|
+
) -> List[Dict[str, Any]]:
|
|
718
|
+
"""Get sprints for a board."""
|
|
719
|
+
try:
|
|
720
|
+
params: Dict[str, Any] = {"maxResults": max_results}
|
|
721
|
+
if state:
|
|
722
|
+
params["state"] = state
|
|
723
|
+
|
|
724
|
+
url = f"{self._get_agile_base_url()}board/{board_id}/sprint"
|
|
725
|
+
response = httpx.get(
|
|
726
|
+
url,
|
|
727
|
+
params=params,
|
|
728
|
+
auth=(self.config.email, self.config.api_token),
|
|
729
|
+
headers={"Accept": "application/json"},
|
|
730
|
+
timeout=self.config.timeout_ms / 1000.0,
|
|
731
|
+
)
|
|
732
|
+
response.raise_for_status()
|
|
733
|
+
return response.json().get("values", [])
|
|
734
|
+
except Exception as e:
|
|
735
|
+
logger.error(f"Failed to get sprints for board {board_id}: {e}")
|
|
736
|
+
raise
|
|
737
|
+
|
|
738
|
+
def get_sprint(self, sprint_id: int) -> Optional[Dict[str, Any]]:
|
|
739
|
+
"""Get sprint details by ID."""
|
|
740
|
+
try:
|
|
741
|
+
url = f"{self._get_agile_base_url()}sprint/{sprint_id}"
|
|
742
|
+
response = httpx.get(
|
|
743
|
+
url,
|
|
744
|
+
auth=(self.config.email, self.config.api_token),
|
|
745
|
+
headers={"Accept": "application/json"},
|
|
746
|
+
timeout=self.config.timeout_ms / 1000.0,
|
|
747
|
+
)
|
|
748
|
+
response.raise_for_status()
|
|
749
|
+
return response.json()
|
|
750
|
+
except httpx.HTTPStatusError as e:
|
|
751
|
+
if e.response.status_code == 404:
|
|
752
|
+
return None
|
|
753
|
+
raise
|
|
754
|
+
except Exception as e:
|
|
755
|
+
logger.error(f"Failed to get sprint {sprint_id}: {e}")
|
|
756
|
+
raise
|
|
757
|
+
|
|
758
|
+
def get_active_sprint(self, board_id: int) -> Optional[Dict[str, Any]]:
|
|
759
|
+
"""Get the active sprint for a board."""
|
|
760
|
+
try:
|
|
761
|
+
sprints = self.get_sprints(board_id, state="active")
|
|
762
|
+
return sprints[0] if sprints else None
|
|
763
|
+
except Exception as e:
|
|
764
|
+
logger.error(f"Failed to get active sprint for board {board_id}: {e}")
|
|
765
|
+
raise
|
|
766
|
+
|
|
767
|
+
def get_sprint_issues(
|
|
768
|
+
self,
|
|
769
|
+
sprint_id: int,
|
|
770
|
+
fields: Optional[List[str]] = None,
|
|
771
|
+
max_results: int = 200
|
|
772
|
+
) -> List[JiraIssue]:
|
|
773
|
+
"""Get all issues in a sprint."""
|
|
774
|
+
try:
|
|
775
|
+
params: Dict[str, Any] = {"maxResults": max_results}
|
|
776
|
+
if fields:
|
|
777
|
+
params["fields"] = ",".join(fields)
|
|
778
|
+
|
|
779
|
+
url = f"{self._get_agile_base_url()}sprint/{sprint_id}/issue"
|
|
780
|
+
response = httpx.get(
|
|
781
|
+
url,
|
|
782
|
+
params=params,
|
|
783
|
+
auth=(self.config.email, self.config.api_token),
|
|
784
|
+
headers={"Accept": "application/json"},
|
|
785
|
+
timeout=self.config.timeout_ms / 1000.0,
|
|
786
|
+
)
|
|
787
|
+
response.raise_for_status()
|
|
788
|
+
data = response.json()
|
|
789
|
+
return [JiraIssue.from_api_response(issue) for issue in data.get("issues", [])]
|
|
790
|
+
except Exception as e:
|
|
791
|
+
logger.error(f"Failed to get issues for sprint {sprint_id}: {e}")
|
|
792
|
+
raise
|