Каждый разработчик знает эту ситуацию. Вы сделали небольшую правку, создали Pull Request и... ждёте. Иногда ревью затягивается, потому что коллеги заняты, а иногда оно превращается в рутинный пинг-понг из-за пропущенной точки с запятой или несоответствия стайлгайду. Рутинные проверки отнимают драгоценное время и концентрацию, которые можно было бы потратить на обсуждение архитектуры и бизнес-логики.

А что, если делегировать эту первую, самую механическую линию обороны машине? Что, если бы у нас был неутомимый ассистент, который мгновенно проверял бы новый код и указывал на очевидные недочёты?

В этой статье мы с нуля создадим именно такого ассистента. Это не будет очередной "hello world" на модную тему. Это будет подробный, основанный на реальном опыте гайд по созданию Node.js-сервиса, который слушает вебхуки GitHub, отправляет код на анализ большой языковой модели (LLM) и публикует результаты в виде построчных комментариев к Pull Request.

Наш технологический стек:

  • Node.js: Простой, быстрый старт и идеальная асинхронная модель для I/O-bound задач, таких как ожидание ответов от внешних API.

  • GitHub: Де-факто стандарт для контроля версий с мощной и гибкой системой вебхуков.

  • OpenRouter: Универсальный шлюз к десяткам языковых моделей. Он позволяет не привязываться к одному API (например, OpenAI) и даёт доступ к отличным бесплатным моделям, на одной из которых мы и построим наш сервис.

  • AI-модель: Мы будем использовать qwen/qwen-2.5-72b-instruct:free - одну из бесплатных моделей, доступных на OpenRouter, которая отлично справляется с анализом кода.

Главная ценность этой статьи - в деталях. Я покажу не только "как надо", но и "как бывает". Мы вместе пройдём через все грабли, на которые я наступил в процессе: будем, отлаживать невалидные подписи вебхуков и, самое интересное, "приручать" LLM, которая не всегда следует инструкциям и возвращает JSON в самых причудливых форматах.

Эта статья - проводник в мир реальной автоматизации разработки. Пристегните ремни, мы начинаем.

Часть 1: Фундамент - Наш первый сервер.

Прежде чем ловить события из внешнего мира, нам нужно построить дом, в котором мы будем их обрабатывать. Этот "дом" - наш Node.js сервер. А его "мозг" - это функция, которая умеет общаться с языковой моделью.

Шаг 1: Идея и архитектура

На высоком уровне наша система будет работать по следующей схеме:

GitHub (событие: Pull Request) → Webhook → Наш Node.js сервер → OpenRouter AI (анализ кода) → GitHub API (публикация комментария)
GitHub (событие: Pull Request) → Webhook → Наш Node.js сервер → OpenRouter AI (анализ кода) → GitHub API (публикация комментария)

Давайте кратко обоснуем выбор технологий:

  • Node.js: Мы не будем усложнять проект фреймворками вроде Express или NestJS. Для нашей задачи - принять один тип POST-запроса и обработать его - достаточно встроенного в Node.js модуля http. Это делает наш сервер легковесным и избавляет от лишних зависимостей. Асинхронная природа Node.js идеально подходит для работы с внешними API, где большую часть времени мы будем просто ждать ответа.

  • OpenRouter: Вместо того чтобы привязываться к API одного конкретного провайдера, мы будем использовать OpenRouter. Это агрегатор, который предоставляет единый API, совместимый с API OpenAI, для доступа к десяткам моделей от разных разработчиков (Google, Anthropic, Mistral и др.). Главные плюсы это - возможность легко менять модели "на лету" и доступ к очень мощным бесплатным моделям, которые идеально подходят для нашего MVP.

Шаг 2: Создание базового Node.js сервера

Начнём с самого простого. Создадим в директории проекта файл index.js и напишем код веб-сервера, который будет слушать входящие запросы.

import http from 'http';

const server = http.createServer((req, res) => {
  // Пока просто отвечаем на любой запрос
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Server is alive!');
});

const port = 3000;
server.listen(port, () => {
  console.log(`Server is listening on http://localhost:${port}`);
});

Этот код использует встроенный модуль http для создания сервера. Он принимает любой запрос (req), отправляет в ответ статус 200 OK и простое текстовое сообщение.

Запустим его в терминале:

node index.js

Вы должны увидеть сообщение: Server is listening on http://localhost:3000. Наш фундамент готов.

Шаг 3: Первое обращение к AI

Теперь научим наш проект общаться с "мозгом". Для взаимодействия с API OpenRouter мы будем использовать официальную библиотеку от OpenAI, так как их API полностью совместимы.

Сначала установим её:

npm install openai

