Skip to content

Commit 4e6a9b4

Browse files
committed
feat: implement search_context and get_standards tools
1 parent a642912 commit 4e6a9b4

4 files changed

Lines changed: 377 additions & 46 deletions

File tree

src/devscontext/adapters/jira.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,104 @@ async def get_jira_context(self, task_id: str) -> JiraContext | None:
226226
"""Alias for get_ticket_full_context for consistency with other adapters."""
227227
return await self.get_ticket_full_context(task_id)
228228

229+
async def search_issues(
230+
self,
231+
query: str,
232+
max_results: int = 5,
233+
) -> list[JiraTicket]:
234+
"""Search for issues using JQL text search.
235+
236+
Performs a full-text search across issue summary and description.
237+
Returns lightweight ticket summaries (no comments or linked issues).
238+
239+
Args:
240+
query: Search terms to find in issues.
241+
max_results: Maximum number of results to return.
242+
243+
Returns:
244+
List of matching JiraTicket objects (lightweight, no comments).
245+
"""
246+
if not self._config.enabled:
247+
return []
248+
249+
start_time = time.monotonic()
250+
client = self._get_client()
251+
252+
# Build JQL query with text search
253+
# Escape quotes in the query for JQL
254+
escaped_query = query.replace('"', '\\"')
255+
jql = f'text ~ "{escaped_query}" ORDER BY updated DESC'
256+
257+
try:
258+
response = await client.get(
259+
f"{JIRA_API_BASE_PATH}/search",
260+
params={
261+
"jql": jql,
262+
"maxResults": max_results,
263+
"fields": "summary,status,assignee,labels,updated",
264+
},
265+
)
266+
response.raise_for_status()
267+
data = response.json()
268+
269+
issues = data.get("issues", [])
270+
duration_ms = int((time.monotonic() - start_time) * 1000)
271+
272+
logger.info(
273+
"Jira search completed",
274+
extra={
275+
"query": query,
276+
"result_count": len(issues),
277+
"duration_ms": duration_ms,
278+
},
279+
)
280+
281+
# Parse into lightweight tickets
282+
results: list[JiraTicket] = []
283+
for issue in issues:
284+
key = issue.get("key", "")
285+
fields = issue.get("fields", {})
286+
results.append(self._parse_search_result(key, fields))
287+
288+
return results
289+
290+
except httpx.HTTPStatusError as e:
291+
logger.warning(
292+
"Jira search failed",
293+
extra={"query": query, "status_code": e.response.status_code},
294+
)
295+
return []
296+
297+
except httpx.RequestError as e:
298+
logger.warning(
299+
"Jira search network error",
300+
extra={"query": query, "error": str(e)},
301+
)
302+
return []
303+
304+
def _parse_search_result(self, key: str, fields: dict[str, Any]) -> JiraTicket:
305+
"""Parse a search result into a lightweight JiraTicket."""
306+
assignee = None
307+
if fields.get("assignee"):
308+
assignee = fields["assignee"].get("displayName")
309+
310+
updated = self._parse_datetime(fields.get("updated"))
311+
312+
return JiraTicket(
313+
ticket_id=key,
314+
title=fields.get("summary", ""),
315+
description=None, # Not fetched for search results
316+
status=fields.get("status", {}).get("name", "Unknown"),
317+
assignee=assignee,
318+
labels=fields.get("labels", []),
319+
components=[], # Not fetched for search results
320+
acceptance_criteria=None,
321+
story_points=None,
322+
sprint=None,
323+
created=updated, # Use updated as approximation
324+
updated=updated,
325+
)
326+
229327
def _parse_ticket(self, key: str, fields: dict[str, Any]) -> JiraTicket:
230328
"""Parse ticket data from Jira API response."""
231329
description = self._extract_text_from_adf(fields.get("description"))

src/devscontext/core.py

Lines changed: 179 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -385,49 +385,144 @@ def invalidate_cache(self, task_id: str | None = None) -> None:
385385
async def search_context(self, query: str) -> dict[str, str | list[str] | int]:
386386
"""Search across all sources by keyword.
387387
388-
This is a placeholder implementation that will be enhanced
389-
when adapters support search functionality.
388+
Searches Jira, meetings, and local docs in parallel for freeform queries
389+
like "how do we handle retries?" or "what was decided about payments?".
390+
391+
No LLM synthesis - returns formatted search results directly.
392+
No caching - queries are too varied.
390393
391394
Args:
392395
query: The search query.
393396
394397
Returns:
395-
Dictionary with search results and metadata.
398+
Dictionary with formatted results and metadata.
396399
"""
397400
start_time = time.monotonic()
398-
sources: list[str] = []
401+
logger.info("Search context", extra={"query": query})
399402

