Привет, я Татьяна Родионова, 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:

import 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 можно считать успешным.

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

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