Danger. Автоматизируем ревью на CI и пишем свой плагин

от автора

Привет, я Татьяна Родионова, Android-разработчица в Lamoda. Как-то раз передо мной появилась задача упростить ревью пул-реквестов с помощью Danger. Я решила добавить автоматическую проверку кодстайла, используя ktlint. Но оказалось, что Danger не поддерживает такое решение, поэтому я добавила такую проверку сама 🙂 

Моя статья поможет разобраться в том, как настроить Danger и как заставить его выполнять задачи немного сложнее тех, которые есть в официальном туториале.

Что такое Danger и как его установить

Danger — это система для автоматизации сообщений во время код-ревью, которая запускается на CI. Она позволяет избавиться от написания однотипных комментариев о кодстайле, ошибках в описании пул-реквеста, или, например, о его размере.

Для установки потребуется nix-система. На MacOS можно воспользоваться командой:

brew install danger/tap/danger-kotlin

А на Linux:

bash <(curl -s https://raw.githubusercontent.com/danger/kotlin/master/scripts/install.sh) source ~/.bash_profile

Настройка контейнера с Danger

Для настройки конфигурации Danger используется Dangerfile.df.kts, который лежит в корне проекта. Язык — Kotlin DSL, с поддержкой автодополнения и подсветкой синтаксиса в Android Studio.

Конфигурацию нужно написать самому и включить в нее те элементы, которые необходимы для конкретной задачи. Вот пример стандартной конфигурации с Github:

mport systems.danger.kotlin.*  danger(args) {      val allSourceFiles = git.modifiedFiles + git.createdFiles     val changelogChanged = allSourceFiles.contains("CHANGELOG.md")     val sourceChanges = allSourceFiles.firstOrNull { it.contains("src") }      onGitHub {         val isTrivial = pullRequest.title.contains("#trivial")          // Changelog         if (!isTrivial && !changelogChanged && sourceChanges != null) {             warn(WordUtils.capitalize("any changes to library code should be reflected in the Changelog."))         }          // Big PR Check         if ((pullRequest.additions ?: 0) - (pullRequest.deletions ?: 0) > 300) {             warn("Big PR, try to keep changes smaller if you can")         }          // Work in progress check         if (pullRequest.title.contains("WIP", false)) {             warn("PR is classed as Work in Progress")         }     } }

Эта конфигурация для Github проверяет, нужно ли добавлять информацию в changelog, не слишком ли это большой пул-реквест (>300 изменений) и есть ли WIP (Work In Progress) в названии пул-реквеста.

В нашем проекте было решено запускать Danger изолированно, поэтому он настроен в Docker-контейнере. На это было несколько причин:

  • Это удобно, так как docker-контейнер содержит только Danger, и необходимое окружение.

  • У Danger есть конфликтующие имплементации. При запуске Danger вызывается команда which danger, которая определяет, какую вызвать имплементацию. На нашем CI была установлена ruby-имплементация Danger для iOS, вызываемая по умолчанию, поэтому для запуска Danger под Android пришлось бы ее удалять (Android требует JS-имплементацию). С docker таких проблем нет.

Для запуска необходима конфигурация в Dockerfile и скрипты запуска build.sh и env.list со списком переменных окружения.

Начнем с настройки конфигурации docker-файла. Устанавливаем make, nodejs и danger js:

RUN apt-get update \     && apt-get install make \     && apt-get install -y ca-certificates \     && curl -sL https://deb.nodesource.com/setup_12.x |  bash - \     && apt-get install -y make zip nodejs \     && npm install -g danger

Далее устанавливаем kotlinc, скачиваем danger-kotlin и собираем его из исходного кода из репозитория:

RUN curl -o kotlinc_inst.zip -L $KOTLINC_URL \     && mkdir -p ${ANDROID_SDK_ROOT}/kotlinc/ \     && unzip -q kotlinc_inst.zip -d /tmp/ \     && mv /tmp/kotlinc/ ${ANDROID_SDK_ROOT}/kotlinc/ \     && rm kotlinc_inst.zip \     && git clone https://github.com/danger/kotlin.git --branch 1.0.0 --depth 1 _danger-kotlin \     && cd _danger-kotlin \     && sed -i 's/val emailAddress: String,/val emailAddress: String? = null,/g' $FILE \     && make install 

Тут можно заметить фикс в виде вызова sed. Это исправление кейса, когда у автора пул-реквеста нет email (например, он уже уволился). В таком случае пул-реквест не запустится из-за бага, который не починили разработчики. 

Скрипт запуска build.sh выглядит следующим образом:

#!/bin/sh  set -e  echo "Running danger"  ./gradlew ktlint danger-kotlin $DANGER_ARG  echo "Build successfully finished" exit 0

Кстати, перед запуском Danger нужно запустить ktlint. Сам по себе Danger не запускает gradle-таски, но может интерпретировать их результаты с помощью плагинов.

В файл с переменными env.list добавляем переменные окружения, необходимые для запуска контейнера. В нашем случае используются переменные для Bitbucket, но они с легкостью могут быть заменены на другие поддерживаемые веб-сервисы для хостинга — Github или Gitlab. 

Для корректной работы в нужно указать host, username и token аккаунта, который будет постить сообщения. Дополнительно я добавила DANGER_ARG, чтобы была возможность менять тип команды и указывать дополнительные параметры: например, pr — отображает вывод Danger в консоль, а ci постит комментарий в пул-реквест:

DANGER_BITBUCKETSERVER_HOST=https://stash.lamoda.ru DANGER_BITBUCKETSERVER_USERNAME=La Cat DANGER_BITBUCKETSERVER_TOKEN=Заполнить перед сборкой DANGER_ARG=pr ci

Все готово к запуску, теперь собираем наш контейнер! Для этого запускаем команду:

docker build -t mobile.docker.lamoda.ru/android/build/android-danger:1.0

mobile.docker.lamoda.ru/android/build/android-danger:1.0  — имя образа Docker. Запускаем его:

docker run --name android-danger -it -v /path/to/project/source/files:/src -w /src --env-file env.list mobile.docker.lamoda.ru/android/build/android-danger:1.0

Чтобы залить образ в хранилище артефактов, исполняем эту команду:  

docker push mobile.docker.lamoda.ru/android/build/android-danger:1.0

Теперь у нас есть готовый образ, который можно использовать на CI. Открываем готовый план (мы в Lamoda используем bamboo), идем в настройки и указываем образ для скачивания:

Далее добавляем шаг для запуска контейнера со скаченным образом:

Дополнительно нужно указать в переменных окружения bitbucket host, username и token, чтобы была возможность быстро изменить их без пересборки контейнера. Также в переменные окружения контейнера нужно добавить номер пул-реквеста pr_key, repositoryUrl и имя плана — без них Danger не увидит нужные ему параметры пул-реквеста при запуске.

bamboo_repository_pr_key=${bamboo.repository.pr.key} bamboo_planRepository_repositoryUrl=${bamboo.planRepository.repositoryUrl} bamboo_buildPlanName=${bamboo.buildPlanName}

Контейнер на CI настроен. По умолчанию установим триггер bamboo на создание и обновление пул-реквеста: Danger должен запускаться при создании пул-реквеста и его обновлении.

Теперь осталось разобраться с конфигурацией Danger.

Плагины Danger

Проверки Danger ограничиваются встроенным API. Используя его, можно проверять данные, которые касаются пул-реквеста: например, коммиты и их авторов. Но более сложные задачи реализуются плагинами. Их написано немного, но с их помощью можно запустить Android Lint, Detect или получить отчет о запуске JUnit на вашем пул-реквесте.

Для подключения плагина перед написанием кода в Dangerfile нужно указать его зависимости – репозиторий, где лежит файл, а также его название, версию, и зарегистрировать используемый плагин. Например:

@file:Repository("https://repo.maven.apache.org") @file:DependsOn("groupId:artifactId:version")  register plugin TestPlugin

Мне хотелось запустить ktlint, но нужного плагина не было. Поэтому я решила написать свой простой плагин для отображения результатов. Я создавала плагин отдельно от проекта, используя композитный билд.

Так как Danger не может запускать таски самостоятельно, а только интерпретирует результаты, то для начала нужно запустить ktlint, используя gradle в контейнере. Напомню, как выглядит скрипт запуска контейнера:

#!/bin/sh  set -e  echo "Running danger"  ./gradlew ktlint danger-kotlin $DANGER_ARG  echo "Build successfully finished" exit 0

 Так как отчет представляет из себя xml-файл, то основную работу будет выполнять парсер. Пример ktlint-отчета:

Подключим зависимость от Danger SDK в build.gradle:

dependencies {     implementation "systems.danger:danger-kotlin-sdk:1.2" }

Создаем kotlin-object и наследуемся от DangerPlugin. Переопределяем обязательный id:

object DangerKtlintPlugin : DangerPlugin() { override val id: String     get() = "systems.danger.ktlint.plugin" }

Добавляем три функции — print, parse и parseAll. В print будем передавать путь (или список путей) к отчету, а parse будет вызывать парсер.

fun print(vararg lintFiles: String) {    report(parseAll(*lintFiles)) }  private fun parse(lintFilePath: String): ErrorType = LintParser.parse(lintFilePath).type  private fun parseAll(vararg lintFilePaths: String): List<ErrorType> = lintFilePaths.map(::parse)

Теперь напишем сам парсер. Обходим все узлы в xml-файле, и собираем данные в модель:

internal object LintParser {      fun parse(filePath: String): KtlintStructure {         val factory = DocumentBuilderFactory.newInstance()         val builder = factory.newDocumentBuilder()          val document = builder.parse(java.io.File(filePath))         val rootElement = document.documentElement         val files = arrayListOf<File>()          val type = rootElement.nodeName          rootElement.childNodes.forEach { file ->             val fileName = file.attributes.getNamedItem("name")             val lintErrors = arrayListOf<LintError>()              file.childNodes.forEach { lintError ->                 lintErrors.add(                     LintError(                         line = lintError.attributes.getNamedItem("line").nodeValue,                         col = lintError.attributes.getNamedItem("column").nodeValue,                         severity = lintError.attributes.getNamedItem("severity").nodeValue,                         ruleId = lintError.attributes.getNamedItem("source").nodeValue,                         detail = lintError.attributes.getNamedItem("message").nodeValue,                     ),                 )             }             files.add(File(lintErrors = lintErrors, name = fileName.nodeValue))         }         return KtlintStructure(ErrorType(type = type, files = files))     }

Далее добавляем функцию для вывода сообщения:

private fun report(errors: List<ErrorType>) {    errors.forEach { errorType ->        errorType.files.forEach { file ->            file.lintErrors.forEach { lintError ->                val message = "⚠️ ${errorType.type} ${lintError.severity}: " +                    "line: ${lintError.line}, column: ${lintError.col}, " +                    "message: ${lintError.detail}," + "\n" +                    "path:${file.name} "                context.fail(                    message,                )            }        }    } }

Если есть ошибки в ktlint, то Danger выводит об этом сообщение. В таком случае помечаем сборку как неудачную.

Плагин написан, осталось собрать его. Настраиваем конфигурацию для build.gradle:

buildscript {     repositories {         // ссылка на ваше хранилище     }      dependencies {         classpath "systems.danger:danger-plugin-installer:0.1"     } }  plugins {     id 'org.jetbrains.kotlin.jvm' version '1.5.0' }  apply plugin: 'danger-kotlin-plugin-installer'  group 'systems.danger.ktlint' version '1.0'  repositories {     // ссылка на ваше хранилище }  dangerPlugin {     outputJar = "${buildDir}/libs/danger-ktlint-plugin.jar" }  dependencies {     implementation "org.jetbrains.kotlin:kotlin-stdlib"     implementation "systems.danger:danger-kotlin-sdk:1.2" }

Для сборки необходим jar-файл danger-plugin-installer. Его можно собрать в одноименном модуле из официального репозитория, вызвав ./gradlew jar.

После необходимо указать этот jar в качестве зависимости для плагина и добавить его в репозиторий. Как только зависимость danger-plugin-installer будет установлена, нужно указать путь outputJar для написанного плагина, а также запустить команды ./gradlew build и ./gradlew installDangerPlugin.

Теперь в /build/libs появился jar библиотеки — danger-ktlint-plugin-1.0.jar. Для использования плагина его можно залить в приватное хранилище, подключить локально или можно опубликовать в opensource, используя maven publish.

Результат

В итоге Dangerfile выглядит следующим образом. Импортируем необходимые пакеты, регистрируем плагин — и все готово к использованию.

@file:Repository(*ссылка на ваше хранилище*) @file:DependsOn("com.lamoda:danger-ktlint-plugin:1.0")  import com.lamoda.danger_ktlint_plugin.DangerKtlintPlugin import systems.danger.kotlin.*  register plugin DangerKtlintPlugin  danger(args) {     DangerKtlintPlugin.print("lamoda/build/reports/ktlint.xml") }

Теперь при запуске Danger плагин будет интерпретировать отчет ktlint и выводить в пул-реквесте в следующем виде:

Если вы исправите ошибки в следующем коммите, то сообщение обновится, и билд Danger можно считать успешным.

Надеюсь, моя статья была для вас полезной. Если остались вопросы, пишите их в комментариях!


ссылка на оригинал статьи https://habr.com/ru/company/lamoda/blog/681564/