|
| 1 | +""" |
| 2 | +Bug Correlation — анализ связи багов с изменениями в коде. |
| 3 | +
|
| 4 | +Связывает: |
| 5 | +- Коммиты с ключевыми словами (fix, bug, hotfix, resolve) → "баг-фиксы" |
| 6 | +- Баг-фиксы с файлами и символами |
| 7 | +- Символы с "баго-историей" — сколько раз их правили из-за багов |
| 8 | +
|
| 9 | +Позволяет отвечать на вопросы: |
| 10 | +- "Какие модули чаще всего ломаются?" |
| 11 | +- "Какие файлы имеют наибольшую баго-нагрузку?" |
| 12 | +- "Кто чаще всего правит этот модуль?" |
| 13 | +""" |
| 14 | + |
| 15 | +import logging |
| 16 | +import re |
| 17 | +from collections import defaultdict |
| 18 | +from datetime import datetime, timedelta |
| 19 | +from pathlib import Path |
| 20 | +from typing import Dict, List, Optional, Any |
| 21 | + |
| 22 | +logger = logging.getLogger("bug_correlation") |
| 23 | + |
| 24 | +# Паттерны для определения баг-фиксов |
| 25 | +BUGFIX_PATTERNS = [ |
| 26 | + r'\bfix\b', |
| 27 | + r'\bbug\b', |
| 28 | + r'\bhotfix\b', |
| 29 | + r'\bresolve\b', |
| 30 | + r'\bpatch\b', |
| 31 | + r'\bworkaround\b', |
| 32 | + r'\bcorrect\b', |
| 33 | + r'\bissue\b', |
| 34 | + r'\bdefect\b', |
| 35 | + r'\bcrash\b', |
| 36 | + r'\berror\b', |
| 37 | + r'\bbroken\b', |
| 38 | +] |
| 39 | + |
| 40 | +BUGFIX_REGEX = re.compile('|'.join(BUGFIX_PATTERNS), re.IGNORECASE) |
| 41 | + |
| 42 | + |
| 43 | +class BugCorrelation: |
| 44 | + """Анализ связи багов с изменениями в коде.""" |
| 45 | + |
| 46 | + def __init__(self, commit_memory): |
| 47 | + """ |
| 48 | + Args: |
| 49 | + commit_memory: Инстанс CommitMemory с загруженными коммитами |
| 50 | + """ |
| 51 | + self.commit_memory = commit_memory |
| 52 | + self._bug_commits: List[Dict] = [] |
| 53 | + self._file_bug_count: Dict[str, int] = {} |
| 54 | + self._symbol_bug_count: Dict[str, int] = {} |
| 55 | + self._analyzed = False |
| 56 | + |
| 57 | + def analyze(self) -> Dict[str, Any]: |
| 58 | + """Полный анализ баго-корреляции. |
| 59 | +
|
| 60 | + Returns: |
| 61 | + Результаты анализа: баг-коммиты, статистика по файлам/символам |
| 62 | + """ |
| 63 | + commits = self.commit_memory._commits |
| 64 | + if not commits: |
| 65 | + self.commit_memory.fetch_commits() |
| 66 | + commits = self.commit_memory._commits |
| 67 | + |
| 68 | + self._bug_commits = [] |
| 69 | + self._file_bug_count = defaultdict(int) |
| 70 | + self._symbol_bug_count = defaultdict(int) |
| 71 | + |
| 72 | + for commit in commits: |
| 73 | + message = commit.get("message", "") |
| 74 | + if self._is_bugfix(message): |
| 75 | + self._bug_commits.append(commit) |
| 76 | + # Считаем баги по файлам |
| 77 | + for f in commit.get("files", []): |
| 78 | + self._file_bug_count[f] += 1 |
| 79 | + |
| 80 | + self._analyzed = True |
| 81 | + |
| 82 | + return { |
| 83 | + "total_commits": len(commits), |
| 84 | + "bugfix_commits": len(self._bug_commits), |
| 85 | + "bugfix_ratio": round(len(self._bug_commits) / max(len(commits), 1), 3), |
| 86 | + "top_buggy_files": self.get_top_buggy_files(10), |
| 87 | + } |
| 88 | + |
| 89 | + def _is_bugfix(self, message: str) -> bool: |
| 90 | + """Определяет является ли коммит баг-фиксом.""" |
| 91 | + return bool(BUGFIX_REGEX.search(message)) |
| 92 | + |
| 93 | + def get_bugfix_commits(self, limit: int = 50) -> List[Dict]: |
| 94 | + """Возвращает список баг-фикс коммитов.""" |
| 95 | + if not self._analyzed: |
| 96 | + self.analyze() |
| 97 | + return self._bug_commits[:limit] |
| 98 | + |
| 99 | + def get_top_buggy_files(self, top_n: int = 10) -> List[Dict]: |
| 100 | + """Возвращает файлы с наибольшим количеством баг-фиксов. |
| 101 | +
|
| 102 | + Returns: |
| 103 | + [{file, bug_count, total_commits, bug_ratio}] |
| 104 | + """ |
| 105 | + if not self._analyzed: |
| 106 | + self.analyze() |
| 107 | + |
| 108 | + # Считаем общее количество коммитов по файлам |
| 109 | + file_total = defaultdict(int) |
| 110 | + for commit in self.commit_memory._commits: |
| 111 | + for f in commit.get("files", []): |
| 112 | + file_total[f] += 1 |
| 113 | + |
| 114 | + # Сортируем по количеству багов |
| 115 | + sorted_files = sorted( |
| 116 | + self._file_bug_count.items(), |
| 117 | + key=lambda x: x[1], |
| 118 | + reverse=True |
| 119 | + )[:top_n] |
| 120 | + |
| 121 | + result = [] |
| 122 | + for file_path, bug_count in sorted_files: |
| 123 | + total = file_total.get(file_path, 0) |
| 124 | + result.append({ |
| 125 | + "file": file_path, |
| 126 | + "bug_count": bug_count, |
| 127 | + "total_commits": total, |
| 128 | + "bug_ratio": round(bug_count / max(total, 1), 3), |
| 129 | + }) |
| 130 | + |
| 131 | + return result |
| 132 | + |
| 133 | + def get_bug_history_for_file(self, file_path: str) -> Dict: |
| 134 | + """Получает полную баго-историю файла. |
| 135 | +
|
| 136 | + Args: |
| 137 | + file_path: Путь к файлу |
| 138 | +
|
| 139 | + Returns: |
| 140 | + {file, bug_commits, total_commits, bug_risk} |
| 141 | + """ |
| 142 | + if not self._analyzed: |
| 143 | + self.analyze() |
| 144 | + |
| 145 | + file_commits = self.commit_memory.get_commits_for_file(file_path) |
| 146 | + bug_commits = [c for c in file_commits if self._is_bugfix(c.get("message", ""))] |
| 147 | + |
| 148 | + # Определяем уровень риска |
| 149 | + total = len(file_commits) |
| 150 | + bug_count = len(bug_commits) |
| 151 | + ratio = bug_count / max(total, 1) |
| 152 | + |
| 153 | + if ratio >= 0.5: |
| 154 | + risk = "critical" |
| 155 | + elif ratio >= 0.3: |
| 156 | + risk = "high" |
| 157 | + elif ratio >= 0.1: |
| 158 | + risk = "medium" |
| 159 | + else: |
| 160 | + risk = "low" |
| 161 | + |
| 162 | + return { |
| 163 | + "file": file_path, |
| 164 | + "bug_commits": bug_commits, |
| 165 | + "total_commits": total, |
| 166 | + "bug_count": bug_count, |
| 167 | + "bug_ratio": round(ratio, 3), |
| 168 | + "bug_risk": risk, |
| 169 | + } |
| 170 | + |
| 171 | + def get_bug_history_for_symbol(self, symbol_name: str, file_path: str = "") -> Dict: |
| 172 | + """Получает баго-историю для символа. |
| 173 | +
|
| 174 | + Args: |
| 175 | + symbol_name: Имя символа |
| 176 | + file_path: Файл где символ определён |
| 177 | +
|
| 178 | + Returns: |
| 179 | + {symbol, file, bug_commits, risk} |
| 180 | + """ |
| 181 | + if not self._analyzed: |
| 182 | + self.analyze() |
| 183 | + |
| 184 | + # Ищем коммиты связанные с символом |
| 185 | + symbol_commits = self.commit_memory.get_commits_for_symbol(symbol_name, file_path) |
| 186 | + bug_commits = [c for c in symbol_commits if self._is_bugfix(c.get("message", ""))] |
| 187 | + |
| 188 | + return { |
| 189 | + "symbol": symbol_name, |
| 190 | + "file": file_path, |
| 191 | + "bug_commits": bug_commits, |
| 192 | + "bug_count": len(bug_commits), |
| 193 | + "total_commits": len(symbol_commits), |
| 194 | + "bug_risk": "high" if len(bug_commits) >= 3 else "medium" if len(bug_commits) >= 1 else "low", |
| 195 | + } |
| 196 | + |
| 197 | + def get_hotspots(self, top_n: int = 10) -> List[Dict]: |
| 198 | + """Находит "горячие точки" — файлы с высокой баго-нагрузкой. |
| 199 | +
|
| 200 | + Учитывает: |
| 201 | + - Количество баг-фиксов |
| 202 | + - Давность последнего баг-фикса |
| 203 | + - Частоту изменений |
| 204 | +
|
| 205 | + Returns: |
| 206 | + [{file, bug_score, last_bugfix, risk}] |
| 207 | + """ |
| 208 | + if not self._analyzed: |
| 209 | + self.analyze() |
| 210 | + |
| 211 | + hotspots = [] |
| 212 | + now = datetime.now() |
| 213 | + |
| 214 | + for file_path, bug_count in self._file_bug_count.items(): |
| 215 | + # Находим последний баг-фикс |
| 216 | + file_commits = self.commit_memory.get_commits_for_file(file_path) |
| 217 | + bug_commits = [c for c in file_commits if self._is_bugfix(c.get("message", ""))] |
| 218 | + |
| 219 | + last_bugfix = None |
| 220 | + if bug_commits: |
| 221 | + dates = [c.get("date", "") for c in bug_commits if c.get("date")] |
| 222 | + if dates: |
| 223 | + last_bugfix = max(dates) |
| 224 | + |
| 225 | + # Считаем score: чем больше багов и чем свежее — тем выше |
| 226 | + recency_bonus = 0 |
| 227 | + if last_bugfix: |
| 228 | + try: |
| 229 | + bug_date = datetime.fromisoformat(last_bugfix) |
| 230 | + days_ago = (now - bug_date).days |
| 231 | + if days_ago < 30: |
| 232 | + recency_bonus = 3 |
| 233 | + elif days_ago < 90: |
| 234 | + recency_bonus = 1 |
| 235 | + except (ValueError, TypeError): |
| 236 | + pass |
| 237 | + |
| 238 | + score = bug_count * 2 + recency_bonus |
| 239 | + |
| 240 | + hotspots.append({ |
| 241 | + "file": file_path, |
| 242 | + "bug_count": bug_count, |
| 243 | + "bug_score": score, |
| 244 | + "last_bugfix": last_bugfix, |
| 245 | + "risk": "critical" if score >= 10 else "high" if score >= 5 else "medium", |
| 246 | + }) |
| 247 | + |
| 248 | + hotspots.sort(key=lambda x: x["bug_score"], reverse=True) |
| 249 | + return hotspots[:top_n] |
| 250 | + |
| 251 | + def get_stats(self) -> Dict: |
| 252 | + """Статистика баго-корреляции.""" |
| 253 | + if not self._analyzed: |
| 254 | + self.analyze() |
| 255 | + |
| 256 | + return { |
| 257 | + "total_commits": len(self.commit_memory._commits), |
| 258 | + "bugfix_commits": len(self._bug_commits), |
| 259 | + "bugfix_ratio": round(len(self._bug_commits) / max(len(self.commit_memory._commits), 1), 3), |
| 260 | + "unique_buggy_files": len(self._file_bug_count), |
| 261 | + "top_risk_file": self.get_top_buggy_files(1)[0]["file"] if self._file_bug_count else None, |
| 262 | + } |
0 commit comments