salim0986/graph-bug-ai
0
1"""2Context Builder Service - Phase 3.53Combines graph queries, vector search, and code analysis into unified context for LangGraph4"""5 6from typing import List, Dict, Any, Optional7from pydantic import BaseModel8from .analyzer import CodeAnalyzer, PRAnalysisRequest, FileChange9from .graph_builder import GraphBuilder10from .vector_builder import VectorBuilder11from .logger import setup_logger12 13logger = setup_logger(__name__)14 15 16# ============================================================================17# MODELS18# ============================================================================19 20class ImpactReport(BaseModel):21 """Blast-radius report built from M5 multi-hop graph queries."""22 affected_functions: List[Dict[str, Any]] = []23 affected_files: List[str] = []24 cycles_detected: List[List[str]] = []25 untested_callers: List[Dict[str, Any]] = []26 total_affected_functions: int = 027 total_affected_files: int = 028 29 30class CodeContext(BaseModel):31 """Context for a single code entity"""32 name: str33 type: str # function, class, method, etc.34 file: str35 line: int36 code: str37 related_entities: List[Dict[str, Any]] = []38 similar_code: List[Dict[str, Any]] = []39 issues: List[Dict[str, Any]] = []40 41 42class FileContext(BaseModel):43 """Context for a single file"""44 filename: str45 language: str46 change_type: str # added, modified, deleted47 additions: int48 deletions: int49 entities: List[CodeContext] = []50 dependencies: List[str] = []51 issues_summary: Dict[str, int] = {}52 complexity_score: int = 053 54 55class PRContext(BaseModel):56 """Unified context for a Pull Request"""57 pr_number: int58 repo_id: str59 title: str60 description: Optional[str] = None61 62 # High-level metrics63 total_files: int64 total_additions: int65 total_deletions: int66 languages: List[str]67 68 # File-level context69 files: List[FileContext]70 71 # Impact analysis72 affected_callers: int = 0 # kept for backward compat; prefer `impact`73 impact: Optional[ImpactReport] = None74 complexity_hotspots: List[Dict[str, Any]] = []75 high_coupling_files: List[Dict[str, Any]] = []76 77 # Issues aggregation78 critical_issues: List[Dict[str, Any]] = []79 high_issues: List[Dict[str, Any]] = []80 medium_issues: List[Dict[str, Any]] = []81 issues_by_category: Dict[str, int] = {}82 83 # Recommendations84 recommendations: List[str] = []85 requires_deep_review: bool = False86 risk_level: str = "low" # low, medium, high, critical87 88 89# ============================================================================90# CONTEXT BUILDER91# ============================================================================92 93class ContextBuilder:94 """95 Builds comprehensive context for PR reviews by combining:96 - Code analysis (issues, patterns, smells)97 - Graph queries (dependencies, impact, coupling)98 - Vector search (similar code, duplicates)99 """100 101 def __init__(102 self,103 analyzer: CodeAnalyzer,104 graph_db: GraphBuilder,105 vector_db: VectorBuilder106 ):107 self.analyzer = analyzer108 self.graph_db = graph_db109 self.vector_db = vector_db110 111 async def build_pr_context(112 self,113 pr_number: int,114 repo_id: str,115 title: str,116 description: Optional[str],117 files: List[FileChange],118 base_ref: str = "main",119 head_ref: str = "unknown"120 ) -> PRContext:121 """122 Build comprehensive context for a PR review123 124 This is the main entry point that orchestrates all analysis125 """126 logger.info(f"[ContextBuilder] Building context for PR #{pr_number} in repo {repo_id}")127 logger.info(f"[ContextBuilder] Input: {len(files)} files, base={base_ref}, head={head_ref}")128 129 # 1. Analyze PR with CodeAnalyzer130 pr_analysis = await self.analyzer.analyze_pr(PRAnalysisRequest(131 pr_number=pr_number,132 repo_id=repo_id,133 files=files,134 base_ref=base_ref,135 head_ref=head_ref136 ))137 138 # 2. Build file-level contexts139 file_contexts = []140 for file_result in pr_analysis.file_results:141 file_ctx = await self._build_file_context(142 repo_id=repo_id,143 filename=file_result.filename,144 language=file_result.language,145 file_change=self._find_file_change(files, file_result.filename),146 issues=file_result.issues,147 similar_code=file_result.similar_code,148 related_code=file_result.related_code149 )150 file_contexts.append(file_ctx)151 152 # 3. Extract critical/high issues153 critical_issues = []154 high_issues = []155 medium_issues = []156 157 for file_result in pr_analysis.file_results:158 for issue in file_result.issues:159 issue_dict = {160 "file": file_result.filename,161 "severity": issue.severity,162 "category": issue.category,163 "title": issue.title,164 "description": issue.description,165 "line": issue.line_number,166 "suggestion": issue.suggestion167 }168 169 if issue.severity == "critical":170 critical_issues.append(issue_dict)171 elif issue.severity == "high":172 high_issues.append(issue_dict)173 elif issue.severity == "medium":174 medium_issues.append(issue_dict)175 176 # 4. Determine languages177 languages = list(set(fc.language for fc in file_contexts if fc.language))178 179 # 5. Generate recommendations180 recommendations = self._generate_recommendations(181 pr_analysis=pr_analysis,182 file_contexts=file_contexts,183 critical_issues=critical_issues,184 high_issues=high_issues185 )186 187 # 6. Calculate risk level188 risk_level = self._calculate_risk_level(189 pr_analysis=pr_analysis,190 critical_issues=critical_issues,191 high_issues=high_issues192 )193 194 # 7. M5: multi-hop impact analysis195 changed_file_paths = [f.filename for f in files]196 try:197 impact_report = self._build_impact_report(repo_id, changed_file_paths)198 except Exception as e:199 logger.warning(f"[M5] Impact report failed (non-fatal): {e}")200 impact_report = ImpactReport()201 202 # 8. Build PR context203 static_affected = pr_analysis.overall_metrics.get("total_affected_callers", 0)204 graph_affected = impact_report.total_affected_functions205 pr_context = PRContext(206 pr_number=pr_number,207 repo_id=repo_id,208 title=title,209 description=description,210 total_files=len(files),211 total_additions=sum(f.additions for f in files),212 total_deletions=sum(f.deletions for f in files),213 languages=languages,214 files=file_contexts,215 affected_callers=graph_affected or static_affected,216 impact=impact_report,217 complexity_hotspots=pr_analysis.overall_metrics.get("complexity_hotspots", []),218 high_coupling_files=pr_analysis.overall_metrics.get("high_coupling_files", []),219 critical_issues=critical_issues,220 high_issues=high_issues,221 medium_issues=medium_issues,222 issues_by_category=pr_analysis.issues_by_category,223 recommendations=recommendations,224 requires_deep_review=risk_level in ["high", "critical"],225 risk_level=risk_level226 )227 228 logger.info(f"Context built: {len(file_contexts)} files, "229 f"{len(critical_issues)} critical issues, "230 f"risk level: {risk_level}")231 232 return pr_context233 234 async def _build_file_context(235 self,236 repo_id: str,237 filename: str,238 language: str,239 file_change: Optional[FileChange],240 issues: List[Any],241 similar_code: List[Any],242 related_code: List[Any]243 ) -> FileContext:244 """Build context for a single file"""245 246 logger.debug(f"[ContextBuilder] Building context for file: {filename}")247 logger.debug(f"[ContextBuilder] repo_id={repo_id}, language={language}")248 249 # Get entities in the file from graph250 entities_data = self.graph_db.find_related_by_file(repo_id, filename, limit=50)251 logger.debug(f"[ContextBuilder] Found {len(entities_data)} entities from Neo4j for {filename}")252 253 # Build entity contexts with similar code from vector search254 entities = []255 for entity_data in entities_data:256 # Map similar code for this entity (from vector search)257 # Convert SimilarCode Pydantic models to dicts258 entity_similar = []259 for sim in similar_code:260 if hasattr(sim, 'file'): # It's a Pydantic model261 entity_similar.append({262 "file": sim.file,263 "score": sim.similarity_score,264 "line": sim.line,265 "snippet": sim.code_snippet[:200] if sim.code_snippet else "",266 "reason": sim.reason or ""267 })268 entity_similar = entity_similar[:3] # Top 3 similar269 270 # Convert RelatedCode objects to dicts271 entity_related = []272 for rel in related_code:273 if hasattr(rel, 'file'): # It's a Pydantic model274 entity_related.append({275 "file": rel.file,276 "name": rel.name,277 "type": rel.type,278 "reason": rel.reason,279 "line": rel.line280 })281 entity_related = entity_related[:5] # Top 5 related282 283 entity_ctx = CodeContext(284 name=entity_data["name"],285 type=entity_data["type"],286 file=filename,287 line=entity_data["line"],288 code=entity_data.get("code", ""),289 related_entities=entity_related,290 similar_code=entity_similar,291 issues=[]292 )293 entities.append(entity_ctx)294 295 # Get file dependencies296 dependencies = self.graph_db.find_file_dependencies(repo_id, filename)297 dep_files = [dep["file"] for dep in dependencies]298 logger.debug(f"[ContextBuilder] Found {len(dep_files)} dependencies for {filename}")299 300 # Aggregate issues by severity301 issues_summary = {302 "critical": len([i for i in issues if i.severity == "critical"]),303 "high": len([i for i in issues if i.severity == "high"]),304 "medium": len([i for i in issues if i.severity == "medium"]),305 "low": len([i for i in issues if i.severity == "low"]),306 }307 308 # Calculate complexity score (based on file size and issues)309 complexity_score = 0310 if file_change:311 complexity_score += file_change.additions + file_change.deletions312 complexity_score += sum(issues_summary.values()) * 10313 314 return FileContext(315 filename=filename,316 language=language,317 change_type=file_change.status if file_change else "unknown",318 additions=file_change.additions if file_change else 0,319 deletions=file_change.deletions if file_change else 0,320 entities=entities,321 dependencies=dep_files,322 issues_summary=issues_summary,323 complexity_score=min(complexity_score, 100) # Cap at 100324 )325 326 def _build_impact_report(327 self,328 repo_id: str,329 changed_files: List[str],330 ) -> ImpactReport:331 """332 Build a merged ImpactReport for all files changed in the PR.333 334 For each changed file:335 - Run find_cycles to detect call-graph cycles.336 - Fetch up to 10 entities defined in the file.337 - For each entity, call find_blast_radius (capped at 5 per file to338 keep query count reasonable).339 Results are deduplicated by (file, name) across all entities.340 Changed files are excluded from affected_files (they are already in scope).341 """342 all_functions: Dict[str, Dict] = {}343 all_files: set = set()344 all_cycles: List[List[str]] = []345 seen_cycles: set = set()346 untested_set: Dict[str, Dict] = {}347 348 for file_path in changed_files[:10]: # cap at 10 files349 try:350 cycles = self.graph_db.find_cycles(repo_id, file_path)351 for c in cycles:352 key = tuple(c)353 if key not in seen_cycles:354 seen_cycles.add(key)355 all_cycles.append(c)356 except Exception as exc:357 logger.warning(f"[M5] find_cycles failed for {file_path}: {exc}")358 359 try:360 entities = self.graph_db.find_related_by_file(repo_id, file_path, limit=10)361 except Exception as exc:362 logger.warning(f"[M5] find_related_by_file failed for {file_path}: {exc}")363 entities = []364 365 for entity in entities[:5]: # cap at 5 entities per file366 name = entity.get("name", "")367 if not name:368 continue369 try:370 blast = self.graph_db.find_blast_radius(repo_id, name)371 for func in blast["affected_functions"]:372 uid = f"{func.get('file', '')}::{func.get('name', '')}"373 if uid not in all_functions:374 all_functions[uid] = func375 all_files.update(blast["affected_files"])376 for uc in blast["untested_callers"]:377 uid = f"{uc.get('file', '')}::{uc.get('name', '')}"378 if uid not in untested_set:379 untested_set[uid] = uc380 except Exception as exc:381 logger.warning(f"[M5] find_blast_radius failed for {name}: {exc}")382 383 changed_set = set(changed_files)384 affected_files = sorted(all_files - changed_set)385 affected_functions = list(all_functions.values())386 387 return ImpactReport(388 affected_functions=affected_functions,389 affected_files=affected_files,390 cycles_detected=all_cycles,391 untested_callers=list(untested_set.values()),392 total_affected_functions=len(affected_functions),393 total_affected_files=len(affected_files),394 )395 396 def _find_file_change(self, files: List[FileChange], filename: str) -> Optional[FileChange]:397 """Find FileChange object for a filename"""398 for f in files:399 if f.filename == filename:400 return f401 return None402 403 def _generate_recommendations(404 self,405 pr_analysis: Any,406 file_contexts: List[FileContext],407 critical_issues: List[Dict],408 high_issues: List[Dict]409 ) -> List[str]:410 """Generate actionable recommendations for the PR"""411 recommendations = []412 413 # Security recommendations414 security_critical = len([i for i in critical_issues if i["category"] == "security"])415 security_high = len([i for i in high_issues if i["category"] == "security"])416 417 if security_critical > 0:418 recommendations.append(419 f"🚨 CRITICAL: {security_critical} critical security vulnerabilities detected. "420 "Address these immediately before merging."421 )422 423 if security_high > 0:424 recommendations.append(425 f"⚠️ {security_high} high-severity security issues found. "426 "Review and fix before deployment."427 )428 429 # Complexity recommendations430 high_complexity_files = [fc for fc in file_contexts if fc.complexity_score > 70]431 if high_complexity_files:432 files_str = ", ".join([f.filename for f in high_complexity_files[:3]])433 recommendations.append(434 f"📊 High complexity detected in {len(high_complexity_files)} files ({files_str}). "435 "Consider breaking down into smaller changes."436 )437 438 # Coupling recommendations439 if pr_analysis.overall_metrics.get("high_coupling_files"):440 recommendations.append(441 "🔗 High coupling detected between files. "442 "Review dependencies and consider reducing coupling."443 )444 445 # Impact recommendations446 affected_callers = pr_analysis.overall_metrics.get("total_affected_callers", 0)447 if affected_callers > 10:448 recommendations.append(449 f"🎯 This PR affects {affected_callers} calling functions across the codebase. "450 "Ensure comprehensive testing of impacted areas."451 )452 453 # Code quality recommendations454 quality_issues = sum(1 for i in critical_issues + high_issues if i["category"] == "code_quality")455 if quality_issues > 5:456 recommendations.append(457 f"✨ {quality_issues} code quality issues found. "458 "Address these to improve maintainability."459 )460 461 # Default recommendation if no issues462 if not recommendations:463 recommendations.append(464 "✅ No major issues detected. Code looks good for review!"465 )466 467 return recommendations468 469 def _calculate_risk_level(470 self,471 pr_analysis: Any,472 critical_issues: List[Dict],473 high_issues: List[Dict]474 ) -> str:475 """Calculate overall risk level for the PR"""476 477 # Critical if any critical security issues478 if any(i["category"] == "security" for i in critical_issues):479 return "critical"480 481 # Critical if many critical issues482 if len(critical_issues) >= 3:483 return "critical"484 485 # High if critical issues or many high issues486 if len(critical_issues) > 0 or len(high_issues) >= 5:487 return "high"488 489 # High if high impact490 affected_callers = pr_analysis.overall_metrics.get("total_affected_callers", 0)491 if affected_callers > 20:492 return "high"493 494 # Medium if some high issues495 if len(high_issues) > 0:496 return "medium"497 498 # Medium if large changeset499 if pr_analysis.overall_metrics.get("total_additions", 0) > 500:500 return "medium"501 502 return "low"503 504 def get_context_summary(self, pr_context: PRContext) -> str:505 """Generate a human-readable summary of the PR context"""506 summary_lines = [507 f"PR #{pr_context.pr_number}: {pr_context.title}",508 f"Risk Level: {pr_context.risk_level.upper()}",509 "",510 f"📊 Changes: {pr_context.total_additions} additions, {pr_context.total_deletions} deletions across {pr_context.total_files} files",511 f"💻 Languages: {', '.join(pr_context.languages)}",512 "",513 f"🐛 Issues: {len(pr_context.critical_issues)} critical, {len(pr_context.high_issues)} high, {len(pr_context.medium_issues)} medium",514 f"🎯 Impact: {pr_context.affected_callers} affected callers",515 ""516 ]517 518 if pr_context.impact and pr_context.impact.total_affected_functions:519 imp = pr_context.impact520 summary_lines.append(521 f"💥 Blast radius: {imp.total_affected_functions} function(s) "522 f"across {imp.total_affected_files} file(s) transitively affected"523 )524 if imp.cycles_detected:525 summary_lines.append(526 f"🔄 Cycles detected: {len(imp.cycles_detected)} call-graph cycle(s) "527 "involving changed files"528 )529 if imp.untested_callers:530 summary_lines.append(531 f"⚠️ Untested callers: {len(imp.untested_callers)} caller(s) with no "532 "apparent test coverage"533 )534 summary_lines.append("")535 536 if pr_context.recommendations:537 summary_lines.append("📝 Recommendations:")538 for rec in pr_context.recommendations:539 summary_lines.append(f" • {rec}")540 541 return "\n".join(summary_lines)542 543 # ============================================================================544 # ENHANCED FORMATTERS FOR RICH CODE CONTEXT (Phase 4.1 Enhancement)545 # ============================================================================546 547 def extract_code_snippet(548 self,549 patch: str,550 target_line: int,551 context_lines: int = 3552 ) -> Dict[str, Any]:553 """554 Extract code snippet from patch with context around a specific line555 556 Args:557 patch: Git diff patch558 target_line: Line number to extract context around559 context_lines: Number of lines before/after to include560 561 Returns:562 {563 "snippet": "formatted code with line numbers",564 "start_line": int,565 "end_line": int,566 "has_target": bool,567 "raw_lines": List[Dict]568 }569 """570 import re571 572 if not patch:573 return {574 "snippet": "",575 "start_line": target_line,576 "end_line": target_line,577 "has_target": False,578 "raw_lines": []579 }580 581 lines = patch.split('\n')582 snippet_lines = []583 current_line = 0584 target_found = False585 586 for line in lines:587 # Parse hunk headers to track line numbers588 if line.startswith('@@'):589 match = re.search(r'@@ -\d+,?\d* \+(\d+),?\d* @@', line)590 if match:591 current_line = int(match.group(1)) - 1592 continue593 594 # Process added lines595 if line.startswith('+') and not line.startswith('+++'):596 current_line += 1597 if abs(current_line - target_line) <= context_lines:598 snippet_lines.append({599 "line": current_line,600 "code": line[1:], # Remove + prefix601 "type": "added",602 "is_target": current_line == target_line603 })604 if current_line == target_line:605 target_found = True606 607 # Process removed lines608 elif line.startswith('-') and not line.startswith('---'):609 if abs(current_line - target_line) <= context_lines:610 snippet_lines.append({611 "line": current_line,612 "code": line[1:], # Remove - prefix613 "type": "removed"614 })615 616 # Process context lines617 elif line.startswith(' '):618 current_line += 1619 if abs(current_line - target_line) <= context_lines:620 snippet_lines.append({621 "line": current_line,622 "code": line[1:], # Remove space prefix623 "type": "context"624 })625 626 # Format snippet with markers627 formatted_lines = []628 for item in snippet_lines:629 prefix = "→" if item.get("is_target") else " "630 marker = "+" if item["type"] == "added" else "-" if item["type"] == "removed" else " "631 formatted_lines.append(f"{prefix} {item['line']:3d} {marker} {item['code']}")632 633 return {634 "snippet": "\n".join(formatted_lines),635 "start_line": snippet_lines[0]["line"] if snippet_lines else target_line,636 "end_line": snippet_lines[-1]["line"] if snippet_lines else target_line,637 "has_target": target_found,638 "raw_lines": snippet_lines639 }640 641 def format_similar_code_with_snippets(642 self,643 similar_code: List[Any],644 max_items: int = 5645 ) -> str:646 """647 Format similar code with actual code snippets for richer context648 649 Args:650 similar_code: List of similar code items651 max_items: Maximum number of items to include652 653 Returns:654 Formatted string with code snippets655 """656 if not similar_code:657 return "None"658 659 formatted = []660 for idx, similar in enumerate(similar_code[:max_items], 1):661 # Handle different object types662 if hasattr(similar, 'file'):663 file = similar.file664 line = similar.line665 score = similar.similarity_score666 snippet = getattr(similar, 'code_snippet', None)667 reason = getattr(similar, 'reason', 'Similar pattern detected')668 else:669 file = similar.get('file', 'unknown')670 line = similar.get('line', 0)671 score = similar.get('similarity', 0.0)672 snippet = similar.get('code_snippet') or similar.get('code')673 reason = similar.get('reason', 'Similar pattern detected')674 675 entry = f"\n**{idx}. {file}:L{line}** (similarity: {score:.2f})\n"676 677 if snippet:678 # Truncate if too long679 if len(snippet) > 300:680 snippet = snippet[:300] + "\n..."681 682 # Detect language from file extension683 language = self._detect_language_from_filename(file)684 685 entry += f"```{language}\n{snippet}\n```\n"686 687 entry += f"_{reason}_\n"688 formatted.append(entry)689 690 return "\n".join(formatted)691 692 def format_dependencies_with_impact(693 self,694 dependencies: List[Any],695 dependents: List[Any],696 filename: str697 ) -> str:698 """699 Format dependencies with impact analysis700 701 Args:702 dependencies: List of upstream dependencies703 dependents: List of downstream dependents704 filename: Current file name705 706 Returns:707 Formatted string with dependency impact708 """709 if not dependencies and not dependents:710 return "None - File is isolated"711 712 parts = []713 714 # Upstream dependencies715 if dependencies:716 parts.append("**Upstream (what this file depends on):**")717 for dep in dependencies[:5]:718 dep_name = dep if isinstance(dep, str) else dep.get("name", "unknown")719 parts.append(f"• `{dep_name}`")720 parts.append(f" _Changes to this dependency may require updates here_")721 722 # Downstream dependents723 if dependents:724 if parts:725 parts.append("")726 parts.append("**Downstream (what depends on this file):**")727 for dependent in dependents[:5]:728 dep_name = dependent if isinstance(dependent, str) else dependent.get("name", "unknown")729 parts.append(f"• `{dep_name}`")730 parts.append(f" _Changes here will affect this file_")731 732 return "\n".join(parts)733 734 def format_entities_with_relationships(735 self,736 entities: List[Any],737 max_entities: int = 8738 ) -> str:739 """740 Format entities with their relationships and call graphs741 742 Args:743 entities: List of code entities (functions, classes)744 max_entities: Maximum entities to include745 746 Returns:747 Formatted string with entity relationships748 """749 if not entities:750 return "None"751 752 formatted = []753 for entity in entities[:max_entities]:754 # Handle different object types755 if hasattr(entity, 'name'):756 name = entity.name757 entity_type = entity.type758 entity_dict = entity.model_dump() if hasattr(entity, 'model_dump') else {}759 else:760 name = entity.get("name", "unknown")761 entity_type = entity.get("type", "function")762 entity_dict = entity763 764 # Build tree structure765 tree = [f"**`{name}`** ({entity_type})"]766 767 # Get relationships768 calls = entity_dict.get("calls", [])769 called_by = entity_dict.get("called_by", [])770 dependencies = entity_dict.get("dependencies", [])771 772 if calls:773 calls_list = ", ".join(f"`{c}`" for c in calls[:5])774 if len(calls) > 5:775 calls_list += f" _(+{len(calls)-5} more)_"776 tree.append(f"├─ Calls: {calls_list}")777 778 if called_by:779 callers = ", ".join(f"`{c}`" for c in called_by[:3])780 if len(called_by) > 3:781 callers += f" _(+{len(called_by)-3} more)_"782 tree.append(f"├─ Called by: {callers}")783 784 if dependencies:785 deps = ", ".join(f"`{d}`" for d in dependencies[:3])786 tree.append(f"└─ Uses: {deps}")787 788 formatted.append("\n".join(tree))789 790 return "\n\n".join(formatted)791 792 def _detect_language_from_filename(self, filename: str) -> str:793 """Detect programming language from filename"""794 ext_map = {795 '.py': 'python', '.js': 'javascript', '.ts': 'typescript',796 '.tsx': 'tsx', '.jsx': 'jsx', '.java': 'java',797 '.go': 'go', '.rs': 'rust', '.cpp': 'cpp', '.c': 'c',798 '.rb': 'ruby', '.php': 'php', '.cs': 'csharp'799 }800 801 import os802 _, ext = os.path.splitext(filename)803 return ext_map.get(ext.lower(), '')804 