diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml new file mode 100644 index 0000000..a0b3b60 --- /dev/null +++ b/.github/workflows/ai-review.yml @@ -0,0 +1,48 @@ +name: AI Review + +# Автоматическое AI-ревью каждого PR: пайплайн достаёт diff, гоняет RAG +# (документация + код) + LLM и постит разбор комментарием в пул-реквест. +# +# Запускается на self-hosted раннере (машина с Ollama для эмбеддингов bge-m3 и +# готовыми FAISS-индексами в ~/rag-kotlin и ~/.jarvis). Ключ DeepSeek — из +# secrets репозитория, gh авторизуется через GH_TOKEN. + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +concurrency: + # Новый пуш в PR отменяет ещё не законченное ревью прошлого коммита. + group: ai-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + review: + runs-on: [self-hosted] + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup venv + deps + run: | + python3 -m venv .venv + ./.venv/bin/pip install --quiet --upgrade pip + ./.venv/bin/pip install --quiet -e ".[rag]" + + - name: AI review + env: + GH_TOKEN: ${{ github.token }} + LLM_PROVIDER: deepseek + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + # Пути к готовым индексам на self-hosted машине (переопредели при желании). + RAG_INDEX_PATH: ~/.jarvis/rag/docs_index + CODE_INDEX_PATH: ~/rag-kotlin/index_jarvis_full + RAG_STRATEGY: structural + run: | + ./.venv/bin/python review_pr.py "${{ github.event.pull_request.number }}" diff --git a/app/ports.py b/app/ports.py index 929614b..8c4a1a5 100644 --- a/app/ports.py +++ b/app/ports.py @@ -13,6 +13,7 @@ from domain.mcp import McpServerConfig, McpTool, ToolResult from domain.profile import Profile from domain.retrieval import RetrievedChunk +from domain.review import PrDiff from domain.task import Task from domain.working_memory import WorkingMemory @@ -160,6 +161,31 @@ def current_branch(self) -> Optional[str]: ... +class DiffProvider(Protocol): + """Источник изменений пул-реквеста для AI-ревью. + + Абстрагирует, откуда берётся diff: конкретная реализация в infra/ + ходит за ним в GitHub через `gh`. Use case `review_pull_request` + знает только про этот порт, поэтому в тестах подменяется фейком. + """ + + def fetch(self, pr: str) -> PrDiff: + """Получить diff и список изменённых файлов пул-реквеста `pr`.""" + ... + + +class ReviewPublisher(Protocol): + """Публикация текста ревью обратно в пул-реквест. + + Реализация в infra/ постит комментарий через `gh pr comment`. + Пайплайн (composition root) выбирает, публиковать или только печатать. + """ + + def publish(self, pr: str, body: str) -> None: + """Опубликовать ревью `body` комментарием к пул-реквесту `pr`.""" + ... + + class McpConfigRepository(Protocol): """Хранилище конфигурации MCP-серверов (~/.jarvis/mcp/servers.json).""" diff --git a/app/pr_review.py b/app/pr_review.py new file mode 100644 index 0000000..3beebed --- /dev/null +++ b/app/pr_review.py @@ -0,0 +1,130 @@ +"""Use case: AI-ревью пул-реквеста по его diff + RAG-контексту проекта. + +Ассистент получает diff и список изменённых файлов, подмешивает релевантные +фрагменты документации и кода (RAG), и выдаёт структурированное ревью: +потенциальные баги, архитектурные проблемы, рекомендации. + +Оркестрирует два порта — `RetrievalEngine` и `LLMClient` — и ничего не знает +об их реализациях (FAISS/Ollama, HTTP, gh). Diff приходит уже готовым (его +достаёт `DiffProvider` в composition root), поэтому use case чист и тестируется +на фейках. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + +from app.ports import LLMClient, RetrievalEngine +from domain.retrieval import RetrievedChunk + +SYSTEM_PROMPT = ( + "Ты — старший инженер, делающий код-ревью пул-реквеста в проекте jarvis-cli. " + "Проект придерживается слоистой архитектуры (cli → app → domain → infra), " + "внедрения зависимостей через конструктор и обязательных тестов на фейках. " + "Анализируй ТОЛЬКО присланный diff, опираясь на приведённый контекст из " + "документации и кода проекта. Не выдумывай изменений, которых нет в diff. " + "Ответ дай на русском строго в таком формате из трёх разделов Markdown:\n" + "## 🐞 Потенциальные баги\n" + "## 🏛 Архитектурные проблемы\n" + "## 💡 Рекомендации\n" + "В каждом пункте ссылайся на конкретный файл (и по возможности строку). " + "Если в разделе замечаний нет — напиши «— замечаний нет». " + "Будь конкретным и кратким, без общих слов." +) + +# Diff может быть огромным; ограничиваем, чтобы не раздувать промпт и не упираться +# в контекст модели. Ревьюим «голову» изменений — обычно самое важное сверху. +_MAX_DIFF_CHARS = 12000 + +_EMPTY_REVIEW = ("В пул-реквесте нет изменений для ревью " + "(пустой diff и список файлов).") + + +@dataclass +class ReviewResult: + """Результат AI-ревью пул-реквеста.""" + text: str + sources: list[RetrievedChunk] = field(default_factory=list) + files: list[str] = field(default_factory=list) + used_context: bool = True + + +def _added_lines(diff: str, limit: int = 40) -> list[str]: + """Содержательные добавленные строки diff (без заголовков `+++`).""" + out: list[str] = [] + for line in diff.splitlines(): + if line.startswith("+") and not line.startswith("+++"): + body = line[1:].strip() + if body: + out.append(body) + if len(out) >= limit: + break + return out + + +def _retrieval_query(changed_files: list[str], diff: str) -> str: + """Собрать поисковый запрос к RAG из путей файлов и добавленного кода.""" + parts: list[str] = [] + if changed_files: + parts.append("Изменённые файлы: " + ", ".join(changed_files)) + added = _added_lines(diff) + if added: + parts.append("\n".join(added)) + return "\n".join(parts).strip() + + +def _build_context(chunks: list[RetrievedChunk]) -> str: + parts: list[str] = [] + for i, c in enumerate(chunks, 1): + loc = c.section or c.title or c.source + header = f"[{i}] {c.source}" + (f" — {loc}" if loc and loc != c.source else "") + parts.append(f"{header}\n{c.text.strip()}") + return "\n\n".join(parts) + + +def _clip_diff(diff: str) -> str: + if len(diff) <= _MAX_DIFF_CHARS: + return diff + return diff[:_MAX_DIFF_CHARS] + "\n… (diff обрезан по размеру)" + + +def review_pull_request( + diff: str, + changed_files: list[str], + engine: Optional[RetrievalEngine], + client: LLMClient, + params: dict, + top_k: int = 5, +) -> ReviewResult: + """Найти по diff релевантный контекст проекта и сгенерировать текст ревью.""" + changed_files = list(changed_files or []) + diff = diff or "" + + if not diff.strip() and not changed_files: + return ReviewResult( + text=_EMPTY_REVIEW, + sources=[], + files=changed_files, + used_context=False, + ) + + chunks: list[RetrievedChunk] = [] + if engine is not None and engine.is_ready(): + query = _retrieval_query(changed_files, diff) + if query: + chunks = engine.retrieve(query, top_k=top_k) + + context = _build_context(chunks) if chunks else "(контекст проекта не найден)" + files_line = ", ".join(changed_files) if changed_files else "(список файлов недоступен)" + user_msg = ( + f"Контекст проекта (документация и код):\n\n{context}\n\n" + f"---\nИзменённые файлы: {files_line}\n\n" + f"---\nDiff пул-реквеста:\n\n{_clip_diff(diff)}" + ) + messages = [{"role": "user", "content": user_msg}] + + aux = dict(params) + aux.setdefault("temperature", 0.2) # фактологичный разбор, минимум фантазии + text = client.chat(messages, aux, SYSTEM_PROMPT) + + return ReviewResult(text=text, sources=chunks, files=changed_files) diff --git a/cli/config.py b/cli/config.py index d798ef3..8705a06 100644 --- a/cli/config.py +++ b/cli/config.py @@ -121,6 +121,8 @@ def resolve_provider(env_value: str) -> str: # ── RAG ───────────────────────────────────────────────────────────────────── DEFAULT_RAG_INDEX_PATH = os.path.expanduser("~/rag-kotlin/index") +# Индекс по коду проекта — второй источник RAG для AI-ревью PR (доки + код). +DEFAULT_CODE_INDEX_PATH = os.path.expanduser("~/rag-kotlin/index_jarvis_full") DEFAULT_RAG_STRATEGY = "structural" DEFAULT_RAG_TOP_K = 5 DEFAULT_RAG_FETCH_K = 20 @@ -149,6 +151,13 @@ def _env_bool(name: str) -> bool: return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "да") +def code_index_path() -> str: + """Путь к индексу по коду проекта (для AI-ревью PR). .env → дефолт.""" + return os.path.expanduser( + os.environ.get("CODE_INDEX_PATH", "").strip() or DEFAULT_CODE_INDEX_PATH + ) + + def load_rag_config() -> RetrievalConfig: """Собрать конфиг RAG из окружения (.env уже должен быть подгружен).""" index_path = os.path.expanduser( diff --git a/cli/main.py b/cli/main.py index a030ec7..35bceec 100644 --- a/cli/main.py +++ b/cli/main.py @@ -46,12 +46,14 @@ WORKING_DIR, DEFAULT_EMBED_MODEL, DEFAULT_OLLAMA_URL, + code_index_path, default_model_for, load_env, load_rag_config, resolve_provider, ) from cli.help_commands import handle_help +from cli.review_commands import handle_review from cli.invariant_commands import handle_inv from cli.know_commands import handle_know from cli.mcp_commands import handle_mcp @@ -98,9 +100,10 @@ from infra.mcp_config_repository import FileMcpConfigRepository from infra.mcp_git import McpGitContextProvider from infra.mcp_registry import StdioMcpRegistry +from infra.pr_diff import GhDiffProvider from infra.profile_repository import FileProfileRepository from infra.query_rewriter import LLMQueryRewriter -from infra.rag_retrieval import FaissOllamaRetrievalEngine +from infra.rag_retrieval import CompositeRetrievalEngine, FaissOllamaRetrievalEngine from infra.rerankers import HeuristicReranker, LLMReranker from infra.session_repository import FileSessionRepository from infra.task_repository import FileTaskRepository @@ -199,6 +202,18 @@ def main(): if rag_config.enabled and not rag_engine.is_ready(): rag_config.enabled = False # индекс/зависимости недоступны — тихо в обычный режим + # AI-ревью PR (/review): RAG по двум индексам сразу — документация + код. + # Берём базовые движки (без rewrite/rerank): для разбора diff достаточно + # прямого косинусного поиска, объединённого композитом. + code_base_engine = FaissOllamaRetrievalEngine( + index_path = code_index_path(), + strategy = rag_config.strategy, + embed_model = DEFAULT_EMBED_MODEL, + ollama_url = DEFAULT_OLLAMA_URL, + ) + review_engine = CompositeRetrievalEngine([rag_base_engine, code_base_engine]) + diff_provider = GhDiffProvider() + orchestrator = build_default_orchestrator(task_repo) # MCP: поднимаем все включённые серверы. Если ни одного — registry просто @@ -406,6 +421,9 @@ def main(): elif cmd == "/help" or cmd.startswith("/help "): handle_help(user_input, rag_engine, git_provider, client, params, top_k=rag_config.top_k) + elif cmd == "/review" or cmd.startswith("/review "): + handle_review(user_input, review_engine, diff_provider, client, + params, top_k=rag_config.top_k) else: print(f"{YELLOW}Неизвестная команда. Введите /help.{RESET}") continue diff --git a/cli/review_commands.py b/cli/review_commands.py new file mode 100644 index 0000000..a311090 --- /dev/null +++ b/cli/review_commands.py @@ -0,0 +1,61 @@ +"""CLI-обработчик /review. + + /review <номер PR> — сгенерировать AI-ревью пул-реквеста: достать diff и + изменённые файлы через GitHub, подмешать RAG-контекст + (документация + код) и вывести структурированный разбор. + +Тонкий слой: парсит номер, зовёт use case `review_pull_request`, печатает +результат. В чате ревью только выводится; публикацию комментарием в PR делает +CI-энтрипойнт review_pr.py. Вся логика — в `app/pr_review.py`. +""" +from __future__ import annotations + +from typing import Optional + +from app.ports import DiffProvider, LLMClient, RetrievalEngine +from app.pr_review import review_pull_request +from cli.ansi import BOLD, CYAN, DIM, GREEN, MAGENTA, RESET, YELLOW +from cli.spinner import Spinner + + +def handle_review(cmd_str: str, + engine: Optional[RetrievalEngine], + diff_provider: DiffProvider, + client: LLMClient, + params: dict, + top_k: int = 5) -> None: + arg = cmd_str[len("/review"):].strip() + + if not arg: + print(f"{YELLOW} Укажи номер PR: {RESET}{DIM}/review 12{RESET}") + return + if not arg.isdigit(): + print(f"{YELLOW} Номер PR должен быть числом, например: {RESET}{DIM}/review 12{RESET}") + return + + try: + with Spinner(f"Достаю diff PR #{arg} через GitHub..."): + pr = diff_provider.fetch(arg) + except Exception as e: + print(f"{YELLOW} Не удалось получить PR #{arg}: {e}{RESET}") + print(f"{DIM} Проверь, что gh авторизован (gh auth status) и номер PR верный.{RESET}") + return + + with Spinner("Анализирую изменения (RAG + LLM)..."): + result = review_pull_request(pr.diff, pr.files, engine, client, params, top_k=top_k) + + print(f"\n{BOLD}{GREEN}AI-ревью PR #{arg}:{RESET}\n") + print(result.text) + print() + + if result.files: + print(f"{DIM} Изменённые файлы: {CYAN}{', '.join(result.files)}{RESET}") + if result.sources: + locs: list[str] = [] + for c in result.sources: + loc = c.section or c.title or c.source + label = loc if loc == c.source else f"{c.source} — {loc}" + if label not in locs: + locs.append(label) + print(f"{DIM} Контекст (RAG): {MAGENTA}{' · '.join(locs)}{RESET}") + print() diff --git a/cli/views.py b/cli/views.py index f66bafe..2946809 100644 --- a/cli/views.py +++ b/cli/views.py @@ -361,6 +361,7 @@ def print_help() -> None: {CYAN}/mem{RESET} — показать все слои памяти {CYAN}/help{RESET} — эта справка {CYAN}/help <вопрос>{RESET} — ответ о проекте по документации (RAG) + git-ветка через MCP + {CYAN}/review {RESET} — AI-ревью пул-реквеста: diff через GitHub + RAG (доки+код) {DIM}Что куда сохраняется: краткосрочная → текущий диалог (messages), авто diff --git a/docs/commands.md b/docs/commands.md index 8f5c172..a01535d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -76,6 +76,28 @@ Tool calling работает только для DeepSeek. После `add`/`rm Примеры: `/help какие слои у проекта?`, `/help как устроена память?`, `/help на какой ветке я работаю?`. +## AI-ревью пул-реквеста (`/review`) + +| Команда | Описание | +|---|---| +| `/review <номер PR>` | Сгенерировать AI-ревью PR: diff и изменённые файлы берутся через GitHub (`gh`), контекст — RAG по документации **и** коду, ответ — структурированный разбор | + +Ревью выводится тремя разделами: **🐞 Потенциальные баги**, **🏛 Архитектурные +проблемы**, **💡 Рекомендации**. В чате ревью только печатается; в CI его постит +комментарием в PR энтрипойнт `review_pr.py`. + +Тот же пайплайн запускается автоматически на каждый PR через GitHub Action +`.github/workflows/ai-review.yml` (self-hosted раннер: локальные эмбеддинги +Ollama + FAISS-индексы). Ручной запуск пайплайна: + +``` +python3 review_pr.py <номер PR> # сгенерировать и запостить комментарий +python3 review_pr.py <номер PR> --no-comment # только напечатать +``` + +Второй индекс — по коду проекта — задаётся `CODE_INDEX_PATH` +(по умолчанию `~/rag-kotlin/index_jarvis_full`). + ## Задачи и инварианты - `/task new · list · resume · advance · back · log · abort · done · delete` — diff --git a/domain/review.py b/domain/review.py new file mode 100644 index 0000000..b2eb877 --- /dev/null +++ b/domain/review.py @@ -0,0 +1,26 @@ +"""Доменные модели AI-ревью пул-реквестов. + +Чистые данные без I/O: сырой diff пул-реквеста и список изменённых файлов. +Получение этих данных (через `gh`/GitHub) — в infra за портом +`app.ports.DiffProvider`; генерация текста ревью — в use case `app/pr_review.py`. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class PrDiff: + """Изменения одного пул-реквеста для ревью. + + number — номер PR (строкой, как приходит из CI/gh); + diff — unified diff всего PR (base..head); + files — пути изменённых файлов относительно корня репозитория. + """ + number: str + diff: str + files: list[str] = field(default_factory=list) + + def is_empty(self) -> bool: + """Нечего ревьюить — ни diff, ни файлов.""" + return not self.diff.strip() and not self.files diff --git a/infra/gh.py b/infra/gh.py new file mode 100644 index 0000000..5820354 --- /dev/null +++ b/infra/gh.py @@ -0,0 +1,27 @@ +"""Тонкая обёртка над GitHub CLI (`gh`). + +Общий транспорт для infra-реализаций, которым нужен GitHub: получить diff PR, +список файлов, оставить комментарий. Держим её отдельно, чтобы `gh`-вызов был в +одном месте и легко подменялся фейком в тестах (провайдеры принимают `run` +через конструктор). +""" +from __future__ import annotations + +import subprocess +from typing import Optional + + +def run_gh(args: list[str], stdin: Optional[str] = None) -> str: + """Выполнить `gh ` и вернуть stdout. Упасть с понятной ошибкой на ненулевом коде.""" + proc = subprocess.run( + ["gh", *args], + input=stdin, + capture_output=True, + text=True, + ) + if proc.returncode != 0: + raise RuntimeError( + f"gh {' '.join(args)} завершился с кодом {proc.returncode}: " + f"{proc.stderr.strip()}" + ) + return proc.stdout diff --git a/infra/pr_diff.py b/infra/pr_diff.py new file mode 100644 index 0000000..a307567 --- /dev/null +++ b/infra/pr_diff.py @@ -0,0 +1,39 @@ +"""Получение diff и списка изменённых файлов пул-реквеста через GitHub CLI. + +Реализует порт `app.ports.DiffProvider`. Берёт данные реального PR (base..head) +командами `gh pr diff` и `gh pr view --json files`, поэтому работает и с +форк-ветками, и с любыми базовыми ветками — в отличие от локального git diff. + +Транспорт `run` инжектится через конструктор: в тестах подменяется фейком, +в проде — `infra.gh.run_gh`. +""" +from __future__ import annotations + +import json +from typing import Callable, Optional + +from domain.review import PrDiff +from infra.gh import run_gh + + +class GhDiffProvider: + """DiffProvider поверх `gh`.""" + + def __init__(self, run: Optional[Callable[..., str]] = None): + self._run = run or run_gh + + def fetch(self, pr: str) -> PrDiff: + pr = str(pr) + diff = self._run(["pr", "diff", pr]) + files_json = self._run(["pr", "view", pr, "--json", "files"]) + files = _parse_files(files_json) + return PrDiff(number=pr, diff=diff, files=files) + + +def _parse_files(files_json: str) -> list[str]: + """Достать список путей из вывода `gh pr view --json files`.""" + try: + data = json.loads(files_json) + except (ValueError, TypeError): + return [] + return [f["path"] for f in data.get("files", []) if f.get("path")] diff --git a/infra/rag_retrieval.py b/infra/rag_retrieval.py index 9081951..c44e9bd 100644 --- a/infra/rag_retrieval.py +++ b/infra/rag_retrieval.py @@ -100,3 +100,28 @@ def retrieve(self, query: str, top_k: int = 5) -> list[RetrievedChunk]: chunk_id=m.get("chunk_id", ""), )) return hits + + +class CompositeRetrievalEngine: + """Объединяет несколько движков в один порт `RetrievalEngine`. + + Нужен AI-ревью: контекст берётся сразу из двух индексов — документации и + кода. Каждый под-движок опрашивается по одному запросу, результаты + сливаются и переупорядочиваются по косинусной близости, наверх выходит + top_k. Не готовые под-движки (нет индекса/зависимостей) молча пропускаются, + поэтому один отсутствующий индекс не ломает ревью. + """ + + def __init__(self, engines: list): + self._engines = list(engines) + + def is_ready(self) -> bool: + return any(e.is_ready() for e in self._engines) + + def retrieve(self, query: str, top_k: int = 5) -> list[RetrievedChunk]: + merged: list[RetrievedChunk] = [] + for engine in self._engines: + if engine.is_ready(): + merged.extend(engine.retrieve(query, top_k=top_k)) + merged.sort(key=lambda c: c.score, reverse=True) + return merged[:top_k] diff --git a/infra/review_publisher.py b/infra/review_publisher.py new file mode 100644 index 0000000..44655ac --- /dev/null +++ b/infra/review_publisher.py @@ -0,0 +1,21 @@ +"""Публикация текста ревью комментарием в пул-реквест через GitHub CLI. + +Реализует порт `app.ports.ReviewPublisher`. Тело ревью передаётся в +`gh pr comment --body-file -` через stdin, чтобы не создавать временных файлов +и не спотыкаться на спецсимволах Markdown в аргументах командной строки. +""" +from __future__ import annotations + +from typing import Callable, Optional + +from infra.gh import run_gh + + +class GhReviewPublisher: + """ReviewPublisher поверх `gh pr comment`.""" + + def __init__(self, run: Optional[Callable[..., str]] = None): + self._run = run or run_gh + + def publish(self, pr: str, body: str) -> None: + self._run(["pr", "comment", str(pr), "--body-file", "-"], stdin=body) diff --git a/review_pr.py b/review_pr.py new file mode 100644 index 0000000..8e29194 --- /dev/null +++ b/review_pr.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""AI-ревью пул-реквеста — composition root для CI (GitHub Action). + +Запуск: + python3 review_pr.py <номер PR> [--no-comment] + +Собирает RAG по двум индексам (документация + код), LLM-клиент и gh-провайдеры, +гоняет use case `review_pull_request`, печатает ревью и по умолчанию публикует +его комментарием в PR. `--no-comment` — только напечатать, ничего не постить. + +Провайдер LLM берётся из LLM_PROVIDER (окружение раннера или .env рядом со +скриптом), ключи — из окружения, как и в основном CLI. `gh` должен быть +авторизован (в CI — через GH_TOKEN). +""" +from __future__ import annotations + +import os +import sys + +from app.pr_review import review_pull_request +from cli.config import ( + DEFAULT_EMBED_MODEL, + DEFAULT_OLLAMA_URL, + DEFAULT_PARAMS, + code_index_path, + default_model_for, + load_env, + load_rag_config, + resolve_provider, +) +from cli.main import _build_client +from infra.pr_diff import GhDiffProvider +from infra.rag_retrieval import CompositeRetrievalEngine, FaissOllamaRetrievalEngine +from infra.review_publisher import GhReviewPublisher + + +def _format_comment(result) -> str: + """Оформить ревью как Markdown-комментарий к PR.""" + lines = ["## 🤖 AI-ревью", "", result.text.strip()] + if result.sources: + locs: list[str] = [] + for c in result.sources: + loc = c.section or c.title or c.source + label = loc if loc == c.source else f"{c.source} — {loc}" + if label not in locs: + locs.append(label) + lines += ["", "---", "_Контекст (RAG): " + " · ".join(locs) + "_"] + lines += ["", "_Сгенерировано jarvis-cli · review_pr.py_"] + return "\n".join(lines) + + +def main(argv: list[str]) -> int: + positional = [a for a in argv if not a.startswith("-")] + flags = {a for a in argv if a.startswith("-")} + if not positional: + print("Использование: python3 review_pr.py <номер PR> [--no-comment]", + file=sys.stderr) + return 2 + pr = positional[0] + + # .env лежит рядом с этим скриптом (в CI обычно отсутствует — тогда ключи + # приходят из окружения раннера). + load_env(os.path.join(os.path.dirname(os.path.realpath(__file__)), ".env")) + + provider = resolve_provider(os.environ.get("LLM_PROVIDER", "")) + params = dict(DEFAULT_PARAMS) + params["model"] = default_model_for(provider) + client = _build_client(provider) + + rag_config = load_rag_config() + docs_engine = FaissOllamaRetrievalEngine( + index_path=rag_config.index_path, strategy=rag_config.strategy, + embed_model=DEFAULT_EMBED_MODEL, ollama_url=DEFAULT_OLLAMA_URL) + code_engine = FaissOllamaRetrievalEngine( + index_path=code_index_path(), strategy=rag_config.strategy, + embed_model=DEFAULT_EMBED_MODEL, ollama_url=DEFAULT_OLLAMA_URL) + engine = CompositeRetrievalEngine([docs_engine, code_engine]) + + diff_provider = GhDiffProvider() + + print(f"Достаю diff PR #{pr} через gh…", file=sys.stderr) + pr_diff = diff_provider.fetch(pr) + print(f"Изменено файлов: {len(pr_diff.files)}. Генерирую ревью " + f"(provider={provider}, RAG={'on' if engine.is_ready() else 'off'})…", + file=sys.stderr) + + result = review_pull_request(pr_diff.diff, pr_diff.files, engine, client, + params, top_k=rag_config.top_k) + + body = _format_comment(result) + print(body) + + if "--no-comment" not in flags: + GhReviewPublisher().publish(pr, body) + print(f"\nРевью опубликовано комментарием в PR #{pr}.", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/app/test_pr_review.py b/tests/app/test_pr_review.py new file mode 100644 index 0000000..f9370e2 --- /dev/null +++ b/tests/app/test_pr_review.py @@ -0,0 +1,112 @@ +"""Юнит-тесты use case AI-ревью PR — фейковые порты RetrievalEngine и LLMClient. + +Без FAISS, gh и сети: проверяем сборку контекста из diff, вызов LLM и обработку +пустого diff. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from app.pr_review import review_pull_request +from domain.retrieval import RetrievedChunk + +_DIFF = """diff --git a/app/foo.py b/app/foo.py +index 111..222 100644 +--- a/app/foo.py ++++ b/app/foo.py +@@ -1,3 +1,5 @@ ++def divide(a, b): ++ return a / b +""" + + +@dataclass +class _FakeEngine: + chunks: list = field(default_factory=list) + ready: bool = True + last_query: str = "" + last_top_k: int = 0 + + def is_ready(self): + return self.ready + + def retrieve(self, query, top_k=5): + self.last_query = query + self.last_top_k = top_k + return list(self.chunks) + + +@dataclass +class _FakeClient: + reply: str = "## 🐞 Потенциальные баги\n- деление на ноль в app/foo.py" + last_system: str = "" + last_messages: list = field(default_factory=list) + calls: int = 0 + + def chat(self, messages, params, system_prompt=None): + self.calls += 1 + self.last_system = system_prompt or "" + self.last_messages = messages + return self.reply + + +def _chunks(): + return [RetrievedChunk(text="Слой app оркестрирует порты.", + source="docs/architecture.md", section="Слои", + chunk_id="a#1", score=0.9)] + + +def test_review_uses_diff_and_retrieval(): + eng = _FakeEngine(chunks=_chunks()) + client = _FakeClient() + res = review_pull_request(_DIFF, ["app/foo.py"], eng, client, {}, top_k=4) + + assert res.text == client.reply + assert res.files == ["app/foo.py"] + assert res.sources and res.sources[0].source == "docs/architecture.md" + assert res.used_context + assert eng.last_top_k == 4 + # добавленный код и путь файла попали в поисковый запрос + assert "divide" in eng.last_query and "app/foo.py" in eng.last_query + # diff и контекст попали в сообщение к LLM + ctx = client.last_messages[0]["content"] + assert "divide" in ctx and "Слой app оркестрирует" in ctx + + +def test_system_prompt_requires_three_sections(): + client = _FakeClient() + review_pull_request(_DIFF, ["app/foo.py"], _FakeEngine(chunks=_chunks()), + client, {}) + for section in ("Потенциальные баги", "Архитектурные проблемы", "Рекомендации"): + assert section in client.last_system + + +def test_empty_diff_returns_hint_without_llm_call(): + client = _FakeClient() + res = review_pull_request("", [], _FakeEngine(chunks=_chunks()), client, {}) + assert not res.used_context + assert client.calls == 0 + assert client.last_messages == [] + + +def test_engine_not_ready_skips_retrieval_but_still_reviews(): + eng = _FakeEngine(chunks=_chunks(), ready=False) + client = _FakeClient() + res = review_pull_request(_DIFF, ["app/foo.py"], eng, client, {}) + assert eng.last_query == "" # ретрив пропущен + assert client.calls == 1 # но ревью по diff сделано + assert res.sources == [] + + +def test_no_engine_still_reviews(): + client = _FakeClient() + res = review_pull_request(_DIFF, ["app/foo.py"], None, client, {}) + assert client.calls == 1 and res.text == client.reply + + +def test_long_diff_is_clipped(): + client = _FakeClient() + big = "diff --git a/x b/x\n" + "\n".join(f"+line {i}" for i in range(20000)) + review_pull_request(big, ["x"], None, client, {}) + ctx = client.last_messages[0]["content"] + assert "diff обрезан по размеру" in ctx diff --git a/tests/cli/test_review_commands.py b/tests/cli/test_review_commands.py new file mode 100644 index 0000000..2100a37 --- /dev/null +++ b/tests/cli/test_review_commands.py @@ -0,0 +1,81 @@ +"""Тесты обработчика /review: парсинг номера PR и вывод ревью.""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from cli.review_commands import handle_review +from domain.retrieval import RetrievedChunk +from domain.review import PrDiff + + +@dataclass +class _FakeEngine: + ready: bool = True + chunks: list = field(default_factory=list) + + def is_ready(self): + return self.ready + + def retrieve(self, query, top_k=5): + return list(self.chunks) + + +@dataclass +class _FakeDiffProvider: + diff: str = "diff --git a/app/x.py b/app/x.py\n+code" + files: list = field(default_factory=lambda: ["app/x.py"]) + fetched: list = field(default_factory=list) + + def fetch(self, pr): + self.fetched.append(pr) + return PrDiff(number=str(pr), diff=self.diff, files=list(self.files)) + + +@dataclass +class _BoomDiffProvider: + def fetch(self, pr): + raise RuntimeError("gh not authed") + + +@dataclass +class _FakeClient: + reply: str = "## 🐞 Потенциальные баги\n- баг в app/x.py" + + def chat(self, messages, params, system_prompt=None): + return self.reply + + +def _chunks(): + return [RetrievedChunk(text="слой app", source="docs/architecture.md", + section="Слои", chunk_id="a#1", score=0.8)] + + +def test_no_arg_prints_usage(capsys): + handle_review("/review", _FakeEngine(), _FakeDiffProvider(), _FakeClient(), {}) + out = capsys.readouterr().out + assert "номер PR" in out + assert "AI-ревью" not in out + + +def test_non_numeric_arg_warns(capsys): + handle_review("/review abc", _FakeEngine(), _FakeDiffProvider(), _FakeClient(), {}) + out = capsys.readouterr().out + assert "должен быть числом" in out + + +def test_valid_pr_prints_review_files_and_sources(capsys): + provider = _FakeDiffProvider() + handle_review("/review 12", _FakeEngine(chunks=_chunks()), provider, + _FakeClient(reply="## 🐞 Потенциальные баги\n- баг"), {}) + out = capsys.readouterr().out + assert provider.fetched == ["12"] + assert "AI-ревью PR #12" in out + assert "Потенциальные баги" in out + assert "app/x.py" in out # изменённые файлы + assert "docs/architecture.md" in out # источник RAG + + +def test_fetch_error_is_reported(capsys): + handle_review("/review 5", _FakeEngine(), _BoomDiffProvider(), _FakeClient(), {}) + out = capsys.readouterr().out + assert "Не удалось получить PR #5" in out diff --git a/tests/infra/test_composite_retrieval.py b/tests/infra/test_composite_retrieval.py new file mode 100644 index 0000000..71a4083 --- /dev/null +++ b/tests/infra/test_composite_retrieval.py @@ -0,0 +1,50 @@ +"""Тесты CompositeRetrievalEngine: слияние результатов двух индексов (доки+код).""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from domain.retrieval import RetrievedChunk +from infra.rag_retrieval import CompositeRetrievalEngine + + +@dataclass +class _FakeEngine: + ready: bool + hits: list = field(default_factory=list) + last_top_k: int = 0 + + def is_ready(self): + return self.ready + + def retrieve(self, query, top_k=5): + self.last_top_k = top_k + return list(self.hits) + + +def _chunk(src, score): + return RetrievedChunk(text=src, source=src, score=score) + + +def test_merges_and_sorts_by_score(): + docs = _FakeEngine(True, [_chunk("docs.md", 0.5), _chunk("docs2.md", 0.9)]) + code = _FakeEngine(True, [_chunk("code.py", 0.7)]) + comp = CompositeRetrievalEngine([docs, code]) + + hits = comp.retrieve("q", top_k=2) + assert [h.source for h in hits] == ["docs2.md", "code.py"] # 0.9, 0.7 (0.5 отсечён top_k) + + +def test_skips_not_ready_engines(): + docs = _FakeEngine(False, [_chunk("docs.md", 0.9)]) + code = _FakeEngine(True, [_chunk("code.py", 0.4)]) + comp = CompositeRetrievalEngine([docs, code]) + + hits = comp.retrieve("q", top_k=5) + assert [h.source for h in hits] == ["code.py"] + assert docs.last_top_k == 0 # не опрашивался + + +def test_is_ready_if_any_subengine_ready(): + assert CompositeRetrievalEngine([_FakeEngine(False), _FakeEngine(True)]).is_ready() + assert not CompositeRetrievalEngine([_FakeEngine(False), _FakeEngine(False)]).is_ready() + assert not CompositeRetrievalEngine([]).is_ready() diff --git a/tests/infra/test_pr_diff.py b/tests/infra/test_pr_diff.py new file mode 100644 index 0000000..9c12277 --- /dev/null +++ b/tests/infra/test_pr_diff.py @@ -0,0 +1,47 @@ +"""Тесты GhDiffProvider: diff и файлы PR через фейковый `gh`-раннер.""" +from __future__ import annotations + +from infra.pr_diff import GhDiffProvider + + +class _FakeRun: + """Фейк infra.gh.run_gh: возвращает заранее заданный вывод по под-команде.""" + + def __init__(self, diff="", files_json="{}"): + self._diff = diff + self._files_json = files_json + self.calls: list[list[str]] = [] + + def __call__(self, args, stdin=None): + self.calls.append(args) + if args[:2] == ["pr", "diff"]: + return self._diff + if args[:2] == ["pr", "view"]: + return self._files_json + raise AssertionError(f"неожиданный вызов gh: {args}") + + +def test_fetch_returns_diff_and_files(): + run = _FakeRun( + diff="diff --git a/x b/x\n+hello", + files_json='{"files":[{"path":"a.py"},{"path":"b.py"}]}', + ) + pr = GhDiffProvider(run=run).fetch("12") + + assert pr.number == "12" + assert pr.diff == "diff --git a/x b/x\n+hello" + assert pr.files == ["a.py", "b.py"] + assert run.calls == [["pr", "diff", "12"], ["pr", "view", "12", "--json", "files"]] + + +def test_fetch_tolerates_empty_files_json(): + run = _FakeRun(diff="", files_json="") + pr = GhDiffProvider(run=run).fetch("7") + assert pr.files == [] and pr.is_empty() + + +def test_fetch_coerces_int_pr_to_str(): + run = _FakeRun(diff="d", files_json='{"files":[]}') + pr = GhDiffProvider(run=run).fetch(5) + assert pr.number == "5" + assert run.calls[0] == ["pr", "diff", "5"] diff --git a/tests/infra/test_review_publisher.py b/tests/infra/test_review_publisher.py new file mode 100644 index 0000000..3efe230 --- /dev/null +++ b/tests/infra/test_review_publisher.py @@ -0,0 +1,28 @@ +"""Тест GhReviewPublisher: комментарий к PR через фейковый `gh`-раннер.""" +from __future__ import annotations + +from infra.review_publisher import GhReviewPublisher + + +class _FakeRun: + def __init__(self): + self.calls: list[tuple] = [] + + def __call__(self, args, stdin=None): + self.calls.append((args, stdin)) + return "" + + +def test_publish_posts_body_via_stdin(): + run = _FakeRun() + GhReviewPublisher(run=run).publish("12", "## Ревью\nтело") + + args, stdin = run.calls[0] + assert args == ["pr", "comment", "12", "--body-file", "-"] + assert stdin == "## Ревью\nтело" + + +def test_publish_coerces_pr_to_str(): + run = _FakeRun() + GhReviewPublisher(run=run).publish(9, "x") + assert run.calls[0][0][2] == "9"