CoolFace
Apppublic

salim0986/graph-bug-ai

sourceHugging Facemitupdated 4mo agoView on Hugging Face
0likes
context_builder.py804 linesDownload Raw Back to src
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