Для безопасного хранения нашего API-ключа мы будем использовать переменные окружения. Создайте в корне проекта файл .env и добавьте в него ваш ключ от OpenRouter. (сгенерировать апи ключ можно по адресу https://openrouter.ai/settings/keys)

OPENROUTER_API_KEY="..."

Чтобы Node.js "увидел" этот файл, установим пакет dotenv:

npm install dotenv

Теперь обновим наш index.js, добавив логику для вызова AI.

import http from 'http';
import OpenAI from 'openai';
import 'dotenv/config'; // Загружаем переменные из .env

// НАСТРОЙКА КЛИЕНТА ДЛЯ OPENROUTER
const openai = new OpenAI({
  baseURL: "https://openrouter.ai/api/v1",
  apiKey: process.env.OPENROUTER_API_KEY,
});

// АСИНХРОННАЯ ФУНКЦИЯ ДЛЯ ТЕСТА AI
async function testAI() {
  console.log("Отправляем тестовый запрос ИИ...");
  try {
    const completion = await openai.chat.completions.create({
      model: "qwen/qwen-2.5-72b-instruct:free",
      messages: [
        { role: "user", content: "Ответь на главный вопрос жизни, вселенной и вообще. (The Hitchhiker's Guide to the Galaxy)" }
      ],
    });

    console.log("Ответ ИИ:", completion.choices[0].message.content);
  } catch (error) {
    console.error("Ощибка вызова AI:", error);
  }
}

// СЕРВЕР (остается без изменений)
const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Server is alive!');
});

const port = 3000;
server.listen(port, () => {
  console.log(`Server is listening on http://localhost:${port}`);
  // Вызываем тестовую функцию при старте сервера
  testAI();
});

Что мы сделали:

  1. Импортировали dotenv/config, чтобы загрузить переменные из .env в process.env.

  2. Инициализировали клиент openai, указав baseURL от OpenRouter и наш API-ключ.

  3. Создали асинхронную функцию testAI, которая отправляет простой текстовый запрос модели и выводит её ответ в консоль.

  4. Вызвали эту функцию один раз при старте сервера для проверки.

Теперь, если вы запустите node index.js, вы увидите не только сообщение о старте сервера, но и ответ от языковой модели.

Итак, наш фундамент готов: у нас есть сервер, способный принимать запросы, и "мозг", способный генерировать осмысленные ответы.

Часть 2: Подключаемся к реальному миру - Вебхуки GitHub

Наш сервер работает, AI готов к труду и обороне. Но пока они существуют в вакууме на localhost. Чтобы наш ассистент мог реагировать на события в репозитории, нам нужно научить GitHub отправлять ему уведомления. Эти уведомления называются вебхуками.

Шаг 4: Проблема локальной разработки

Здесь мы сталкиваемся с первой фундаментальной проблемой. Наш сервер работает по адресу http://localhost:3000 - этот адрес доступен только на нашем компьютере. GitHub, находясь в глобальной сети, понятия не имеет, как отправить запрос на ваш localhost. Ему нужен публично доступный URL.

Классическое решение этой задачи - ngrok. Это утилита, которая создает безопасный туннель от публичного URL к вашему локальному порту. Это отличный инструмент, но для работы с GitHub есть решение элегантнее и удобнее, интегрированное в экосистему самого GitHub.

Шаг 5: Правильный инструмент - GitHub CLI

Вместо сторонних утилит мы воспользуемся официальным инструментом - GitHub CLI (gh). Это консольная утилита, которая позволяет делать с GitHub практически всё, не выходя из терминала. Одна из её самых полезных функций для нас - перенаправление вебхуков.

Сначала установим gh. Инструкции для всех ОС есть на официальной странице. После установки нужно будет авторизоваться, выполнив gh auth login.

Далее, установим специальное расширение для работы с вебхуками:

gh extension install cli/gh-webhook

Теперь у нас есть всё необходимое. Одной командой мы можем "сказать" GitHub: "Все вебхуки, связанные с Pull Request в этом репозитории, пожалуйста, пересылай на мой локальный сервер".

Убедитесь, что ваш Node.js сервер запущен, и в новом окне терминала выполните команду:

gh webhook forward --repo=<OWNER>/<REPO> --events=pull_request --url="http://localhost:3000/webhook"

Давайте разберём её:

--repo=<OWNER>/<REPO>: Укажите владельца и имя вашего репозитория (например, AlekseyVY/leetcode).

--events=pull_request: Мы подписываемся только на события, связанные с Pull Request.

--url="http://localhost:3000/webhook": Тот самый адрес, куда gh будет пересылать полученные события. Мы будем обрабатывать их по пути /webhook.

После выполнения вы должны увидеть сообщение Forwarding Webhook events from GitHub.... Поздравляю, мост между GitHub и вашим локальным сервером построен!

Шаг 6: Первая линия обороны - верификация подписи

Теперь наш сервер доступен извне. Но как нам убедиться, что запрос, пришедший на /webhook, был отправлен именно GitHub, а не каким-то злоумышленником? Для этого GitHub подписывает каждый вебхук. Он создает HMAC-хеш от тела запроса (payload), используя секретный ключ, который знаете только вы и он. Этот хеш отправляется в заголовке X-Hub-Signature-256. Наша задача - выполнить ту же операцию у себя на сервере и сравнить результаты. Если хеши совпадают - запрос подлинный. Для начала, обновим наш сервер, чтобы он обрабатывал путь /webhook и читал тело запроса. Нам понадобится пакет raw-body для корректного чтения потока.