400-
if self._config.sources.jira.enabled:
401-
sources.append("jira")
402-
if self._config.sources.fireflies.enabled:
403-
sources.append("fireflies")
404-
if self._config.sources.docs.enabled:
405-
sources.append("docs")
403+
# Search all sources in parallel
404+
jira_coro = self._search_jira(query)
405+
meetings_coro = self._search_meetings(query)
406+
docs_coro = self._search_docs(query)
406407

407-
# TODO: Implement real search across adapters
408-
logger.info("Search context", extra={"query": query, "sources": sources})
408+
results = await asyncio.gather(jira_coro, meetings_coro, docs_coro, return_exceptions=True)
409409

410-
duration_ms = int((time.monotonic() - start_time) * 1000)
410+
jira_results, meeting_results, docs_results = results
411411

412-
results = f"""## Search Results
412+
# Format results into markdown
413+
sections: list[str] = [f'## Search Results for "{query}"']
414+
sources_used: list[str] = []
415+
total_results = 0
416+
417+
# Format Jira results
418+
if isinstance(jira_results, list) and jira_results:
419+
sources_used.append("jira")
420+
total_results += len(jira_results)
421+
sections.append(self._format_jira_search_results(jira_results))
422+
elif isinstance(jira_results, BaseException):
423+
logger.warning("Jira search failed", extra={"error": str(jira_results)})
424+
425+
# Format meeting results
426+
if isinstance(meeting_results, MeetingContext) and meeting_results.meetings:
427+
sources_used.append("fireflies")
428+
total_results += len(meeting_results.meetings)
429+
sections.append(self._format_meeting_search_results(meeting_results))
430+
elif isinstance(meeting_results, BaseException):
431+
logger.warning("Meeting search failed", extra={"error": str(meeting_results)})
432+
433+
# Format docs results
434+
if isinstance(docs_results, DocsContext) and docs_results.sections:
435+
sources_used.append("docs")
436+
total_results += len(docs_results.sections)
437+
sections.append(self._format_docs_search_results(docs_results))
438+
elif isinstance(docs_results, BaseException):
439+
logger.warning("Docs search failed", extra={"error": str(docs_results)})
440+
441+
# Handle no results
442+
if total_results == 0:
443+
sections.append("\nNo results found.")
413444

414-
No real search implemented yet. Query: "{query}"
445+
duration_ms = int((time.monotonic() - start_time) * 1000)
415446

416-
This will search across:
417-
- Jira tickets (title, description, comments)
418-
- Meeting transcripts (full text search)
419-
- Local documentation (keyword matching)
420-
"""
447+
logger.info(
448+
"Search completed",
449+
extra={
450+
"query": query,
451+
"result_count": total_results,
452+
"sources": sources_used,
453+
"duration_ms": duration_ms,
454+
},
455+
)
421456

422457
return {
423458
"query": query,
424-
"results": results,
425-
"sources": sources if sources else ["none configured"],
426-
"result_count": 0,
459+
"results": "\n\n".join(sections),
460+
"sources": sources_used if sources_used else ["none"],
461+
"result_count": total_results,
427462
"duration_ms": duration_ms,
428463
}
429464

