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 = 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()
val type = rootElement.nodeName
rootElement.childNodes.forEach { file ->
val fileName = file.attributes.getNamedItem("name")
val lintErrors = arrayListOf()
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) {
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 можно считать успешным.
Надеюсь, моя статья была для вас полезной. Если остались вопросы, пишите их в комментариях!