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.
- devscontext/__init__.py +3 -0
- devscontext/adapters/__init__.py +23 -0
- devscontext/adapters/base.py +105 -0
- devscontext/adapters/fireflies.py +585 -0
- devscontext/adapters/gmail.py +580 -0
- devscontext/adapters/jira.py +639 -0
- devscontext/adapters/local_docs.py +984 -0
- devscontext/adapters/slack.py +804 -0
- devscontext/agents/__init__.py +28 -0
- devscontext/agents/preprocessor.py +775 -0
- devscontext/agents/watcher.py +265 -0
- devscontext/cache.py +151 -0
- devscontext/cli.py +727 -0
- devscontext/config.py +264 -0
- devscontext/constants.py +107 -0
- devscontext/core.py +582 -0
- devscontext/exceptions.py +148 -0
- devscontext/logging.py +181 -0
- devscontext/models.py +504 -0
- devscontext/plugins/__init__.py +49 -0
- devscontext/plugins/base.py +321 -0
- devscontext/plugins/registry.py +544 -0
- devscontext/py.typed +0 -0
- devscontext/rag/__init__.py +113 -0
- devscontext/rag/embeddings.py +296 -0
- devscontext/rag/index.py +323 -0
- devscontext/server.py +374 -0
- devscontext/storage.py +321 -0
- devscontext/synthesis.py +1057 -0
- devscontext/utils.py +297 -0
- devscontext-0.1.0.dist-info/METADATA +253 -0
- devscontext-0.1.0.dist-info/RECORD +35 -0
- devscontext-0.1.0.dist-info/WHEEL +4 -0
- devscontext-0.1.0.dist-info/entry_points.txt +2 -0
- devscontext-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|