review
Ядро ревью: сборка diff, вызов LLM, постинг ревью в Gitea.
Используется и webhook-хендлером (app.py), и CLI (run_review.py).
1""" 2Ядро ревью: сборка diff, вызов LLM, постинг ревью в Gitea. 3 4Используется и webhook-хендлером (app.py), и CLI (run_review.py). 5""" 6 7from __future__ import annotations 8 9import re 10import threading 11from dataclasses import dataclass, field 12 13from loguru import logger 14 15from config import Config 16from gitea_client import GiteaClient 17from reviewer import ( 18 CRITICAL, 19 WARNING, 20 build_prompt, 21 call_llm, 22 parse_response, 23 truncate_diff, 24) 25 26try: 27 from langfuse import observe 28except ImportError: 29 30 def observe(**kwargs): 31 def decorator(fn): 32 return fn 33 34 return decorator 35 36 37EVENT_COMMENT = "COMMENT" 38EVENT_REQUEST_CHANGES = "REQUEST_CHANGES" 39EVENT_APPROVED = "APPROVED" 40 41# Цветные точки статуса перед пунктами code review: 42# зелёная — замечаний нет, жёлтая — незначительные, красная — критические. 43GREEN_DOT = "🟢" 44YELLOW_DOT = "🟡" 45RED_DOT = "🔴" 46 47SEVERITY_DOTS = {CRITICAL: RED_DOT, WARNING: YELLOW_DOT} 48 49 50def _finding_dot(severity: str) -> str: 51 """Точка критичности замечания (неизвестный уровень — жёлтая).""" 52 return SEVERITY_DOTS.get(severity, YELLOW_DOT) 53 54 55def _finding_mark(f) -> str: 56 """Строка пункта ревью с цветной точкой и локацией.""" 57 loc = f"{f.path}:{f.line}" if f.line > 0 else f.path 58 return f"- {_finding_dot(f.severity)} `{loc}`: {f.comment}" 59 60 61def _status_line(result) -> str: 62 """Итоговая строка статуса ревью с цветной точкой.""" 63 if not result.has_any: 64 return f"{GREEN_DOT} Замечаний нет." 65 if result.has_critical: 66 return f"{RED_DOT} Есть критические замечания." 67 return f"{YELLOW_DOT} Есть незначительные замечания." 68 69 70def _build_body(result, notes: list[str]) -> str: 71 """Собирает тело ревью: заголовок, статус с точкой, summary, заметки.""" 72 parts = ["## Code review", _status_line(result), ""] 73 if result.summary: 74 parts.append(result.summary) 75 if notes: 76 parts.extend(["", "Не удалось привязать к строкам:", *notes]) 77 return "\n".join(parts).strip() 78 79 80# Признак ревью, поставленного этим ботом: имя пользователя в review. 81# Gitea сам не помечает источник ревью, поэтому используем login бота. 82 83 84def _changed_files(diff: str) -> set[str]: 85 """Извлекает изменённые пути из unified diff (без удалённых файлов).""" 86 paths: set[str] = set() 87 for line in diff.splitlines(): 88 if line.startswith("+++ b/"): 89 path = line[6:].strip() 90 if path and path != "/dev/null": 91 paths.add(path) 92 return paths 93 94 95_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@") 96 97 98def _parse_diff_hunks(diff: str) -> dict[str, set[int]]: 99 """ 100 Карта «путь → номера строк нового файла, присутствующие в диффе». 101 102 Учитываются только new-side строки: добавленные («+») и контекстные. 103 Строки, которые только удалены («-»), в новом файле не существуют 104 и в карту не попадают. 105 """ 106 hunks: dict[str, set[int]] = {} 107 path: str | None = None 108 new_line = 0 109 110 for line in diff.splitlines(): 111 if line.startswith("diff --git"): 112 path = None 113 new_line = 0 114 continue 115 if line.startswith("+++ b/"): 116 new_path = line[6:].strip() 117 path = new_path if new_path and new_path != "/dev/null" else None 118 new_line = 0 119 continue 120 if path is None: 121 continue 122 m = _HUNK_RE.match(line) 123 if m: 124 new_line = int(m.group(1)) 125 continue 126 if new_line == 0 or line.startswith("\\"): 127 continue 128 marker = line[:1] 129 if marker in ("+", " "): 130 hunks.setdefault(path, set()).add(new_line) 131 new_line += 1 132 elif marker == "-": 133 # удалённые строки не занимают позицию в новом файле 134 continue 135 return hunks 136 137 138SNAP_WINDOW = 10 139 140 141def _snap_line(line: int, hunk_lines: set[int]) -> int | None: 142 """ 143 Возвращает строку для привязки: сам line, если он в hunk'ах, иначе 144 ближайшую строку hunk'ов в окне SNAP_WINDOW; иначе None (не привязывать). 145 При равной дистанции берётся меньший номер строки. 146 """ 147 if line in hunk_lines: 148 return line 149 if not hunk_lines: 150 return None 151 best: tuple[int, int] | None = None 152 for candidate in hunk_lines: 153 distance = abs(candidate - line) 154 if distance <= SNAP_WINDOW and ( 155 best is None 156 or distance < best[0] 157 or (distance == best[0] and candidate < best[1]) 158 ): 159 best = (distance, candidate) 160 return best[1] if best else None 161 162 163def _file_line_count(text: str) -> int: 164 """Число строк в файле (пустой файл — 0).""" 165 if not text: 166 return 0 167 if not text.endswith("\n"): 168 return text.count("\n") + 1 169 return text.count("\n") 170 171 172def _is_binary(diff: str) -> bool: 173 """Определяет бинарный файл по маркерам в diff.""" 174 return "Binary files" in diff or "GIT binary patch" in diff 175 176 177def _pr_diff( 178 client: GiteaClient, owner: str, repo: str, index: int, max_chars: int 179) -> str: 180 """ 181 Совокупный дифф PR vs base-ветка. 182 183 Основной путь — pulls/{index}.diff (в Gitea 1.19 compare API даёт 404). 184 Если он не сработал, фолбэк — сумма диффов всех коммитов PR. 185 """ 186 try: 187 return truncate_diff(client.get_pr_diff(owner, repo, index), max_chars) 188 except Exception as e: 189 logger.warning(f"Gitea: не получить PR diff ({e}), фолбэк на диффы коммитов") 190 parts: list[str] = [] 191 for sha in client.get_pr_commits(owner, repo, index): 192 diff = client.get_commit_diff(owner, repo, sha) 193 if _is_binary(diff): 194 continue 195 parts.append(diff) 196 return truncate_diff("\n".join(parts), max_chars) 197 198 199@dataclass 200class ReviewOutcome: 201 """Результат выполнения ревью одного PR.""" 202 203 skipped: bool = False 204 reason: str = "" 205 event: str | None = None 206 findings: int = 0 207 208 209@dataclass 210class _LockRegistry: 211 """Реестр per-PR блокировок против гонки webhook'ов.""" 212 213 _locks: dict[str, threading.Lock] = field(default_factory=dict) 214 _guard: threading.Lock = field(default_factory=threading.Lock) 215 216 def get(self, owner: str, repo: str, index: int) -> threading.Lock: 217 key = f"{owner}/{repo}#{index}" 218 with self._guard: 219 return self._locks.setdefault(key, threading.Lock()) 220 221 222_LOCKS = _LockRegistry() 223 224 225def _existing_bot_reviews( 226 client: GiteaClient, config: Config, owner: str, repo: str, index: int 227) -> list[dict]: 228 """Ревью PR, оставленные ботом.""" 229 reviews = client.list_reviews(owner, repo, index) 230 bot = config.gitea_bot_username.lower() 231 return [ 232 r for r in reviews if str(r.get("user", {}).get("login", "")).lower() == bot 233 ] 234 235 236def _already_reviewed( 237 client: GiteaClient, 238 config: Config, 239 owner: str, 240 repo: str, 241 index: int, 242 head_sha: str, 243) -> bool: 244 """Есть ли ревью бота на этот самый head-коммит.""" 245 for r in _existing_bot_reviews(client, config, owner, repo, index): 246 if r.get("commit_id") == head_sha: 247 return True 248 return False 249 250 251def _build_inline_comments( 252 client: GiteaClient, 253 config: Config, 254 owner: str, 255 repo: str, 256 head_sha: str, 257 result, 258 diff: str, 259) -> tuple[list[dict], list[str]]: 260 """ 261 Собирает инлайн-комментарии с валидацией позиций. 262 263 Позиция (номер строки нового файла) берётся из ответа LLM и проверяется 264 по реальной длине файла на head-коммите и по hunk'ам диффа. Если строка 265 вне hunk'ов, но близко — привязываем к ближайшей строке диффа (снап), 266 иначе замечание не теряется — попадает в summary_notes. 267 """ 268 changed = _changed_files(diff) 269 hunk_lines = _parse_diff_hunks(diff) 270 comments: list[dict] = [] 271 notes: list[str] = [] 272 273 for f in result.findings: 274 if f.path not in changed: 275 notes.append(_finding_mark(f) + " (файл не изменён)") 276 continue 277 if f.line <= 0: 278 notes.append(_finding_mark(f)) 279 continue 280 try: 281 content = client.get_raw_file(owner, repo, head_sha, f.path) 282 except Exception as e: 283 logger.warning(f"Gitea: не прочитать файл {f.path}: {e}") 284 notes.append(_finding_mark(f)) 285 continue 286 if f.line > _file_line_count(content): 287 notes.append(_finding_mark(f) + " (строка вне файла)") 288 continue 289 line = _snap_line(f.line, hunk_lines.get(f.path, set())) 290 if line is None: 291 notes.append(_finding_mark(f) + " (строка вне диффа)") 292 continue 293 comments.append( 294 { 295 "path": f.path, 296 "body": f"{_finding_dot(f.severity)} {f.comment}", 297 # Gitea 1.19.3: CreatePullReviewComment использует 298 # new_position/old_position (НЕ new_line_num). 299 "new_position": line, 300 "old_position": 0, 301 } 302 ) 303 return comments, notes 304 305 306def _compute_event(config: Config, result, had_blocking_review: bool) -> str: 307 """ 308 Определяет event ревью. 309 310 Критичные замечания при включённом block_on_critical -> REQUEST_CHANGES 311 (блокирует мерж при block_on_rejected_reviews в защите ветки). 312 Если критичных нет, но раньше бот уже блокировал этот PR — 313 ставим APPROVED, чтобы снять блок после исправлений. Иначе — COMMENT. 314 """ 315 if config.block_on_critical and result.has_critical: 316 return EVENT_REQUEST_CHANGES 317 if had_blocking_review: 318 return EVENT_APPROVED 319 return EVENT_COMMENT 320 321 322def _build_error_body(message: str, detail: str = "") -> str: 323 """Тело ревью при сбое (LLM недоступен / битый ответ).""" 324 parts = ["## Code review", f"⚠️ {message}."] 325 if detail: 326 parts.extend(["", "```", detail, "```"]) 327 return "\n".join(parts) 328 329 330def _post_failed_review( 331 client: GiteaClient, 332 owner: str, 333 repo: str, 334 index: int, 335 head_sha: str, 336 message: str, 337 detail: str = "", 338) -> None: 339 """ 340 Постит COMMENT-ревью о том, что ревью не выполнено. 341 342 Фиксирует head_sha как «отревьюенный», чтобы не спамить повторными 343 попытками на синхронизациях без новых коммитов. 344 """ 345 try: 346 client.post_review( 347 owner, 348 repo, 349 index, 350 body=_build_error_body(message, detail), 351 event=EVENT_COMMENT, 352 commit_id=head_sha, 353 ) 354 except Exception as e: 355 logger.error(f"Gitea: не удалось опубликовать ревью о сбое: {e}") 356 357 358@observe(name="Review PR", capture_input=False) 359def run_review( 360 client: GiteaClient, 361 config: Config, 362 owner: str, 363 repo: str, 364 index: int, 365 head_sha: str | None = None, 366) -> ReviewOutcome: 367 """ 368 Выполняет LLM-ревью PR и постит результат в Gitea. 369 370 Идемпотентность: если ревью бота на этот head-коммит уже есть — пропуск. 371 """ 372 lock = _LOCKS.get(owner, repo, index) 373 with lock: 374 pr = client.get_pr(owner, repo, index) 375 if head_sha is None: 376 head_sha = pr["head"]["sha"] 377 378 if _already_reviewed(client, config, owner, repo, index, head_sha): 379 return ReviewOutcome(skipped=True, reason="уже отревьюено на этом коммите") 380 381 diff = _pr_diff(client, owner, repo, index, config.max_diff_chars) 382 383 if not diff.strip(): 384 return ReviewOutcome(skipped=True, reason="нет текстовых изменений") 385 386 prompt = build_prompt(diff, pr.get("title", ""), pr.get("body", "")) 387 try: 388 raw = call_llm(config, prompt) 389 except Exception as e: 390 logger.error(f"LLM: не удалось получить ответ: {e}") 391 _post_failed_review( 392 client, 393 owner, 394 repo, 395 index, 396 head_sha, 397 "не удалось получить ответ от LLM", 398 str(e), 399 ) 400 return ReviewOutcome(event=EVENT_COMMENT, findings=0) 401 try: 402 result = parse_response(raw) 403 except Exception as e: 404 logger.error(f"LLM: не удалось разобрать ответ: {e}") 405 snippet = (raw or "").strip()[:300] 406 detail = f"{e}" + ( 407 f"\n\nОтвет LLM (фрагмент):\n{snippet}" if snippet else "" 408 ) 409 _post_failed_review( 410 client, 411 owner, 412 repo, 413 index, 414 head_sha, 415 "не удалось разобрать ответ LLM", 416 detail, 417 ) 418 return ReviewOutcome(event=EVENT_COMMENT, findings=0) 419 420 comments, notes = _build_inline_comments( 421 client, config, owner, repo, head_sha, result, diff 422 ) 423 424 body = _build_body(result, notes) 425 426 previous = _existing_bot_reviews(client, config, owner, repo, index) 427 had_blocking = any( 428 str(r.get("state", "")).upper() == "REQUEST_CHANGES" for r in previous 429 ) 430 event = _compute_event(config, result, had_blocking) 431 432 client.post_review( 433 owner, 434 repo, 435 index, 436 body=body, 437 event=event, 438 commit_id=head_sha, 439 comments=comments, 440 ) 441 logger.info( 442 f"Ревью {owner}/{repo}#{index} ({head_sha[:8]}): " 443 f"event={event}, findings={len(result.findings)}, inline={len(comments)}" 444 ) 445 return ReviewOutcome(event=event, findings=len(result.findings))
EVENT_COMMENT =
'COMMENT'
EVENT_REQUEST_CHANGES =
'REQUEST_CHANGES'
EVENT_APPROVED =
'APPROVED'
GREEN_DOT =
'🟢'
YELLOW_DOT =
'🟡'
RED_DOT =
'🔴'
SEVERITY_DOTS =
{'critical': '🔴', 'warning': '🟡'}
SNAP_WINDOW =
10
@dataclass
class
ReviewOutcome:
200@dataclass 201class ReviewOutcome: 202 """Результат выполнения ревью одного PR.""" 203 204 skipped: bool = False 205 reason: str = "" 206 event: str | None = None 207 findings: int = 0
Результат выполнения ревью одного PR.
@observe(name='Review PR', capture_input=False)
def
run_review( client: gitea_client.GiteaClient, config: config.Config, owner: str, repo: str, index: int, head_sha: str | None = None) -> ReviewOutcome:
359@observe(name="Review PR", capture_input=False) 360def run_review( 361 client: GiteaClient, 362 config: Config, 363 owner: str, 364 repo: str, 365 index: int, 366 head_sha: str | None = None, 367) -> ReviewOutcome: 368 """ 369 Выполняет LLM-ревью PR и постит результат в Gitea. 370 371 Идемпотентность: если ревью бота на этот head-коммит уже есть — пропуск. 372 """ 373 lock = _LOCKS.get(owner, repo, index) 374 with lock: 375 pr = client.get_pr(owner, repo, index) 376 if head_sha is None: 377 head_sha = pr["head"]["sha"] 378 379 if _already_reviewed(client, config, owner, repo, index, head_sha): 380 return ReviewOutcome(skipped=True, reason="уже отревьюено на этом коммите") 381 382 diff = _pr_diff(client, owner, repo, index, config.max_diff_chars) 383 384 if not diff.strip(): 385 return ReviewOutcome(skipped=True, reason="нет текстовых изменений") 386 387 prompt = build_prompt(diff, pr.get("title", ""), pr.get("body", "")) 388 try: 389 raw = call_llm(config, prompt) 390 except Exception as e: 391 logger.error(f"LLM: не удалось получить ответ: {e}") 392 _post_failed_review( 393 client, 394 owner, 395 repo, 396 index, 397 head_sha, 398 "не удалось получить ответ от LLM", 399 str(e), 400 ) 401 return ReviewOutcome(event=EVENT_COMMENT, findings=0) 402 try: 403 result = parse_response(raw) 404 except Exception as e: 405 logger.error(f"LLM: не удалось разобрать ответ: {e}") 406 snippet = (raw or "").strip()[:300] 407 detail = f"{e}" + ( 408 f"\n\nОтвет LLM (фрагмент):\n{snippet}" if snippet else "" 409 ) 410 _post_failed_review( 411 client, 412 owner, 413 repo, 414 index, 415 head_sha, 416 "не удалось разобрать ответ LLM", 417 detail, 418 ) 419 return ReviewOutcome(event=EVENT_COMMENT, findings=0) 420 421 comments, notes = _build_inline_comments( 422 client, config, owner, repo, head_sha, result, diff 423 ) 424 425 body = _build_body(result, notes) 426 427 previous = _existing_bot_reviews(client, config, owner, repo, index) 428 had_blocking = any( 429 str(r.get("state", "")).upper() == "REQUEST_CHANGES" for r in previous 430 ) 431 event = _compute_event(config, result, had_blocking) 432 433 client.post_review( 434 owner, 435 repo, 436 index, 437 body=body, 438 event=event, 439 commit_id=head_sha, 440 comments=comments, 441 ) 442 logger.info( 443 f"Ревью {owner}/{repo}#{index} ({head_sha[:8]}): " 444 f"event={event}, findings={len(result.findings)}, inline={len(comments)}" 445 ) 446 return ReviewOutcome(event=event, findings=len(result.findings))
Выполняет LLM-ревью PR и постит результат в Gitea.
Идемпотентность: если ревью бота на этот head-коммит уже есть — пропуск.