npm install raw-body

затем создадим Секретный ключ, который мы будем использовать GITHUB_WEBHOOK_SECRET = 'my-super-secret-123'в нашем .env. ВАЖНО: Он должен быть таким же, как и тот, что мы укажем для gh.

Теперь напишем саму функцию верификации и интегрируем её в сервер.

// index.js (добавляем новые импорты и логику)
import http from 'http';
import crypto from 'crypto';
import getRawBody from 'raw-body';
// ... остальной код инициализации ...


const server = http.createServer(async (req, res) => {
  if (req.method === 'POST' && req.url === '/webhook') {
    // Верифицируем подпись
    const signature = req.headers['x-hub-signature-256'];
    const rawBody = await getRawBody(req);

    if (!verifySignature(signature, rawBody)) {
      console.error('Невалидная сигнатура, запрос проигнорирован.');
      res.writeHead(401, { 'Content-Type': 'text/plain' });
      res.end('Unauthorized');
      return;
    }

    // Если подпись верна, пока просто логируем событие
    const event = req.headers['x-github-event'];
    console.log(`Получено валидное событие вебхука: ${event}`);
    
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Webhook received!');
    return;
  }

  // Для всех остальных запросов
  res.writeHead(404);
  res.end();
});

function verifySignature(signature, payloadBody) {
  if (!signature) {
    return false;
  }
  const hmac = crypto.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET);
  const digest = 'sha256=' + hmac.update(payloadBody).digest('hex');
  
  // Используем crypto.timingSafeEqual для защиты от атак по времени
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest));
}

// ... код запуска сервера ...

"Ага!" момент: ловушка с "Невалидная сигнатура"

Теперь, если вы запустите сервер и создадите Pull Request, вы, скорее всего, столкнётесь с ошибкой в консоли: Невалидная сигнатура, запрос проигнорирован.. Почему? Ведь код верификации правильный.

Дело в том, что по умолчанию gh webhook forward сам генерирует случайный секрет для создаваемого на лету вебхука. А наш сервер ожидает my-super-secret-123. Секреты не совпадают. Решение простое - нужно явно указать gh, какой секрет использовать, с помощью флага --secret. Остановите предыдущую команду gh и запустите новую, правильную:

gh webhook forward --repo=<OWNER>/<REPO> --events=pull_request --url="http://localhost:3000/webhook" --secret "my-super-secret-123"

Теперь, когда вы создадите Pull Request, ваш сервер должен будет ответить в консоли Получено валидное событие вебхука: pull_request. Мы успешно и безопасно соединили наш сервер с GitHub. Он готов принимать события и проверять их подлинность.

Часть 3: MVP 1.0 - Общий комментарий к Pull Request

На этом этапе мы создадим первую рабочую версию нашего ассистента (Minimum Viable Product). Он будет выполнять три ключевые действия:

  1. Получать изменения кода (diff) из Pull Request.

  2. Отправлять их на анализ AI.

  3. Публиковать ответ модели в виде общего комментария к Pull Request.

Шаг 7: Получаем изменения кода (diff)

Вся информация о событии, включая Pull Request, приходит к нам в теле вебхука в формате JSON. Среди множества полей там есть одно, которое нам особенно интересно - pull_request.diff_url. Это прямая ссылка на .diff файл, содержащий все изменения кода в данном PR.

Чтобы получить доступ к этому файлу, нам нужно будет сделать аутентифицированный запрос к API GitHub. Для этого понадобится Personal Access Token (PAT).

Создать его можно в настройках вашего профиля GitHub:

  • Перейдите в Settings → Developer settings → Personal access tokens → Tokens (classic).

  • Нажмите Generate new token (classic).

  • Дайте токену имя (например, code-review-bot) и установите срок действия.

  • Самое важное - выберите права (scopes). Для наших задач достаточно одного: repo. Он даёт полный контроль над вашими репозиториями, включая чтение PR и написание комментариев.

  • Сгенерируйте токен и немедленно скопируйте его. Вы больше никогда не сможете его увидеть.

Добавим этот токен в наш .env файл:

# .env
OPENROUTER_API_KEY="..."
GITHUB_WEBHOOK_SECRET="..."
GITHUB_TOKEN="..."

Теперь напишем асинхронную функцию, которая будет скачивать diff.

// index.js (добавляем эту функцию в файл)

async function getPullRequestDiff(diffUrl) {
  console.log("Фетчим дифф:", diffUrl);
  const response = await fetch(diffUrl, {
    headers: {
      'Authorization': `token ${process.env.GITHUB_TOKEN}`,
      'Accept': 'application/vnd.github.v3.diff' // Важный заголовок, указывающий нужный формат
    }
  });

  if (!response.ok) {
    throw new Error(`Ошибка получения дифф: ${response.statusText}`);
  }
  return response.text();
}

Эта функция использует встроенный в Node.js fetch для выполнения запроса, добавляя в заголовки наш PAT для аутентификации.

Шаг 8: Первый промпт для код-ревью

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

// index.js (обновим нашу функцию для общения с AI)

