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/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