# AI-ревью кода в CI на открытом агенте: конфигурация, которая реально выдержала

> Нам был нужен второй ревьюер, который никогда не устаёт, но и не выдумывает проблему из воздуха. Вот как мы встроили Octomind в GitHub Actions для автоматического ревью кода: реальные inputs octomind-action, запуск настоящих команд lint и test команды через .agents/tools/, неинтерактивный и безопасный CI и ограничение стоимости.

Первая версия нашего AI-ревьюера отметила 14 «критических» проблем в однострочном изменении конфига. Двенадцать были вымышленными. Одна — реальной опечаткой. И одна — настоящим багом, который больше никто не заметил. Именно это соотношение — один сигнал, погребённый под двенадцатью выдумками, — и есть причина, по которой большинство команд выключает бота за неделю и больше не включает.

Нам не нужен был такой бот. Нам был нужен второй ревьюер, который никогда не устаёт, но и не выдумывает проблему из воздуха. Ревьюер, который запускает наш _настоящий_ набор тестов, а не угадывает его, комментирует один раз тем, что нашёл, и стоит предсказуемых денег. Это та конфигурация, которая после нескольких неловких итераций реально выдержала в нашем CI.

Всё здесь работает на открытом коде: [Octomind](https://github.com/Muvon/octomind) — сессионный агент, и [octomind-action](https://github.com/Muvon/octomind-action) — GitHub Action, который его оборачивает. Без SaaS-ревьюера, без оплаты за место, без того чтобы код покидал ваш раннер куда-либо, кроме провайдера модели, за которого вы и так платите.

---

## Почему «самохостинговый AI-ревьюер», а не SaaS-бот

Сейчас есть здоровый рынок хостинговых продуктов AI-ревью кода. Они работают. Но для AI-ревью кода в CI три вещи подтолкнули нас к самохостинговому AI-ревьюеру, работающему внутри наших собственных GitHub Actions:

1. **Ревьюер должен запускать наши команды, а не своё представление о наших командах.** Хостинговый бот читает дифф. Он не запускает `cargo clippy --all-targets` с нашим точным конфигом линтера и не гоняет интеграционный набор за нужными feature-флагами. Ревьюер, который не может выполнить настоящие проверки проекта, сводится к pattern-matching по тексту — а это ровно то место, откуда берутся галлюцинации.
2. **Агент работает на раннере, которому мы уже доверяем.** Тот же checkout, та же граница секретов, те же правила сетевого egress. Ничто из диффа не уходит туда, что мы ещё не авторизовали.
3. **Это конфиг-файл в репозитории, ревьюимый как любой другой.** Поведение ревьюера — это workflow-YAML и несколько скриптов в `.agents/tools/` под контролем версий. Когда он становится шумным, вы чините это в PR. Когда новичок спрашивает «а что бот вообще проверяет», ответ — `git log`.

Если вы читали нашу более раннюю заметку о том, почему [кастомные MCP должны жить в репозитории](/blog/custom-mcps-belong-in-your-repo), это та же философия, наведённая на CI: инструменты, которые управляют вашим агентом, живут рядом с кодом, над которым они работают.

---

## Как `octomind run` ведёт себя неинтерактивно

Всё начинается с одного факта об агенте: `octomind run` переключается в неинтерактивный режим в тот момент, когда вы даёте ему `--format` или передаёте stdin по пайпу. Из определения CLI:

```
--format <FORMAT>   Output format: plain or jsonl. When set, runs
                    non-interactively (reads input from stdin).
```

Внутри рантайм определяет режим вывода по флагу и по тому, является ли stdin терминалом:

| Вызов                                   | Режим                                    |
| --------------------------------------- | ---------------------------------------- |
| `octomind run` в терминале              | `Interactive` (промпты, цвета, анимации) |
| `echo "..." \| octomind run` (по пайпу) | `NonInteractive`                         |
| `octomind run --format plain`           | `NonInteractive`                         |
| `octomind run --format jsonl`           | `Jsonl` (один JSON-объект на строку)     |

CI — это случай пайпа, и `--format jsonl` — то, что вам там нужно. Он подавляет интерактивную мишуру и выдаёт поток структурированных событий — сообщения ассистента, финальное событие стоимости, — который workflow может распарсить, не скребя прозу. В этом и вся причина, почему агент автоматизируем: нет скрытого промпта, ждущего, пока человек нажмёт `y`.

Можно прогнать это руками, чтобы увидеть:

```bash
echo "Review the staged diff and report only real defects." \
  | octomind run developer:rust --format jsonl
```

Последнее событие `assistant` несёт текст ревью; завершающее событие `cost` несёт итоги по токенам и долларам. Это тот же поток, который парсит GitHub Action.

---

## Action и его реальные inputs

`octomind-action` поставляет две точки входа. Мы используем обе для разных задач:

- `muvon/octomind-action@v1` (алиас `muvon/octomind-action/run@v1`) запускает одну сессию `octomind run` по промпту.
- `muvon/octomind-action/workflow@v1` запускает многошаговый `octomind workflow` из TOML-файла.

Для ревью кода достаточно одиночной сессии `run`. Вот её реальные inputs — скопированы из `action.yml`, не выдуманы:

| Input           | По умолчанию          | Что делает                                                    |
| --------------- | --------------------- | ------------------------------------------------------------- |
| `prompt`        | _(обязателен)_        | Задача/сообщение, отправляемое octomind                       |
| `role`          | из конфига            | Тег роли/агента, напр. `developer:rust`                       |
| `model`         | —                     | Override модели, напр. `openrouter:anthropic/claude-sonnet-5` |
| `name`          | —                     | Именованная сессия (создать или возобновить)                  |
| `resume`        | —                     | Возобновить именованную сессию                                |
| `resume_recent` | `false`               | Возобновить самую недавнюю сессию для cwd                     |
| `sandbox`       | `false`               | Ограничить запись в ФС рабочей директорией                    |
| `version`       | `latest`              | Версия Octomind для установки                                 |
| `tap`           | —                     | Добавить tap перед запуском                                   |
| `config`        | —                     | Путь к конфиг-файлу octomind                                  |
| `comment`       | `none`                | Режим комментария к PR: `full`, `compact` или `none`          |
| `github_token`  | `${{ github.token }}` | Токен для публикации комментария к PR                         |

И outputs, которые можно прочитать в последующих шагах:

| Output       | Что несёт                                     |
| ------------ | --------------------------------------------- |
| `result`     | Последнее сообщение ассистента (само ревью)   |
| `session_id` | ID сессии, для возобновления в следующем шаге |
| `cost`       | `{"tokens": N, "cost": N}`                    |
| `raw_output` | Полный поток JSONL                            |
| `exit_code`  | Код возврата процесса                         |

Два из них несущие для ревьюера. `comment: compact` публикует ревью свёрнутым блоком `<details>` в PR — один комментарий, сворачиваемый, со стоимостью в подвале `<sub>` — вместо стены текста. А `cost` позволяет класть цену каждого ревью в логи, что важнее, чем звучит.

---

## Подключение реальных `lint` и `test` команды через `.agents/tools/`

Вот та часть, что отделила «реально выдержало» от «выключено за неделю». Ревьюер, который только читает текст, выдумывает проблемы, потому что текст — это всё, что у него есть. Дайте ему возможность запускать ваши настоящие проверки — и он перестанет угадывать.

Octomind находит локальные инструменты проекта в `<workdir>/.agents/tools/<name>` — скрипты с shebang и заголовком-комментарием, который становится схемой инструмента. Они появляются у модели как `local`-сервер MCP, без отдельного серверного процесса, без регистрации. Механику мы разбирали в [кастомные MCP должны жить в репозитории](/blog/custom-mcps-belong-in-your-repo); вот значимый для CI момент: те же скрипты, что агенты ваших разработчиков используют локально, — это те, что агент-ревьюер запускает в CI, потому что они закоммичены в репозиторий.

Поэтому мы спрятали _точные_ команды команды за именованными инструментами.

`.agents/tools/lint`:

```bash
#!/usr/bin/env bash
# @description Run the project linter exactly as CI does. Returns warnings and errors.
set -euo pipefail
cd "$OCTOMIND_WORKDIR"
cargo clippy --all-targets --all-features -- -D warnings
```

`.agents/tools/test`:

```bash
#!/usr/bin/env bash
# @description Run the test suite. Defaults to the fast unit set.
# @param scope string One of: unit, all (default: unit)
set -euo pipefail
cd "$OCTOMIND_WORKDIR"
scope="${OCTOMIND_PARAM_SCOPE:-unit}"
case "$scope" in
  unit) cargo test --lib ;;
  all)  cargo test --all-targets ;;
  *) echo "Unknown scope: $scope" >&2; exit 2 ;;
esac
```

Теперь промпт ревьюера может сказать «запусти `lint` и `test` и сообщай только о дефектах, которые можешь подтвердить». Агент вызывает именованный инструмент, получает реальный код возврата и реальный вывод и заземляет своё ревью на том, что проект делает на самом деле. Утверждение вроде «это завалит clippy» больше не догадка — агент запустил clippy. Если clippy прошёл, утверждение вообще не пишется.

Это самый большой рычаг против режима отказа «галлюцинированная проблема». Модель плохо предсказывает, компилируется ли ваш код. Она прекрасно справляется с _чтением вывода команды, которая его скомпилировала_.

---

## Ограничение того, что ревьюеру позволено трогать

Ревьюер должен читать, запускать проверки и докладывать. Он не должен редактировать файлы, пушить или открывать PR. Роли Octomind управляют этим через `server_refs` (какие серверы инструментов видит роль) и `allowed_tools` (какие инструменты внутри них). Форма — из поставляемых шаблонов ролей:

```toml
[[roles]]
name = "reviewer:strict"
system = """
You are a code reviewer. Run the project's lint and test tools to ground
every claim. Report only defects you can substantiate with tool output or a
specific line. If you find nothing, say so in one sentence. Never edit files.
"""
temperature = 0.1

[roles.mcp]
server_refs = ["filesystem"]
allowed_tools = ["view", "lint", "test"]
```

Несколько намеренных решений тут. `temperature = 0.1`, потому что ревьюер должен быть скучным и повторяемым, а не творческим. `allowed_tools` перечисляет только инструменты чтения и проверки — без записи через shell, без редактирования, — так что даже если модель решит что-то «починить», у неё нет для этого инструмента. А последние две фразы system-промпта — это контроль шума: _сообщай только то, что можешь подтвердить; если ничего не нашёл, скажи это одной фразой._ Одна эта инструкция превратила наш средний комментарий с четырнадцати «проблем» в, на чистом PR, «Дефектов не найдено».

Для дополнительного «ремня и подтяжек» input `sandbox: true` у Action ограничивает любую запись в ФС рабочей директорией — так что инструмент, которому действительно нужно записать временный файл, может, но ничто не выходит за пределы checkout.

---

## Разрешения и безопасность в неинтерактивном прогоне

Что людей беспокоит в агенте в CI — это аппрувы. В интерактивной сессии вас спросили бы перед запуском инструмента. В CI спрашивать некого, и в этом суть: в неинтерактивном режиме нет ворот `y/n`, которые подвесят джоб. Безопасность идёт не от человека в петле — она идёт от того, что петля узкая:

- **`allowed_tools` роли — это белый список для инструментов MCP-серверов.** Ревьюер не может вызвать серверный инструмент, которого нет в списке, — это статическая граница, а не суждение в рантайме, от которого модель можно отговорить. Исключение — локальные скрипты `.agents/tools/`: они доступны всегда, их граница — то, что вы коммитите в этот каталог, а ревью PR видит каждое его изменение.
- **`sandbox: true`** держит запись внутри checkout.
- **Секреты остаются в окружении раннера**, никогда в репозитории. Скрипты `.agents/tools/` читают `ACME_TOKEN` и компанию из окружения; скрипт закоммичен, секрет — это secret GitHub Actions. Если секрет отсутствует, инструмент завершается с ненулевым кодом и ясным сообщением, а не выдумывает результат.
- **`github_token` ограничен комментированием.** Дефолтный `${{ github.token }}` публикует комментарий к PR; модели он как инструмент не отдаётся.

Радиус поражения ревьюера: прочитать файлы, запустить две команды из белого списка, написать один комментарий к PR. Это граница, о которой можно рассуждать — что уже больше, чем можно сказать про «модель пообещала ничего не трогать».

---

## Workflow, который выдержал

Вот реальный джоб ревью, целиком заземлённый на inputs выше:

```yaml
name: AI Code Review
on:
  pull_request:
    types: [opened, synchronize]

permissions:
  contents: read
  pull-requests: write # needed to post the review comment

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # so the agent can diff against the base

      - uses: muvon/octomind-action@v1
        with:
          role: reviewer:strict
          model: openrouter:anthropic/claude-sonnet-5
          sandbox: true
          comment: compact
          prompt: |
            Review the changes on this branch against the base.
            Run the `lint` and `test` tools to ground your findings.
            Report ONLY defects you can substantiate with tool output or a
            specific changed line. Group by severity. If nothing is wrong,
            reply in one sentence. Do not restate the diff.
        env:
          OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
```

Заметки по решениям:

- **`role: reviewer:strict`** несёт низкую температуру, белый список инструментов и system-промпт «подтверди или молчи». Input промпта — это _задача_; роль — это _характер и ограничители_. Держите их раздельно, чтобы настраивать раздельно.
- **`model:` закреплён**, а не пущен на самотёк. Закрепление модели — половина вашего контроля стоимости и почти вся воспроизводимость. Мы закрепляем одну модель класса Sonnet и меняем её только намеренно.
- **`comment: compact`** даёт один сворачиваемый комментарий на прогон. На `synchronize` (каждый пуш в PR) вы получаете свежее ревью, не превращая тред в бесконечный скролл.
- **`OPENROUTER_API_KEY`** — единственный секрет, нужный джобу. Octomind говорит с OpenRouter, Anthropic, OpenAI, DeepSeek, Google Vertex, AWS Bedrock и Cloudflare; заданная переменная окружения выбирает провайдера.

Это весь ревьюер. Один джоб, один Action, два закоммиченных скрипта-инструмента, одно определение роли.

---

## Как держать стоимость в рамках

Агент, который может запускать инструменты, в принципе может зациклиться навсегда и выставлять счёт навсегда. На практике мы ограничиваем его несколькими скучными, но действенными рычагами:

- **Закрепите модель.** Закреплённая модель класса Sonnet имеет предсказуемую стоимость за токен. «Самое свежее и лучшее» — это как получить счёт-сюрприз.
- **Триггерьте на нужных событиях.** Только `opened` и `synchronize`. Не на каждый комментарий, не на черновые PR (фильтруйте через `if: github.event.pull_request.draft == false`, когда нужно).
- **Читайте output `cost` и логируйте его.** Каждый прогон выдаёт `cost` как `{"tokens": N, "cost": N}`. Слейте его в summary джоба. Как только цена каждого ревью лежит в ваших логах Actions, стоимость перестаёт быть ощущением и становится числом, на которое можно повесить порог.

```yaml
      - run: echo "Review cost: ${{ steps.review.outputs.cost }}" >> "$GITHUB_STEP_SUMMARY"
```

Главный драйвер стоимости — инструменты, а не промпт. Ревьюер, который гоняет ваш полный интеграционный набор на каждый пуш, дорог потому, что _набор_ дорог, а не потому, что агент. Наш инструмент `test` по умолчанию использует быстрый unit-набор именно по этой причине; агент тянется к `scope: all` только когда дифф трогает что-то, что этого требует, и даже тогда мы можем ограничить это на уровне роли.

---

## Подводные камни, честно

**Недетерминизм.** Два прогона на одном диффе могут сформулировать ревью по-разному, и иногда один находит то, чего не находит другой. `temperature = 0.1` сильно это уменьшает, но не убивает. Лечение — не гнаться за детерминизмом, а относиться к AI-ревью как к одной _совещательной_ проверке среди нескольких. Оно никогда не блокирует merge само по себе. Это делают человек и настоящие проверки CI.

**Шум — это проблема промпта, а не модели.** Наш первый ревьюер был громким, потому что мы просили «отревью код», что энтузиастичная модель читает как «найди четырнадцать вещей». Лекарством стали две фразы в system-промпте: _подтверждай каждое утверждение; если ничего не нашёл, скажи это одной фразой._ Плюс инструменты — половина шума была угадыванием моделью исходов, которые она могла просто запустить. Если ваш ревьюер шумный, чините промпт и дайте ему возможность проверять, прежде чем винить модель.

**Секреты в выводе инструментов.** Инструмент, который вываливает окружение или подробные логи, может слить секрет прямо в комментарий к PR. Держите вывод инструментов плотным: возвращайте результат lint/test, а не весь мир. Мы узнали это, когда многословный раннер тестов сделал echo строки подключения в тело ревью. Теперь инструменты печатают результаты, а не окружение.

**Дифф против base-ref.** Используйте `fetch-depth: 0` на checkout, иначе агент не сможет сравнить с базовой веткой и в итоге «отревьюит» весь файл целиком. Дешёвая ошибка, раздражающий симптом.

**Это второй ревьюер, а не тот самый ревьюер.** День, когда вы начнёте верить, что он поймает всё, — это день, когда он пропустит тот самый важный. Это неутомимый первый проход. Уставшие люди всё ещё владеют merge.

---

## Частые вопросы

**Нужен ли агенту доступ на запись в мой репозиторий?**

Нет. Роль ревьюера перечисляет в `allowed_tools` только инструменты чтения и проверки, `sandbox: true` запирает любую запись в checkout, а `github_token` используется только для публикации комментария. Модели никогда не отдаётся инструмент пуша или редактирования.

**Можно ли запускать реальные lint/test команды вместо обобщённых?**

В этом вся суть. Положите ваши точные команды в `.agents/tools/lint` и `.agents/tools/test`, закоммитьте — и агент запустит их в CI. Те же скрипты, что используют локальные агенты ваших разработчиков. См. [кастомные MCP должны жить в репозитории](/blog/custom-mcps-belong-in-your-repo).

**Как прочитать стоимость ревью?**

Action отдаёт output `cost` (`{"tokens": N, "cost": N}`) и полный JSONL через `raw_output`. Сделайте echo `cost` в `$GITHUB_STEP_SUMMARY` — и он в логе каждого прогона.

**А если я хочу многошаговый пайплайн, а не одно ревью?**

Используйте `muvon/octomind-action/workflow@v1` с TOML-файлом: соберите контекст одним шагом, отревьюйте следующим, резюмируйте третьим. То же окружение провайдера, тот же plumbing комментария к PR, плюс input `dry_run` для валидации пайплайна без траты токенов.

**Какие провайдеры моделей работают?**

Тот, за которого вы уже платите: OpenRouter, Anthropic, OpenAI, DeepSeek, Google Vertex, AWS Bedrock, Cloudflare. Задайте соответствующий API key в `env` и закрепите модель через input `model`.

---

## Сдвиг

Инстинкт с AI-ревьюером — сделать его умнее: лучше модель, длиннее промпт, изощрённее. Что реально выдержало у нас — это сделать его _уже и заземлённее_. Меньше инструментов, все наши. Низкая температура. System-промпт, вся работа которого — держать модель молчащей, пока она не сможет доказать свою правоту. И возможность запускать настоящие проверки, чтобы она рассуждала от вывода `cargo clippy`, а не от догадки о том, что `cargo clippy` мог бы сказать.

Результат — не гениальный ревьюер. Это надёжный: он никогда не устаёт, запускает одни и те же проверки каждый раз и — та часть, что далась дольше всего, — перестал галлюцинировать проблемы из воздуха. Про более широкий набор того, что вышло вокруг этого, остальное есть в [обзоре за июнь 2026](/blog/release-round-june-2026).

Подключите его сперва на низкорисковом репозитории. Посмотрите несколько ревью. Подкрутите промпт дважды. Потом дайте ему тихо сидеть на ваших PR вторым набором глаз, который никогда не моргает.

— Don

---

_[Octomind](https://github.com/Muvon/octomind) и [octomind-action](https://github.com/Muvon/octomind-action) — открытый код под Apache-2.0. Если ваш ревьюер шумный, лекарство почти всегда — промпт и инструменты; а если нет, [откройте issue](https://github.com/Muvon/octomind/issues) с JSONL, и мы посмотрим._