async function getCodeReviewFromLLM(diff) {
  console.log("Sending diff to LLM for review...");
  const prompt = `
      Ты - экспертный AI-код-ревьюер. Твоя задача - провести обзор следующего pull request.
      Пользователь предоставил файл с диффом. Пожалуйста, проанализируй его и предоставь конструктивный отзыв.
      Сфокусируйся на возможных ошибках, несоответствиях стилю или предложениях по улучшению.
      Предоставь отзыв в понятной и дружелюбной форме, используя форматирование Markdown.
      Вот дифф:

      \`\`\`diff
      ${diff}
      \`\`\`
  `;

  try {
    const completion = await openai.chat.completions.create({
      model: "qwen/qwen-2.5-72b-instruct:free",
      messages: [{ role: "user", content: prompt }],
    });
    return completion.choices[0].message.content;
  } catch (error) {
    console.error("Ошибка вызова LLM API:", error);
    return "Извините, я не смог провести код-ревью на данный момент.";
  }
}

Шаг 9: Публикуем ответ

Получив ревью от модели, нам нужно опубликовать его в GitHub. Для этого мы будем использовать pull_request.comments_url из полезной нагрузки вебхука. Это эндпоинт для создания общих комментариев к PR.

// index.js (добавляем ещё одну функцию-хелпер)

async function postReviewComment(commentsUrl, reviewText) {
  console.log("Публикация комментария к review. :", commentsUrl);
  await fetch(commentsUrl, {
    method: 'POST',
    headers: {
      'Authorization': `token ${process.env.GITHUB_TOKEN}`,
      'Accept': 'application/vnd.github.v3+json',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ body: reviewText })
  });
}

Теперь соберём всё вместе в главной логике нашего сервера.

// index.js (обновляем обработчик вебхука)

const server = http.createServer(async (req, res) => {
  if (req.method === 'POST' && req.url === '/webhook') {
    // ... код верификации подписи ...
    
    // Парсим payload
    const payload = JSON.parse(rawBody.toString());
    const event = req.headers['x-github-event'];

    if (event === 'pull_request') {
      if (payload.action === 'opened') {
        const pr = payload.pull_request;
        console.log(`Обрабатываю PR: ${pr.title}`);
        
        // Запускаем процесс ревью
        const diff = await getPullRequestDiff(pr.diff_url);
        const review = await getCodeReviewFromLLM(diff);
        await postReviewComment(pr.comments_url, review);
      }
    }
    
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Webhook processed!');
    return;
  }
  // ...
});

Наш MVP 1.0 готов! Если сейчас создать PR, наш ассистент скачает diff, отправит его на анализ и опубликует результат в виде комментария. Но... скорее всего, вы столкнётесь с парой неприятных сюрпризов.

Шаг 10: Первые грабли и гонка состояний

Реальный мир сложнее, чем кажется, и при работе с асинхронными системами, такими как вебхуки, это проявляется особенно ярко.

  1. Проблема "пустых" или двойных комментариев:

  • Симптом: Вы создаете PR и видите, что бот оставляет несколько комментариев, один из которых может быть пустым.

  • Причина: Событие pull_request в GitHub срабатывает не только при открытии (opened), но и при других действиях: добавлении коммитов (synchronize), переводе из черновика в готовые (ready_for_review) и т.д. Наш код реагирует на все, что приводит к хаосу.

  • Решение: Сделать обработку событий более строгой. Мы должны реагировать только на те действия, которые нас интересуют. Идеально для этого подходит конструкция switch.

  1. Проблема пустого diff-файла:

  • Симптом: Бот оставляет комментарий, в котором жалуется на пустой diff, хотя в PR очевидно есть изменения.

  • Причина: API GitHub работает по принципу "согласованности в конечном счёте" (eventual consistency). Когда GitHub отправляет вебхук opened, это не гарантирует, что diff_url уже на 100% доступен и содержит данные. Наш сервер слишком быстр - он запрашивает diff раньше, чем тот успевает сгенерироваться.

  • Решение: Добавить небольшую искусственную задержку перед запросом diff-файла. 3-5 секунд обычно достаточно, чтобы дать API GitHub время "прийти в себя".

Вот как будет выглядеть наш улучшенный, более надёжный обработчик:

// index.js (финальная версия обработчика для MVP 1.0)
// ...
if (event === 'pull_request') {
  const action = payload.action;
  console.log(`Получил событие pull_request с действием: ${action}`);

  switch (action) {
    case 'opened':
    case 'reopened':
      res.writeHead(200, { 'Content-Type': 'text/plain' });
      res.end('Webhook accepted. Processing review in the background.');
      
      // Запускаем "медленный" процесс в фоне
      handlePullRequest(payload); 
      break;

    default:
      console.log(`Игнорирую действие "${action}".`);
      res.writeHead(200, { 'Content-Type': 'text/plain' });
      res.end(`Action "${action}" ignored.`);
      break;
  }
}
// ...

