devscontext 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.
@@ -0,0 +1,639 @@
1
+ """Jira adapter for fetching issue context.
2
+
3
+ This adapter connects to the Jira REST API (v3) to fetch ticket details,
4
+ comments, and linked issues. It handles Atlassian Document Format (ADF)
5
+ conversion and assembles all data into a structured context block.
6
+
7
+ This adapter implements the SourcePlugin interface for the plugin system.
8
+
9
+ Example:
10
+ config = JiraConfig(base_url="https://company.atlassian.net", ...)
11
+ adapter = JiraAdapter(config)
12
+ context = await adapter.fetch_task_context("PROJ-123")
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import time
19
+ from datetime import UTC, datetime
20
+ from typing import Any, ClassVar
21
+
22
+ import httpx
23
+
24
+ from devscontext.constants import (
25
+ ADAPTER_JIRA,
26
+ DEFAULT_HTTP_TIMEOUT_SECONDS,
27
+ JIRA_API_BASE_PATH,
28
+ JIRA_MAX_COMMENTS,
29
+ JIRA_TICKET_FIELDS,
30
+ SOURCE_TYPE_ISSUE_TRACKER,
31
+ )
32
+ from devscontext.logging import get_logger
33
+ from devscontext.models import (
34
+ ContextData,
35
+ JiraComment,
36
+ JiraConfig,
37
+ JiraContext,
38
+ JiraTicket,
39
+ LinkedIssue,
40
+ )
41
+ from devscontext.plugins.base import Adapter, SearchResult, SourceContext
42
+
43
+ logger = get_logger(__name__)
44
+
45
+
46
+ class JiraAdapter(Adapter):
47
+ """Adapter for fetching context from Jira issues.
48
+
49
+ Implements the Adapter interface for the plugin system.
50
+ Provides context from Jira tickets, comments, and linked issues.
51
+
52
+ Class Attributes:
53
+ name: Adapter identifier ("jira").
54
+ source_type: Source category ("issue_tracker").
55
+ config_schema: Configuration model (JiraConfig).
56
+ """
57
+
58
+ # Adapter class attributes
59
+ name: ClassVar[str] = ADAPTER_JIRA
60
+ source_type: ClassVar[str] = SOURCE_TYPE_ISSUE_TRACKER
61
+ config_schema: ClassVar[type[JiraConfig]] = JiraConfig
62
+
63
+ def __init__(self, config: JiraConfig) -> None:
64
+ """Initialize the Jira adapter.
65
+
66
+ Args:
67
+ config: Jira configuration with credentials and settings.
68
+ """
69
+ self._config = config
70
+ self._client: httpx.AsyncClient | None = None
71
+
72
+ def _get_client(self) -> httpx.AsyncClient:
73
+ """Get or create the HTTP client."""
74
+ if self._client is None:
75
+ self._client = httpx.AsyncClient(
76
+ base_url=self._config.base_url.rstrip("/"),
77
+ auth=(self._config.email, self._config.api_token),
78
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
79
+ timeout=DEFAULT_HTTP_TIMEOUT_SECONDS,
80
+ )
81
+ return self._client
82
+
83
+ async def close(self) -> None:
84
+ """Close the HTTP client."""
85
+ if self._client is not None:
86
+ await self._client.aclose()
87
+ self._client = None
88
+
89
+ async def get_ticket(self, ticket_id: str) -> JiraTicket | None:
90
+ """Fetch ticket details from Jira."""
91
+ start_time = time.monotonic()
92
+ client = self._get_client()
93
+
94
+ try:
95
+ response = await client.get(
96
+ f"{JIRA_API_BASE_PATH}/issue/{ticket_id}",
97
+ params={"fields": JIRA_TICKET_FIELDS},
98
+ )
99
+ response.raise_for_status()
100
+ data = response.json()
101
+
102
+ duration_ms = int((time.monotonic() - start_time) * 1000)
103
+ logger.info(
104
+ "Fetched Jira ticket",
105
+ extra={"ticket_id": ticket_id, "duration_ms": duration_ms},
106
+ )
107
+
108
+ return self._parse_ticket(data["key"], data.get("fields", {}))
109
+
110
+ except httpx.HTTPStatusError as e:
111
+ if e.response.status_code == 404:
112
+ logger.warning("Jira ticket not found", extra={"ticket_id": ticket_id})
113
+ return None
114
+ logger.error(
115
+ "Jira API error",
116
+ extra={"ticket_id": ticket_id, "status_code": e.response.status_code},
117
+ )
118
+ return None
119
+
120
+ except httpx.RequestError as e:
121
+ logger.exception("Network error fetching Jira ticket", extra={"error": str(e)})
122
+ return None
123
+
124
+ async def get_comments(self, ticket_id: str) -> list[JiraComment]:
125
+ """Fetch comments for a Jira ticket."""
126
+ client = self._get_client()
127
+
128
+ try:
129
+ response = await client.get(
130
+ f"{JIRA_API_BASE_PATH}/issue/{ticket_id}/comment",
131
+ params={"maxResults": JIRA_MAX_COMMENTS, "orderBy": "-created"},
132
+ )
133
+ response.raise_for_status()
134
+ data = response.json()
135
+
136
+ logger.info(
137
+ "Fetched Jira comments",
138
+ extra={"ticket_id": ticket_id, "count": len(data.get("comments", []))},
139
+ )
140
+
141
+ return [self._parse_comment(c) for c in data.get("comments", [])]
142
+
143
+ except httpx.HTTPStatusError:
144
+ logger.warning("Failed to fetch Jira comments", extra={"ticket_id": ticket_id})
145
+ return []
146
+
147
+ except httpx.RequestError as e:
148
+ logger.exception("Network error fetching comments", extra={"error": str(e)})
149
+ return []
150
+
151
+ async def get_linked_issues(self, ticket_id: str) -> list[LinkedIssue]:
152
+ """Fetch linked issues for a Jira ticket."""
153
+ start_time = time.monotonic()
154
+ client = self._get_client()
155
+
156
+ try:
157
+ response = await client.get(
158
+ f"{JIRA_API_BASE_PATH}/issue/{ticket_id}",
159
+ params={"fields": "issuelinks"},
160
+ )
161
+ response.raise_for_status()
162
+ data = response.json()
163
+
164
+ links = data.get("fields", {}).get("issuelinks", [])
165
+ duration_ms = int((time.monotonic() - start_time) * 1000)
166
+ logger.info(
167
+ "Fetched linked issues",
168
+ extra={"ticket_id": ticket_id, "count": len(links), "duration_ms": duration_ms},
169
+ )
170
+
171
+ return [linked for link in links if (linked := self._parse_linked_issue(link))]
172
+
173
+ except httpx.HTTPStatusError:
174
+ logger.warning("Failed to fetch linked issues", extra={"ticket_id": ticket_id})
175
+ return []
176
+
177
+ except httpx.RequestError as e:
178
+ logger.exception("Network error fetching linked issues", extra={"error": str(e)})
179
+ return []
180
+
181
+ async def get_ticket_full_context(self, ticket_id: str) -> JiraContext | None:
182
+ """Fetch full context for a Jira ticket (ticket, comments, linked issues)."""
183
+ start_time = time.monotonic()
184
+
185
+ results = await asyncio.gather(
186
+ self.get_ticket(ticket_id),
187
+ self.get_comments(ticket_id),
188
+ self.get_linked_issues(ticket_id),
189
+ return_exceptions=True,
190
+ )
191
+
192
+ ticket_result = results[0]
193
+ comments_result = results[1]
194
+ linked_result = results[2]
195
+
196
+ # Handle exceptions from gather
197
+ ticket: JiraTicket | None = None
198
+ if isinstance(ticket_result, JiraTicket):
199
+ ticket = ticket_result
200
+ elif isinstance(ticket_result, Exception):
201
+ ticket = None
202
+
203
+ comments: list[JiraComment] = []
204
+ if isinstance(comments_result, list):
205
+ comments = comments_result
206
+
207
+ linked_issues: list[LinkedIssue] = []
208
+ if isinstance(linked_result, list):
209
+ linked_issues = linked_result
210
+
211
+ duration_ms = int((time.monotonic() - start_time) * 1000)
212
+
213
+ if ticket is None:
214
+ logger.warning("Ticket not found", extra={"ticket_id": ticket_id})
215
+ return None
216
+
217
+ logger.info(
218
+ "Assembled full ticket context",
219
+ extra={
220
+ "ticket_id": ticket_id,
221
+ "comment_count": len(comments),
222
+ "linked_count": len(linked_issues),
223
+ "duration_ms": duration_ms,
224
+ },
225
+ )
226
+
227
+ return JiraContext(
228
+ ticket=ticket,
229
+ comments=comments,
230
+ linked_issues=linked_issues,
231
+ )
232
+
233
+ async def get_jira_context(self, task_id: str) -> JiraContext | None:
234
+ """Alias for get_ticket_full_context for consistency with other adapters."""
235
+ return await self.get_ticket_full_context(task_id)
236
+
237
+ async def search_issues(
238
+ self,
239
+ query: str,
240
+ max_results: int = 5,
241
+ ) -> list[JiraTicket]:
242
+ """Search for issues using JQL text search.
243
+
244
+ Performs a full-text search across issue summary and description.
245
+ Returns lightweight ticket summaries (no comments or linked issues).
246
+
247
+ Args:
248
+ query: Search terms to find in issues.
249
+ max_results: Maximum number of results to return.
250
+
251
+ Returns:
252
+ List of matching JiraTicket objects (lightweight, no comments).
253
+ """
254
+ if not self._config.enabled:
255
+ return []
256
+
257
+ start_time = time.monotonic()
258
+ client = self._get_client()
259
+
260
+ # Build JQL query with text search
261
+ # Escape quotes in the query for JQL
262
+ escaped_query = query.replace('"', '\\"')
263
+ jql = f'text ~ "{escaped_query}" ORDER BY updated DESC'
264
+
265
+ try:
266
+ response = await client.get(
267
+ f"{JIRA_API_BASE_PATH}/search",
268
+ params={
269
+ "jql": jql,
270
+ "maxResults": max_results,
271
+ "fields": "summary,status,assignee,labels,updated",
272
+ },
273
+ )
274
+ response.raise_for_status()
275
+ data = response.json()
276
+
277
+ issues = data.get("issues", [])
278
+ duration_ms = int((time.monotonic() - start_time) * 1000)
279
+
280
+ logger.info(
281
+ "Jira search completed",
282
+ extra={
283
+ "query": query,
284
+ "result_count": len(issues),
285
+ "duration_ms": duration_ms,
286
+ },
287
+ )
288
+
289
+ # Parse into lightweight tickets
290
+ results: list[JiraTicket] = []
291
+ for issue in issues:
292
+ key = issue.get("key", "")
293
+ fields = issue.get("fields", {})
294
+ results.append(self._parse_search_result(key, fields))
295
+
296
+ return results
297
+
298
+ except httpx.HTTPStatusError as e:
299
+ logger.warning(
300
+ "Jira search failed",
301
+ extra={"query": query, "status_code": e.response.status_code},
302
+ )
303
+ return []
304
+
305
+ except httpx.RequestError as e:
306
+ logger.warning(
307
+ "Jira search network error",
308
+ extra={"query": query, "error": str(e)},
309
+ )
310
+ return []
311
+
312
+ def _parse_search_result(self, key: str, fields: dict[str, Any]) -> JiraTicket:
313
+ """Parse a search result into a lightweight JiraTicket."""
314
+ assignee = None
315
+ if fields.get("assignee"):
316
+ assignee = fields["assignee"].get("displayName")
317
+
318
+ updated = self._parse_datetime(fields.get("updated"))
319
+
320
+ return JiraTicket(
321
+ ticket_id=key,
322
+ title=fields.get("summary", ""),
323
+ description=None, # Not fetched for search results
324
+ status=fields.get("status", {}).get("name", "Unknown"),
325
+ assignee=assignee,
326
+ labels=fields.get("labels", []),
327
+ components=[], # Not fetched for search results
328
+ acceptance_criteria=None,
329
+ story_points=None,
330
+ sprint=None,
331
+ created=updated, # Use updated as approximation
332
+ updated=updated,
333
+ )
334
+
335
+ def _parse_ticket(self, key: str, fields: dict[str, Any]) -> JiraTicket:
336
+ """Parse ticket data from Jira API response."""
337
+ description = self._extract_text_from_adf(fields.get("description"))
338
+
339
+ # Parse datetime fields
340
+ created = self._parse_datetime(fields.get("created"))
341
+ updated = self._parse_datetime(fields.get("updated"))
342
+
343
+ # Get assignee display name
344
+ assignee = None
345
+ if fields.get("assignee"):
346
+ assignee = fields["assignee"].get("displayName")
347
+
348
+ # Get components
349
+ components = [c.get("name", "") for c in fields.get("components", [])]
350
+
351
+ # Get sprint (from custom field if available)
352
+ sprint = None
353
+ if fields.get("sprint"):
354
+ sprint = fields["sprint"].get("name") if isinstance(fields["sprint"], dict) else None
355
+
356
+ return JiraTicket(
357
+ ticket_id=key,
358
+ title=fields.get("summary", ""),
359
+ description=description,
360
+ status=fields.get("status", {}).get("name", "Unknown"),
361
+ assignee=assignee,
362
+ labels=fields.get("labels", []),
363
+ components=components,
364
+ acceptance_criteria=None, # Would need custom field mapping
365
+ story_points=None, # Would need custom field mapping
366
+ sprint=sprint,
367
+ created=created,
368
+ updated=updated,
369
+ )
370
+
371
+ def _parse_comment(self, comment_data: dict[str, Any]) -> JiraComment:
372
+ """Parse comment data from Jira API response."""
373
+ author_data = comment_data.get("author", {})
374
+ body = self._extract_text_from_adf(comment_data.get("body")) or ""
375
+ created = self._parse_datetime(comment_data.get("created"))
376
+
377
+ return JiraComment(
378
+ author=author_data.get("displayName", "Unknown"),
379
+ body=body,
380
+ created=created,
381
+ )
382
+
383
+ def _parse_linked_issue(self, link: dict[str, Any]) -> LinkedIssue | None:
384
+ """Parse linked issue data from Jira API response."""
385
+ link_type = link.get("type", {}).get("name", "Related")
386
+
387
+ if "outwardIssue" in link:
388
+ issue = link["outwardIssue"]
389
+ link_direction = link.get("type", {}).get("outward", link_type)
390
+ elif "inwardIssue" in link:
391
+ issue = link["inwardIssue"]
392
+ link_direction = link.get("type", {}).get("inward", link_type)
393
+ else:
394
+ return None
395
+
396
+ return LinkedIssue(
397
+ ticket_id=issue["key"],
398
+ title=issue.get("fields", {}).get("summary", ""),
399
+ status=issue.get("fields", {}).get("status", {}).get("name", "Unknown"),
400
+ link_type=link_direction,
401
+ )
402
+
403
+ def _parse_datetime(self, value: str | None) -> datetime:
404
+ """Parse ISO datetime string to timezone-aware datetime."""
405
+ if not value:
406
+ return datetime.now(UTC)
407
+ try:
408
+ # Jira returns ISO format like "2024-01-15T10:30:00.000+0000"
409
+ dt = datetime.fromisoformat(value.replace("+0000", "+00:00"))
410
+ if dt.tzinfo is None:
411
+ dt = dt.replace(tzinfo=UTC)
412
+ return dt
413
+ except ValueError:
414
+ return datetime.now(UTC)
415
+
416
+ def _extract_text_from_adf(self, adf: dict[str, Any] | str | None) -> str | None:
417
+ """Extract plain text from Atlassian Document Format."""
418
+ if adf is None:
419
+ return None
420
+ if isinstance(adf, str):
421
+ return adf
422
+
423
+ def extract_from_node(node: dict[str, Any]) -> str:
424
+ if node.get("type") == "text":
425
+ return str(node.get("text", ""))
426
+ content = node.get("content", [])
427
+ texts = [extract_from_node(child) for child in content]
428
+ if node.get("type") in ("paragraph", "heading", "listItem"):
429
+ return "".join(texts) + "\n"
430
+ return "".join(texts)
431
+
432
+ try:
433
+ return extract_from_node(adf).strip()
434
+ except Exception:
435
+ logger.warning("Failed to parse ADF document")
436
+ return None
437
+
438
+ def _format_context_content(self, context: JiraContext) -> str:
439
+ """Format ticket context as markdown."""
440
+ parts: list[str] = []
441
+
442
+ # Description
443
+ parts.append(f"## Description\n{context.ticket.description or 'No description'}")
444
+
445
+ # Details
446
+ parts.append("\n## Details")
447
+ parts.append(f"- **Status:** {context.ticket.status}")
448
+ if context.ticket.assignee:
449
+ parts.append(f"- **Assignee:** {context.ticket.assignee}")
450
+ if context.ticket.labels:
451
+ parts.append(f"- **Labels:** {', '.join(context.ticket.labels)}")
452
+ if context.ticket.components:
453
+ parts.append(f"- **Components:** {', '.join(context.ticket.components)}")
454
+ if context.ticket.sprint:
455
+ parts.append(f"- **Sprint:** {context.ticket.sprint}")
456
+
457
+ # Comments
458
+ if context.comments:
459
+ parts.append(f"\n## Comments ({len(context.comments)})")
460
+ for comment in context.comments[:10]:
461
+ date_str = comment.created.strftime("%Y-%m-%d")
462
+ parts.append(f"\n**{comment.author}** ({date_str}):")
463
+ parts.append(comment.body)
464
+
465
+ # Linked issues
466
+ if context.linked_issues:
467
+ parts.append(f"\n## Linked Issues ({len(context.linked_issues)})")
468
+ for linked in context.linked_issues:
469
+ parts.append(
470
+ f"- [{linked.ticket_id}] {linked.title} ({linked.status}) - {linked.link_type}"
471
+ )
472
+
473
+ return "\n".join(parts)
474
+
475
+ async def fetch_task_context(
476
+ self,
477
+ task_id: str,
478
+ ticket: JiraTicket | None = None,
479
+ ) -> SourceContext:
480
+ """Fetch context from a Jira issue.
481
+
482
+ Implements the SourcePlugin interface. Fetches the ticket, comments,
483
+ and linked issues, returning them as a SourceContext.
484
+
485
+ Args:
486
+ task_id: The Jira ticket ID (e.g., "PROJ-123").
487
+ ticket: Ignored for Jira adapter (we fetch fresh data).
488
+
489
+ Returns:
490
+ SourceContext with JiraContext data, or empty context if disabled/error.
491
+ """
492
+ if not self._config.enabled:
493
+ logger.debug("Jira adapter is disabled")
494
+ return SourceContext(
495
+ source_name=self.name,
496
+ source_type=self.source_type,
497
+ data=None,
498
+ raw_text="",
499
+ )
500
+
501
+ context = await self.get_ticket_full_context(task_id)
502
+ if context is None:
503
+ return SourceContext(
504
+ source_name=self.name,
505
+ source_type=self.source_type,
506
+ data=None,
507
+ raw_text="",
508
+ metadata={"task_id": task_id, "error": "not_found"},
509
+ )
510
+
511
+ raw_text = self._format_context_content(context)
512
+
513
+ return SourceContext(
514
+ source_name=self.name,
515
+ source_type=self.source_type,
516
+ data=context,
517
+ raw_text=raw_text,
518
+ metadata={
519
+ "task_id": task_id,
520
+ "status": context.ticket.status,
521
+ "assignee": context.ticket.assignee,
522
+ "labels": context.ticket.labels,
523
+ "components": context.ticket.components,
524
+ "comment_count": len(context.comments),
525
+ "linked_issue_count": len(context.linked_issues),
526
+ },
527
+ )
528
+
529
+ async def search(
530
+ self,
531
+ query: str,
532
+ max_results: int = 10,
533
+ ) -> list[SearchResult]:
534
+ """Search for Jira issues matching the query.
535
+
536
+ Implements the SourcePlugin interface. Performs JQL text search
537
+ across issue summary and description.
538
+
539
+ Args:
540
+ query: Search terms to find in issues.
541
+ max_results: Maximum number of results to return.
542
+
543
+ Returns:
544
+ List of SearchResult items.
545
+ """
546
+ if not self._config.enabled:
547
+ return []
548
+
549
+ tickets = await self.search_issues(query, max_results)
550
+
551
+ results: list[SearchResult] = []
552
+ for ticket in tickets:
553
+ # Build excerpt from title and status
554
+ excerpt = f"{ticket.title}\n\nStatus: {ticket.status}"
555
+ if ticket.assignee:
556
+ excerpt += f"\nAssignee: {ticket.assignee}"
557
+ if ticket.labels:
558
+ excerpt += f"\nLabels: {', '.join(ticket.labels)}"
559
+
560
+ # Build URL if we have base_url
561
+ url = None
562
+ if self._config.base_url:
563
+ url = f"{self._config.base_url.rstrip('/')}/browse/{ticket.ticket_id}"
564
+
565
+ results.append(
566
+ SearchResult(
567
+ source_name=self.name,
568
+ source_type=self.source_type,
569
+ title=f"[{ticket.ticket_id}] {ticket.title}",
570
+ excerpt=excerpt,
571
+ url=url,
572
+ metadata={
573
+ "ticket_id": ticket.ticket_id,
574
+ "status": ticket.status,
575
+ "assignee": ticket.assignee,
576
+ },
577
+ )
578
+ )
579
+
580
+ return results
581
+
582
+ async def fetch_context(self, task_id: str) -> list[ContextData]:
583
+ """Fetch context from a Jira issue (legacy Adapter interface).
584
+
585
+ This method is kept for backward compatibility with the old Adapter
586
+ interface. New code should use fetch_task_context() instead.
587
+
588
+ Args:
589
+ task_id: The Jira ticket ID.
590
+
591
+ Returns:
592
+ List of ContextData items.
593
+ """
594
+ source_context = await self.fetch_task_context(task_id)
595
+
596
+ if source_context.is_empty():
597
+ return []
598
+
599
+ context = source_context.data
600
+ if not isinstance(context, JiraContext):
601
+ return []
602
+
603
+ return [
604
+ ContextData(
605
+ source=f"jira:{task_id}",
606
+ source_type=self.source_type,
607
+ title=f"[{task_id}] {context.ticket.title}",
608
+ content=source_context.raw_text,
609
+ metadata=dict(source_context.metadata),
610
+ )
611
+ ]
612
+
613
+ async def health_check(self) -> bool:
614
+ """Check if Jira is configured and accessible."""
615
+ if not self._config.enabled:
616
+ return True
617
+
618
+ if not (self._config.base_url and self._config.email and self._config.api_token):
619
+ logger.warning("Jira adapter missing required configuration")
620
+ return False
621
+
622
+ try:
623
+ client = self._get_client()
624
+ response = await client.get(f"{JIRA_API_BASE_PATH}/myself")
625
+ healthy = response.status_code == 200
626
+
627
+ if healthy:
628
+ logger.info("Jira health check passed")
629
+ else:
630
+ logger.warning(
631
+ "Jira health check failed",
632
+ extra={"status_code": response.status_code},
633
+ )
634
+
635
+ return healthy
636
+
637
+ except Exception as e:
638
+ logger.exception("Jira health check error", extra={"error": str(e)})
639
+ return False