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.

ReviewOutcome( skipped: bool = False, reason: str = '', event: str | None = None, findings: int = 0)
skipped: bool = False
reason: str = ''
event: str | None = None
findings: int = 0
@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-коммит уже есть — пропуск.