async function handlePullRequest(payload) {
  try {
    const pr = payload.pull_request;
    console.log(`Обрабатываю PR: "${pr.title}"`);

    // РЕШЕНИЕ 2: Добавляем задержку
    await new Promise(resolve => setTimeout(resolve, 5000));

    const diff = await getPullRequestDiff(pr.diff_url);
    if (diff && diff.trim()) {
      const review = await getCodeReviewFromLLM(diff);
      await postReviewComment(pr.comments_url, review);
      console.log(`Ревью орубликованно для PR: "${pr.title}"`);
    } else {
      console.log("Дифф пустой. Пропускаю ревью.");
    }
  } catch (error) {
    console.error("Ошибка обработки PR:", error);
  }
}

Обратите внимание на ещё одно улучшение: мы сначала отправляем GitHub ответ 200 OK, а уже потом запускаем handlePullRequest. Это лучшая практика для долгих задач. GitHub не любит, когда вебхуки "висят" без ответа больше нескольких секунд.

Теперь наш MVP не только работает, но и устойчив к основным проблемам реального мира. Однако общий комментарий - это хорошо, но настоящий ревьюер комментирует конкретные строки.

Часть 4: Level Up - От общих фраз к построчным комментариям

Общий комментарий - это уже неплохо, но ценность код-ревью в контексте. Замечания должны быть привязаны к конкретным строкам кода, чтобы разработчик мог сразу понять, о чём идёт речь, и начать обсуждение. Чтобы этого добиться, нам нужно научить модель возвращать структурированные данные и использовать другой, более мощный эндпоинт GitHub API.

Шаг 11: Промпт-инжиниринг для JSON

Просить модель вернуть обычный текст больше не годится. Нам нужно, чтобы на выходе мы получали данные в формате, который легко сможет "прочитать" наша программа. Идеальный кандидат - JSON.

Мы должны кардинально изменить наш промпт. Теперь его главная задача - не просто попросить сделать ревью, а строго указать, в каком формате вернуть результат.

  • Формат: Мы будем ожидать JSON-массив.

  • Элемент массива: Каждый элемент будет представлять собой один комментарий и должен быть объектом с тремя ключами:

    • path: путь к файлу, к которому относится комментарий.

    • line: номер строки в diff-файле.

    • body: текст самого комментария.

Вот как выглядит новый промпт:

// index.js (внутри функции getCodeReviewFromLLM)

const prompt = `
    Ты - высококвалифицированный AI-ревьюер кода. Твоя задача - провести ревью pull request на основе предоставленного diff-файла и вернуть свои замечания в формате JSON.

    Проанализируй следующие изменения. Для КАЖДОГО замечания, которое ты найдешь, укажи путь к файлу и номер строки в diff-файле, к которой относится замечание.

    Твой ответ ДОЛЖЕН быть валидным JSON-массивом объектов. Каждый объект должен иметь следующие ключи:
    - "path": (string) Полный путь к файлу.
    - "line": (number) Номер строки в diff-файле, к которой относится комментарий.
    - "body": (string) Текст твоего комментария.

    Пример желаемого формата ответа:
    [
      {
        "path": "src/user-service.ts",
        "line": 15,
        "body": "Здесь лучше использовать 'const' вместо 'let', так как переменная не переназначается."
      }
    ]

    Если в коде нет проблем, верни пустой массив [].

    Вот diff для анализа:
    \`\`\`diff
    ${diff}
    \`\`\`
`;

Чтобы помочь модели придерживаться формата, мы также можем использовать специальный параметр в запросе к API, который явно указывает, что мы ожидаем JSON.

// index.js (обновляем вызов openai.chat.completions.create)

const completion = await openai.chat.completions.create({
  model: "qwen/qwen-2.5-72b-instruct:free",
  messages: [{ "role": "user", "content": prompt }],
  // Говорим модели, что ответ должен быть в формате JSON
  response_format: { type: "json_object" }, 
});

const responseText = completion.choices[0].message.content;

// Модель может вернуть JSON внутри markdown блока, очистим это.
const cleanedJson = JSON.parse(responseText.replace(/```json/g, '').replace(/```/g, '').trim());
// или обьект с полем comments
if('comments' in cleanedJson) {
    return cleanedJson.comments;
}

return JSON.parse(cleanedJson);

Теперь наша функция getCodeReviewFromLLM возвращает не просто строку, а готовый JavaScript-массив с объектами комментариев.

Шаг 12: Новый API GitHub - Pull Request Reviews

Просто отправлять комментарии больше не получится. Чтобы привязать их к строкам, нам нужно использовать более сложный механизм — Pull Request Reviews.

Ревью в GitHub — это сущность, которая может объединять:

  • Один общий комментарий к Pull Request.

  • Несколько построчных комментариев.

  • Статус (Approve, Request Changes или просто Comment).

Для создания ревью используется новый эндпоинт: POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews.

Давайте напишем новую функцию для отправки такого ревью. Старая postReviewComment нам больше не понадобится.

// index.js (новая функция вместо postReviewComment)