430-
async def get_standards(self, area: str | None = None) -> dict[str, str | None | int]:
465+
async def _search_jira(self, query: str) -> list[JiraTicket]:
466+
"""Search Jira for matching issues."""
467+
jira = self._get_jira()
468+
if jira is None:
469+
return []
470+
return await jira.search_issues(query, max_results=5)
471+
472+
async def _search_meetings(self, query: str) -> MeetingContext:
473+
"""Search meeting transcripts for query."""
474+
fireflies = self._get_fireflies()
475+
if fireflies is None:
476+
return MeetingContext(meetings=[])
477+
return await fireflies.get_meeting_context(query)
478+
479+
async def _search_docs(self, query: str) -> DocsContext:
480+
"""Search local documentation for query."""
481+
docs = self._get_docs()
482+
if docs is None:
483+
return DocsContext(sections=[])
484+
return await docs.search_docs(query, max_results=5)
485+
486+
def _format_jira_search_results(self, tickets: list[JiraTicket]) -> str:
487+
"""Format Jira search results as markdown."""
488+
parts = ["### Jira Tickets"]
489+
for ticket in tickets:
490+
status_badge = f"[{ticket.status}]"
491+
assignee = f" — {ticket.assignee}" if ticket.assignee else ""
492+
parts.append(f"- **{ticket.ticket_id}**: {ticket.title} {status_badge}{assignee}")
493+
return "\n".join(parts)
494+
495+
def _format_meeting_search_results(self, context: MeetingContext) -> str:
496+
"""Format meeting search results as markdown."""
497+
parts = ["### Meeting Discussions"]
498+
for meeting in context.meetings:
499+
date_str = meeting.meeting_date.strftime("%Y-%m-%d")
500+
parts.append(f"\n**{meeting.meeting_title}** ({date_str})")
501+
# Truncate excerpt for search results
502+
excerpt = meeting.excerpt
503+
if len(excerpt) > 300:
504+
excerpt = excerpt[:300] + "..."
505+
parts.append(excerpt)
506+
return "\n".join(parts)
507+
508+
def _format_docs_search_results(self, context: DocsContext) -> str:
509+
"""Format docs search results as markdown."""
510+
parts = ["### Documentation"]
511+
for section in context.sections:
512+
title = section.section_title or section.file_path
513+
doc_type = f"[{section.doc_type}]"
514+
parts.append(f"\n**{title}** {doc_type}")
515+
parts.append(f"*Source: {section.file_path}*")
516+
# Truncate content for search results
517+
content = section.content
518+
if len(content) > 200:
519+
content = content[:200] + "..."
520+
parts.append(content)
521+
return "\n".join(parts)
522+
523+
async def get_standards(
524+
self, area: str | None = None
525+
) -> dict[str, str | None | int | list[str]]:
431526
"""Get coding standards from local documentation.
432527
433528
Args:
@@ -440,23 +535,42 @@ async def get_standards(self, area: str | None = None) -> dict[str, str | None |
440535
logger.info("Get standards", extra={"area": area})
441536

442537
docs = self._get_docs()
538+
available_areas: list[str] = []
539+
443540
if docs is None:
444-
content = self._get_standards_not_configured_message(area)
541+
content = self._get_standards_not_configured_message()
445542
section_count = 0
446543
else:
447544
docs_context = await docs.get_standards(area)
545+
available_areas = await docs.list_standards_areas()
546+
448547
if docs_context.sections:
449-
# Format sections into markdown
450-
parts: list[str] = []
548+
# Format sections into markdown with header
549+
title = f"# Coding Standards: {area}" if area else "# Coding Standards"
550+
parts: list[str] = [title, ""]
551+
451552
for section in docs_context.sections:
553+
# Add source file info
554+
source_info = f"*Source: {section.file_path}*"
452555
if section.section_title:
453-
parts.append(f"## {section.section_title}\n\n{section.content}")
556+
parts.append(f"## {section.section_title}")
557+
parts.append(source_info)
558+
parts.append("")
559+
parts.append(section.content)
454560
else:
561+
parts.append(source_info)
562+
parts.append("")
455563
parts.append(section.content)
456-
content = "\n\n---\n\n".join(parts)
564+
parts.append("") # Blank line between sections
565+
566+
content = "\n".join(parts)
457567
section_count = len(docs_context.sections)
568+
elif area and available_areas:
569+
# Area specified but no matches - show available areas
570+
content = self._get_no_matching_standards_message(area, available_areas)
571+
section_count = 0
458572
else:
459-
content = self._get_standards_not_configured_message(area)
573+
content = self._get_standards_not_configured_message()
460574
section_count = 0
461575

462576
duration_ms = int((time.monotonic() - start_time) * 1000)
@@ -469,28 +583,57 @@ async def get_standards(self, area: str | None = None) -> dict[str, str | None |
469583
"area": area,
470584
"content": content,
471585
"section_count": section_count,
586+
"available_areas": available_areas,
587+
"duration_ms": duration_ms,
472588
}
473589

474-
def _get_standards_not_configured_message(self, area: str | None) -> str:
590+
def _get_standards_not_configured_message(self) -> str:
475591
"""Generate message when no standards are configured."""
476-
area_filter = f" for {area}" if area else ""
477-
return f"""## Coding Standards{area_filter}
592+
return """# Coding Standards
478593
479594
No standards documents found.
480595
481-
To add standards:
596+
## How to Configure
597+
482598
1. Create markdown files in your docs directory
483-
2. Configure `sources.docs.paths` in .devscontext.yaml
599+
2. Configure `sources.docs.paths` in `.devscontext.yaml`
484600
3. Place files in a `standards/` subdirectory
485601
486-
Example structure:
602+
### Example Structure
603+
487604
```
488605
docs/
489606
standards/
490607
typescript.md
491608
testing.md
492609
api-design.md
493610
```
611+
612+
### Example .devscontext.yaml
613+
614+
```yaml
615+
sources:
616+
docs:
617+
enabled: true
618+
paths:
619+
- ./docs
620+
```
621+
622+
Files in the `standards/` directory will be automatically recognized as coding standards.
623+
"""
624+
625+
def _get_no_matching_standards_message(self, area: str, available_areas: list[str]) -> str:
626+
"""Generate message when no standards match the requested area."""
627+
areas_list = "\n".join(f"- `{a}`" for a in available_areas)
628+
return f"""# Coding Standards: {area}
629+
630+
No standards found for "{area}".
631+
632+
## Available Areas
633+
634+
{areas_list}
635+
636+
Try one of the areas above, or omit the area parameter to see all standards.
494637
"""
495638

496639
async def close(self) -> None:

0 commit comments

Comments
 (0)