async function postLineByLineReview(owner, repo, pull_number, comments) {
  const endpoint = `https://api.github.com/repos/${owner}/${repo}/pulls/${pull_number}/reviews`;
  console.log("Публикую комментарии:", endpoint);

  const reviewBody = {
    body: "AI-ассистент провел ревью кода. Пожалуйста, ознакомьтесь с комментариями.", // Общий комментарий
    event: "COMMENT", // Мы просто комментируем, не апрувим и не запрашиваем изменения
    comments: comments, // Массив наших построчных комментариев
  };

  const response = await fetch(endpoint, {
    method: 'POST',
    headers: {
      'Authorization': `token ${process.env.GITHUB_TOKEN}`,
      'Accept': 'application/vnd.github.v3+json',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(reviewBody)
  });

  if (!response.ok) {
    const errorBody = await response.text();
    throw new Error(`Ошибка публикации комментариев: ${response.statusText} - ${errorBody}`);
  }
}

Эта функция принимает все необходимые данные (владельца репо, имя репо, номер PR) и массив комментариев, который мы получили от LLM. Она формирует тело запроса и отправляет его в GitHub.

Осталось только обновить handlePullRequest, чтобы он вызывал новую функцию с правильными параметрами.

// index.js (обновляем handlePullRequest)

async function handlePullRequest(payload) {
  try {
    const pr = payload.pull_request;
    const repo = payload.repository;
    
    // Получаем нужные данные из payload
    const owner = repo.owner.login;
    const repoName = repo.name;
    const prNumber = pr.number;

    // ... (код с задержкой и получением diff) ...

    if (diff && diff.trim()) {
      const reviewComments = await getCodeReviewFromLLM(diff);
      if (reviewComments && reviewComments.length > 0) {
        await postLineByLineReview(owner, repoName, prNumber, reviewComments);
        console.log(`Ревью успешно проведено для PR: "${pr.title}"`);
      } else {
        console.log("LLM не вернула комментарии.");
      }
    } else {
      console.log("Дифф пустой, скипаю ревью.");
    }
  } catch (error) {
    console.error("Ошибк обработки PR:", error);
  }
}

Теперь наш ассистент перешёл на новый уровень. Он не просто бросает общие фразы, а участвует в ревью почти как настоящий член команды, оставляя замечания там, где им и место - в коде.

Казалось бы, работа сделана. Но, как вы догадываетесь, именно здесь и начинается самое интересное. Языковая модель - не компилятор. Она креативна, а иногда - слишком креативна.

Часть 5: Закаляем код - Битва с непредсказуемостью LLM

Теория великолепна: мы просим модель вернуть JSON, она его возвращает, мы его парсим и отправляем. Реальность гораздо интереснее. Языковая модель это не детерминированный API, а творческий партнёр. Иногда её "творчество" ломает наш код. В этой главе мы рассмотрим реальные ошибки, с которыми столкнулись, и превратим наш скрипт в по-настоящему отказоустойчивый инструмент.

Шаг 13: Парсинг и очистка данных

Первая же попытка запустить нашего обновлённого бота привела к ошибке 422 Unprocessable Entity от API GitHub. Сообщение гласило: "Pull request review thread path is invalid".

  • Проблема: Мы попросили модель вернуть path и line из diff-файла. Она справилась идеально. Вот только формат diff устроен так, что пути к файлам указываются с префиксами a/ и b/. Модель вернула нам путь вида b/src/index.js. Но API GitHub ожидает "чистый" путь: src/index.js. Он не смог найти файл b/src/index.js в репозитории и отклонил запрос.

  • Решение: Добавить простой шаг очистки данных перед отправкой в GitHub.

// index.js (внутри handlePullRequest)

// ... получили reviewComments от LLM ...

const cleanedComments = reviewComments.map(comment => ({
  ...comment,
  // Если путь начинается с 'b/', убираем первые два символа.
  // Аналогично можно добавить и для 'a/', если модель вдруг решит вернуть такой путь.
  path: comment.path.startsWith('b/') ? comment.path.substring(2) : comment.path
}));

// Отправляем в GitHub уже очищенные комментарии
await postLineByLineReview(owner, repoName, prNumber, cleanedComments);

Просто, но эффективно. Это первый урок: никогда не доверяйте данным от LLM на 100%, всегда валидируйте и очищайте их.

Шаг 14: Делаем код "пуленепробиваемым"

Следующие несколько запусков подарили нам целый каскад ошибок TypeError и SyntaxError. Каждый раз, когда мы думали, что предусмотрели всё, модель находила новый способ нас удивить. Вот наш "хит-парад" проблем и их решений.

  1. Проблема: Модель вернула ОБЪЕКТ {} вместо МАССИВА []

    • Симптом: Мы нашли одно замечание. Вместо того чтобы обернуть его в массив [{...}], модель вернула просто один объект {...}.

    • Ошибка: TypeError: reviewComments.map is not a function. Логично, у объектов нет метода .map().

    • Решение: Сделать наш парсер умнее. Если мы получили объект, нужно проверить, не является ли он сам по себе комментарием.

  2. Проблема: Модель вернула ОБЪЕКТ С ВЛОЖЕННЫМ МАССИВОМ { "comments": [...] }

    • Симптом: Модель решила быть "полезной" и обернула массив в объект с ключом comments.

    • Ошибка: Та же самая, reviewComments.map is not a function.

    • Решение: Если мы получили объект, нужно не только проверить, не является ли он сам комментарием, но и поискать, нет ли внутри него ключа, значение которого - массив.

  3. Проблема: Модель вернула ПУСТУЮ СТРОКУ ""

    • Симптом: Модель не нашла замечаний и вместо пустого массива [] вернула пустой ответ.

    • Ошибка: SyntaxError: Unexpected end of JSON input при попытке JSON.parse("").

    • Решение: Добавить проверку на пустую строку перед вызовом JSON.parse.

  4. Проблема: API вернул ОТВЕТ БЕЗ поля choices

    • Симптом: Редкий случай, когда API OpenRouter вернул 200 OK, но в теле ответа отсутствовал ключ choices. Это может случиться при внутреннем таймауте у провайдера модели.

    • Ошибка: TypeError: Cannot read properties of undefined (reading '0') при попытке доступа к completion.choices[0].

    • Решение: Добавить проверку на существование и непустоту completion.choices перед доступом к его элементам.

Чтобы решить все эти проблемы разом, мы написали финальную, надёжную логику парсинга в handlePullRequest и getCodeReviewFromLLM.

Вот обновлённый, закалённый в боях код:

// index.js (финальная, отказоустойчивая версия getCodeReviewFromLLM)
async function getCodeReviewFromLLM(diff) {
  // ... (промпт без изменений) ...
  try {
    const completion = await openai.chat.completions.create({ /* ... */ });
    
    // РЕШЕНИЕ 4: Проверяем 'choices'
    if (!completion.choices || completion.choices.length === 0) {
      console.log("LLM ответ не содержит поля 'choices'.");
      return null;
    }
    const responseText = completion.choices[0].message.content;
    
    // РЕШЕНИЕ 3: Проверяем на пустую строку
    if (!responseText || !responseText.trim()) {
      console.log("LLM вернула пустую строку.");
      return null;
    }
    const cleanedJson = responseText.replace(/```json/g, '').replace(/```/g, '').trim();
    return JSON.parse(cleanedJson);
  } catch (error) {
    console.error("Ошибка парсинга LLM ответа:", error);
    return null; // Возвращаем null в случае любой ошибки
  }
}

// index.js (финальная, отказоустойчивая версия handlePullRequest)
async function handlePullRequest(payload) {
  // ... (получение diff) ...
  const rawResponse = await getCodeReviewFromLLM(diff);
  console.log("Чистый AI ответ:", rawResponse);

  let commentsArray = [];
  // РЕШЕНИЕ 1 и 2: Гибкий парсинг
  if (Array.isArray(rawResponse)) {
    commentsArray = rawResponse; // Идеальный случай
  } else if (typeof rawResponse === 'object' && rawResponse !== null) {
    // Ищем массив внутри объекта
    let foundArray = false;
    for (const key in rawResponse) {
      if (Array.isArray(rawResponse[key])) {
        commentsArray = rawResponse[key];
        foundArray = true;
        break;
      }
    }
    // Если массив не найден, проверяем, не является ли сам объект комментарием
    if (!foundArray && rawResponse.path && rawResponse.line && rawResponse.body) {
      commentsArray = [rawResponse];
    }
  }

  if (commentsArray.length === 0) {
    console.log("Не найдено валидных комментариев. Пропускаю.");
    return;
  }
  
  // ... (очистка путей и отправка) ...
}

Шаг 15: Отладка проблем с API OpenRouter

Иногда проблемы возникают не в нашем коде и не в модели, а в конфигурации. Я столкнулся с двумя такими ошибками от OpenRouter:

  • 404 No endpoints found matching your data policy: Эта ошибка означает, что ваши настройки приватности в OpenRouter запрещают отправлять данные на бесплатные модели, которые могут использовать их для обучения. Решение - изменить политику на более разрешающую.

  • 400 Invalid Model ID: Просто опечатка или использование устаревшего ID модели. Решение - проверить актуальное название на сайте OpenRouter.

Этот опыт учит нас важному правилу: всегда внимательно читать сообщения об ошибках от внешних API.

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

Часть 6: Финальные штрихи и взгляд в будущее

Наш AI-ассистент работает, он надёжен и справляется с основной задачей. Но, как и в любом хорошем проекте, здесь всегда есть место для улучшений и новых идей. В этой заключительной части мы добавим несколько полезных штрихов и обсудим, как можно развивать этот инструмент дальше.

Шаг 16: Интернационализация

Что, если ваша команда предпочитает получать фидбэк на другом языке? С языковыми моделями это решается элементарно. Нам не нужно менять логику, добавлять файлы локализации или переписывать код. Всё, что требуется - немного изменить наш промпт.

Откроем функцию getCodeReviewFromLLM и добавим в инструкцию для модели несколько фраз на русском:

// index.js (отрывок из промпта в getCodeReviewFromLLM)

const prompt = `
    Ты - высококвалифицированный AI-ревьюер кода. Твоя задача - провести ревью pull request...

    // ... (описание формата JSON) ...

    Пожалуйста, предоставь весь твой ответ на русском языке.
    Если в коде нет проблем, верни пустой массив [].

    Вот diff для анализа:
    ...
`;

Этого достаточно. Модель поймёт контекст и переведёт свои ответы. Это яркий пример того, насколько гибкими становятся системы, построенные на LLM: изменение поведения часто сводится к изменению текста на естественном языке, а не переписыванию сложной логики.

Шаг 17: Куда двигаться дальше?

Наш MVP - это только начало. Вот несколько идей, как можно превратить его в полноценный промышленный инструмент:

  • Развёртывание в Kubernetes:
    Сейчас мы запускаем сервер локально. Для продакшена его следует упаковать в Docker-контейнер и развернуть в Kubernetes-кластере. Это даст нам масштабируемость, отказоустойчивость и позволит легко управлять секретами (API-ключами) через нативные инструменты K8s.

  • Интеграция с GitLab/Bitbucket:
    Демонстрация была сделана на GitHub, но архитектура нашего сервиса универсальна. GitLab, Bitbucket и другие системы контроля версий предоставляют очень похожие механизмы вебхуков. Адаптация нашего сервиса для GitLab сведётся к трём вещам:

    1. Разобраться со структурой JSON-объекта, который присылает GitLab (она будет отличаться от GitHub).

    2. Изменить эндпоинты для публикации комментариев в соответствии с API GitLab.

    3. Реализовать их механизм верификации подписи (например, через X-Gitlab-Token). Сама логика работы с LLM останется неизменной.

  • Более сложные промпты:
    Можно создать библиотеку промптов для разных типов файлов. Например, для .sql-файлов просить модель обращать внимание на потенциальные SQL-инъекции и производительность запросов, а для .js-файлов - на асинхронные паттерны и обработку ошибок.

  • Анализ изображений и диаграмм:
    Современные модели, такие как Google Gemini, мультимодальны - они могут анализировать не только текст, но и изображения. Можно расширить бота, чтобы он "смотрел" на скриншоты в комментариях и описывал, что на них видит, или даже анализировал загруженные в PR диаграммы архитектуры.

  • Сбор статистики и обучение команды:
    Каждый комментарий, оставленный ботом - это ценные данные. Можно сохранять их в базу данных и анализировать: какие типы ошибок встречаются в коде чаще всего? Это позволит выявить "слепые зоны" в знаниях команды и организовать целенаправленное обучение.

Заключение

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

Главный вывод, который мы сделали: создание таких AI‑инструментов — это не столько про вызов API, сколько про повышение отказоустойчивости и «приручение» мощной, но не всегда предсказуемой технологии. Каждый TypeError, каждая 422 Unprocessable Entity — это не провал, а возможность сделать систему умнее и надёжнее.

P.S. Полный код проекта доступен в этом GitHub-репозитории.

Комментарии (8)


  1. bow
    12.10.2025 21:40

    Хорошо вам, раз не запрещают корпоративный код отправлять в различные сторонние сервисы, либо код неконфиденциальный.


    1. AlekseyVY Автор
      12.10.2025 21:40

      На работе у нас на внутреннем контуре висит модель для таких кейсов. А для демо я использовал свой личный GitHub и бесплатные модели с OpenRouter.


      1. ferticultyap
        12.10.2025 21:40

        Подскажите название модели. Я бы тоже развернул у нас


        1. AlekseyVY Автор
          12.10.2025 21:40

          qwen3-coder


          1. ferticultyap
            12.10.2025 21:40

            Большое спасибо. Завтра чекну её


  1. Terranz
    12.10.2025 21:40

    >создадим с нуля

    >берём готовую нейронку

    Хех мда


  1. rumyash
    12.10.2025 21:40

    Есть несколько вопросов:

    1. Есть ли у вас какие-то метрики полезности замечаний от модели? Всё по делу пишет или половину потом приходится игнорировать?

    2. Продолжают ли обычные разработчики делать ревью или всё отдали на откуп модели?

    3. Добавляете ли в контекст что-то кроме диффов? Либо предоставляете модели какие-то инструменты для доступа к командным договоренностям и поиску мест, которые могут затронуть изменения? Просто посмотреть на диффы часто недостаточно для хорошего ревью. У разработчика здесь гораздо больше возможностей: опыт, знание проекта, IDE под рукой для поиска связанного кода. Если у модели ничего этого нет, то ревью чаще всего будет поверхностным. Можно, например, посмотреть как в qodo это сделано и позаимствовать идеи:)

    И пара уточнений по работе с запросами: промпт и диффы лучше разделять на пару сообщений system и user, это позволит кэшировать промпты, если модель это поддерживает и чуть-чуть экономить в стоимости; опять же, если модель поддерживает structured outputs, то лучше в запросах явно указывать схему, чтобы модель не сильно фантазировала в ответах и меньше кода писать на разбор этих фантазий.


  1. AlekseyVY Автор
    12.10.2025 21:40

    Привет! Спасибо огромное за советы. Попробую их применить. Что касается трёх пунктов, то тут пока нет очевидных ответов - всё на этапе внедрения и экспериментов.