diff --git a/README.md b/README.md index 22069ab..c4a147d 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ 1. Запуск валидации проекта средствами EDT и конвертация отчета в формате generic issues. 1. Запуск статического анализа для SonarQube. 1. Публикация результатов junit и Allure в интерфейс Jenkins. +1. Рассылка результатов сборки на почту и в Telegram. 1. Конфигурирование логгера запускаемых oscript-приложений. ## Подключение @@ -109,9 +110,11 @@ pipeline1C() * Исходники конфигурации ожидаются в каталоге `src/cf` (`srcDir`). * Формат исходников - выгрузка из Конфигуратора (`sourceFormat`). * Ветка по умолчанию (для комбинированного режима загрузки конфигурации) - "main" (`defaultBranch`). - * Имена "секретов" (jenkins credentials, `secrets`) по умолчанию высчитываются из пути к git-репозиторию (без учета домена, с заменой `/` на `_`) с прибавлением ключа секрета. Например, для репозитория https://github.com/firstBitSemenovskaya/jenkins-lib секрет с адресом хранилища будет выглядеть как `firstBitSemenovskaya_jenkins-lib_STORAGE_PATH`. Ключи секретов: + * Имена большинства "секретов" (jenkins credentials, `secrets`) по умолчанию высчитываются из пути к git-репозиторию (без учета домена, с заменой `/` на `_`) с прибавлением ключа секрета. Например, для репозитория https://github.com/firstBitSemenovskaya/jenkins-lib секрет с адресом хранилища будет выглядеть как `firstBitSemenovskaya_jenkins-lib_STORAGE_PATH`. Ключи секретов: * `STORAGE_PATH` - путь к хранилищу конфигурации (для `secrets` -> `storagePath`); * `STORAGE_USER` - параметры авторизации в хранилище вида "username with password" (для `secrets` -> `storage`). + * `TELEGRAM_CHAT_ID` - идентификатор чата Telegram для рассылки уведомлений и результате сборки вида "secret text" (для `secrets` -> `telegramChatId`). + * Секрет `TELEGRAM_BOT_TOKEN` задается глобально на весь сервер Jenkins, либо может быть переопределен (`secrets` -> `telegramBotToken`) * Все "шаги" по умолчанию выключены (`stages`). * Если в корне репозитория существует файл `packagedef`, то в шагах, работающих с информационной базой, будет выполнена попытка установки локальных зависимостей средствами `opm`. * Если после установки локальных зависимостей в каталоге `oscript_modules/bin` существует файл `vrunner`, то для выполнения команд работы с информационной базой будет использоваться он, а не глобально установленный `vrunner` из `PATH`. @@ -150,3 +153,16 @@ pipeline1C() * Шаг анализа не дожидается окончания фонового задания на сервере SonarQube и не анализирует результат прохождения Порога качества (`sonarqube` -> `waitForQualityGate`). Для этого необходимо заполнить параметр (`sonarqube` -> `infoBaseUpdateModuleName`). Если параметр не заполнен, версия передается из корня конфигурации. * Если выполнялась валидация EDT, результаты валидации в формате `generic issues` передаются утилите `sonar-scanner` как значение параметра `sonar.externalIssuesReportPaths`. +* Рассылка уведомлений: + * Электронная почта: + * Для отправки используется плагин [`email-ext`](https://plugins.jenkins.io/email-ext). Шаблоны сообщений конфигурируются в настройках плагина. + * Уведомления о результатах сборки по умолчанию рассылаются только при полном падении сборочной линии (`notifications` -> `email` -> `onAlways`, `onFailure`, `onUnstable`, `onSuccess`). + * Лог сборки прикладывается к письму при полном падении сборочной линии и при отправке в режиме "всегда отправлять" (`notifications` -> `email` -> `*options` -> `attachLog`). + * В качестве получателей писем (`notifications` -> `email` -> `*options` -> `recipientProviders`) в различных режимах отправки используются: + * всегда - разработчики и запустивший сборку; + * при падении - разработчики, запустивший сборку и подозреваемый в причине падения сборки; + * при успехе - разработчики и запустивший сборку; + * при нестабильной сборке (упавшие тесты) - разработчики и запустивший сборку. + * Прямые получатели уведомлений не заполнены (`notifications` -> `email` -> `*options` -> `directRecipients`). + * Telegram: + * Уведомления о результатах сборки по умолчанию рассылаются всегда (`notifications` -> `telegram` -> `onAlways`, `onFailure`, `onUnstable`, `onSuccess`). \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 903165c..f53a5c2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -80,6 +80,7 @@ sharedLibrary { dependency("org.jenkins-ci.plugins", "pipeline-build-step", "2.12") dependency("org.jenkins-ci.plugins", "pipeline-utility-steps", "2.8.0") dependency("org.jenkins-ci.plugins", "git", "4.4.4") + dependency("org.jenkins-ci.plugins", "http_request", "1.15") dependency("org.6wind.jenkins", "lockable-resources", "2.7") dependency("ru.yandex.qatools.allure", "allure-jenkins-plugin", "2.28.1") val declarativePluginsVersion = "1.6.0" @@ -87,5 +88,6 @@ sharedLibrary { dependency("org.jenkinsci.plugins", "pipeline-model-declarative-agent", "1.1.1") dependency("org.jenkinsci.plugins", "pipeline-model-definition", declarativePluginsVersion) dependency("org.jenkinsci.plugins", "pipeline-model-extensions", declarativePluginsVersion) + dependency("io.jenkins.blueocean", "blueocean-pipeline-api-impl", "1.25.3") } } diff --git a/resources/globalConfiguration.json b/resources/globalConfiguration.json index e770b9c..5fcd3eb 100644 --- a/resources/globalConfiguration.json +++ b/resources/globalConfiguration.json @@ -7,7 +7,9 @@ "defaultBranch": "main", "secrets": { "storagePath": "UNKNOWN_ID", - "storage": "UNKNOWN_ID" + "storage": "UNKNOWN_ID", + "telegramBotToken": "UNKNOWN_ID", + "telegramChatId": "UNKNOWN_ID" }, "stages": { "initSteps": false, @@ -15,7 +17,9 @@ "bdd": false, "syntaxCheck": false, "edtValidate": false, - "smoke": false + "smoke": false, + "email": false, + "telegram": false }, "timeout": { "smoke": 240, @@ -77,5 +81,52 @@ "removeSupport": true, "supportLevel": 0 }, + "notifications": { + "email": { + "onAlways": false, + "onFailure": true, + "onUnstable": false, + "onSuccess": false, + "alwaysOptions": { + "attachLog": true, + "directRecipients": [], + "recipientProviders": [ + "developers", + "requestor" + ] + }, + "failureOptions": { + "attachLog": true, + "directRecipients": [], + "recipientProviders": [ + "developers", + "requestor", + "brokenBuildSuspects" + ] + }, + "successOptions": { + "attachLog": false, + "directRecipients": [], + "recipientProviders": [ + "developers", + "requestor" + ] + }, + "unstableOptions": { + "attachLog": false, + "directRecipients": [], + "recipientProviders": [ + "developers", + "requestor" + ] + } + }, + "telegram": { + "onAlways": true, + "onFailure": false, + "onUnstable": false, + "onSuccess": false + } + }, "logosConfig": "" } diff --git a/resources/schema.json b/resources/schema.json index 9c5b6ec..fbeb0dd 100644 --- a/resources/schema.json +++ b/resources/schema.json @@ -35,6 +35,14 @@ "storage" : { "type" : "string", "description" : "Данные авторизации в хранилище конфигурации" + }, + "telegramChatId" : { + "type" : "string", + "description" : "Идентификатор telegram-чата для отправки уведомлений" + }, + "telegramBotToken" : { + "type" : "string", + "description" : "Токен авторизации telegram-бота для отправки уведомлений" } } }, @@ -66,6 +74,14 @@ "bdd" : { "type" : "boolean", "description" : "Запуск BDD сценариев включен" + }, + "email" : { + "type" : "boolean", + "description" : "Выполнять рассылку результатов сборки на email" + }, + "telegram" : { + "type" : "boolean", + "description" : "Выполнять рассылку результатов сборки в telegram" } } }, @@ -257,6 +273,93 @@ } } }, + "notifications" : { + "type" : "object", + "id" : "urn:jsonschema:ru:pulsar:jenkins:library:configuration:NotificationsOptions", + "description" : "Настройки рассылки результатов сборки", + "properties" : { + "email" : { + "type" : "object", + "id" : "urn:jsonschema:ru:pulsar:jenkins:library:configuration:notification:EmailNotificationOptions", + "description" : "Настройки рассылки результатов сборки через email", + "properties" : { + "onAlways" : { + "type" : "boolean", + "description" : "Отправлять всегда" + }, + "onSuccess" : { + "type" : "boolean", + "description" : "Отправлять при успешной сборке" + }, + "onFailure" : { + "type" : "boolean", + "description" : "Отправлять при падении сборки" + }, + "onUnstable" : { + "type" : "boolean", + "description" : "Отправлять при нестабильной сборке" + }, + "alwaysOptions" : { + "type" : "object", + "id" : "urn:jsonschema:ru:pulsar:jenkins:library:configuration:notification:email:EmailExtConfiguration", + "properties" : { + "attachLog" : { + "type" : "boolean" + }, + "directRecipients" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "recipientProviders" : { + "type" : "array", + "items" : { + "type" : "string", + "enum" : [ "developers", "requestor", "brokenBuildSuspects", "brokenTestsSuspects" ] + } + } + } + }, + "successOptions" : { + "type" : "object", + "$ref" : "urn:jsonschema:ru:pulsar:jenkins:library:configuration:notification:email:EmailExtConfiguration" + }, + "failureOptions" : { + "type" : "object", + "$ref" : "urn:jsonschema:ru:pulsar:jenkins:library:configuration:notification:email:EmailExtConfiguration" + }, + "unstableOptions" : { + "type" : "object", + "$ref" : "urn:jsonschema:ru:pulsar:jenkins:library:configuration:notification:email:EmailExtConfiguration" + } + } + }, + "telegram" : { + "type" : "object", + "id" : "urn:jsonschema:ru:pulsar:jenkins:library:configuration:notification:TelegramNotificationOptions", + "description" : "Настройки рассылки результатов сборки через telegram", + "properties" : { + "onAlways" : { + "type" : "boolean", + "description" : "Отправлять всегда" + }, + "onSuccess" : { + "type" : "boolean", + "description" : "Отправлять при успешной сборке" + }, + "onFailure" : { + "type" : "boolean", + "description" : "Отправлять при падении сборки" + }, + "onUnstable" : { + "type" : "boolean", + "description" : "Отправлять при нестабильной сборке" + } + } + } + } + }, "logosConfig" : { "type" : "string", "description" : "Конфигурация библиотеки logos. Применяется перед запуском каждой стадии сборки" diff --git a/src/ru/pulsar/jenkins/library/IStepExecutor.groovy b/src/ru/pulsar/jenkins/library/IStepExecutor.groovy index a41cbeb..beb4320 100644 --- a/src/ru/pulsar/jenkins/library/IStepExecutor.groovy +++ b/src/ru/pulsar/jenkins/library/IStepExecutor.groovy @@ -1,7 +1,11 @@ package ru.pulsar.jenkins.library +import jenkins.plugins.http_request.HttpMode +import jenkins.plugins.http_request.MimeType +import jenkins.plugins.http_request.ResponseContentSupplier import org.jenkinsci.plugins.pipeline.utility.steps.fs.FileWrapper import org.jenkinsci.plugins.workflow.support.actions.EnvironmentAction +import org.jenkinsci.plugins.workflow.support.steps.build.RunWrapper interface IStepExecutor { @@ -69,7 +73,9 @@ interface IStepExecutor { def catchError(Closure body) - def httpRequest(String url, String outputFile, String responseHandle, boolean wrapAsMultipart) + ResponseContentSupplier httpRequest(String url, String outputFile, String responseHandle, boolean wrapAsMultipart) + + ResponseContentSupplier httpRequest(String url, HttpMode httpMode, MimeType contentType, String requestBody, String validResponseCodes, boolean consoleLogResponseBody) def error(String errorMessage) @@ -78,4 +84,16 @@ interface IStepExecutor { def junit(String testResults, boolean allowEmptyResults) def installLocalDependencies() + + def emailext(String subject, String body, String to, List recipientProviders, boolean attachLog) + + def developers() + + def requestor() + + def brokenBuildSuspects() + + def brokenTestsSuspects() + + RunWrapper currentBuild() } \ No newline at end of file diff --git a/src/ru/pulsar/jenkins/library/StepExecutor.groovy b/src/ru/pulsar/jenkins/library/StepExecutor.groovy index ecde586..b58b494 100644 --- a/src/ru/pulsar/jenkins/library/StepExecutor.groovy +++ b/src/ru/pulsar/jenkins/library/StepExecutor.groovy @@ -1,7 +1,11 @@ package ru.pulsar.jenkins.library +import jenkins.plugins.http_request.HttpMode +import jenkins.plugins.http_request.MimeType +import jenkins.plugins.http_request.ResponseContentSupplier import org.jenkinsci.plugins.pipeline.utility.steps.fs.FileWrapper import org.jenkinsci.plugins.workflow.support.actions.EnvironmentAction +import org.jenkinsci.plugins.workflow.support.steps.build.RunWrapper import ru.yandex.qatools.allure.jenkins.config.ResultsConfig class StepExecutor implements IStepExecutor { @@ -153,10 +157,22 @@ class StepExecutor implements IStepExecutor { } @Override - def httpRequest(String url, String outputFile, String responseHandle = 'NONE', boolean wrapAsMultipart = false) { + ResponseContentSupplier httpRequest(String url, String outputFile, String responseHandle = 'NONE', boolean wrapAsMultipart = false) { steps.httpRequest responseHandle: responseHandle, outputFile: outputFile, url: url, wrapAsMultipart: wrapAsMultipart } + @Override + ResponseContentSupplier httpRequest(String url, HttpMode httpMode, MimeType contentType, String requestBody, String validResponseCodes, boolean consoleLogResponseBody) { + steps.httpRequest( + url: url, + httpMode: httpMode, + contentType: contentType, + requestBody: requestBody, + validResponseCodes: validResponseCodes, + consoleLogResponseBody: consoleLogResponseBody + ) + } + @Override def error(String errorMessage) { steps.error errorMessage @@ -183,4 +199,40 @@ class StepExecutor implements IStepExecutor { def installLocalDependencies() { steps.installLocalDependencies() } + + @Override + def emailext(String subject, String body, String to, List recipientProviders, boolean attachLog) { + steps.emailext( + subject: subject, + body: body, + to: to, + recipientProviders: recipientProviders, + attachLog: attachLog, + ) + } + + @Override + def developers() { + steps.developers() + } + + @Override + def requestor() { + steps.requestor() + } + + @Override + def brokenBuildSuspects() { + steps.brokenBuildSuspects() + } + + @Override + def brokenTestsSuspects() { + steps.brokenTestsSuspects() + } + + @Override + RunWrapper currentBuild() { + steps.currentBuild + } } diff --git a/src/ru/pulsar/jenkins/library/configuration/ConfigurationReader.groovy b/src/ru/pulsar/jenkins/library/configuration/ConfigurationReader.groovy index ca75e17..c9587a8 100644 --- a/src/ru/pulsar/jenkins/library/configuration/ConfigurationReader.groovy +++ b/src/ru/pulsar/jenkins/library/configuration/ConfigurationReader.groovy @@ -6,8 +6,11 @@ import com.fasterxml.jackson.databind.ObjectMapper import org.apache.commons.beanutils.BeanUtilsBean import org.apache.commons.beanutils.ConvertUtilsBean import ru.pulsar.jenkins.library.IStepExecutor +import ru.pulsar.jenkins.library.configuration.notification.email.EmailExtConfiguration import ru.pulsar.jenkins.library.ioc.ContextRegistry +import static java.util.Collections.emptySet + class ConfigurationReader implements Serializable { private static ObjectMapper mapper @@ -62,12 +65,21 @@ class ConfigurationReader implements Serializable { "sonarQubeOptions", "smokeTestOptions", "syntaxCheckOptions", - "resultsTransformOptions" + "resultsTransformOptions", + "notificationsOptions", + "emailNotificationOptions", + "alwaysEmailOptions", + "successEmailOptions", + "failureEmailOptions", + "unstableEmailOptions", + "recipientProviders", + "telegramNotificationOptions" ).toSet() mergeObjects(baseConfiguration, configurationToMerge, nonMergeableSettings) - mergeInitInfoBaseOptions(baseConfiguration.initInfoBaseOptions, configurationToMerge.initInfoBaseOptions); - mergeBddOptions(baseConfiguration.bddOptions, configurationToMerge.bddOptions); + mergeInitInfoBaseOptions(baseConfiguration.initInfoBaseOptions, configurationToMerge.initInfoBaseOptions) + mergeBddOptions(baseConfiguration.bddOptions, configurationToMerge.bddOptions) + mergeNotificationsOptions(baseConfiguration.notificationsOptions, configurationToMerge.notificationsOptions) return baseConfiguration; } @@ -84,10 +96,16 @@ class ConfigurationReader implements Serializable { } nonMergeableSettings.forEach({ key -> + if (!baseObject.hasProperty(key)) { + return + } + if (objectToMerge == null) { + return + } mergeObjects( baseObject[key], objectToMerge[key], - Collections.emptySet() + nonMergeableSettings ) }) } @@ -107,4 +125,53 @@ class ConfigurationReader implements Serializable { } baseObject.vrunnerSteps = objectToMerge.vrunnerSteps.clone() } + + + private static void mergeNotificationsOptions(NotificationsOptions baseObject, NotificationsOptions objectToMerge) { + if (objectToMerge == null) { + return + } + + if (objectToMerge.telegramNotificationOptions != null) { + + mergeObjects( + baseObject.telegramNotificationOptions, + objectToMerge.telegramNotificationOptions, + emptySet() + ) + } + + def emailNotificationOptionsBase = baseObject.emailNotificationOptions + def emailNotificationOptionsToMerge = objectToMerge.emailNotificationOptions + + if (emailNotificationOptionsToMerge != null) { + mergeEmailExtConfiguration( + emailNotificationOptionsBase.successEmailOptions, + emailNotificationOptionsToMerge.successEmailOptions + ) + mergeEmailExtConfiguration( + emailNotificationOptionsBase.failureEmailOptions, + emailNotificationOptionsToMerge.failureEmailOptions + ) + mergeEmailExtConfiguration( + emailNotificationOptionsBase.unstableEmailOptions, + emailNotificationOptionsToMerge.unstableEmailOptions + ) + mergeEmailExtConfiguration( + emailNotificationOptionsBase.alwaysEmailOptions, + emailNotificationOptionsToMerge.alwaysEmailOptions + ) + } + } + + @NonCPS + private static void mergeEmailExtConfiguration(EmailExtConfiguration baseObject, EmailExtConfiguration objectToMerge) { + if (objectToMerge != null && objectToMerge.recipientProviders != null) { + baseObject.recipientProviders = objectToMerge.recipientProviders.clone() + } + + if (objectToMerge != null && objectToMerge.directRecipients != null) { + baseObject.directRecipients = objectToMerge.directRecipients.clone() + } + } } diff --git a/src/ru/pulsar/jenkins/library/configuration/JobConfiguration.groovy b/src/ru/pulsar/jenkins/library/configuration/JobConfiguration.groovy index 066e672..75932fd 100644 --- a/src/ru/pulsar/jenkins/library/configuration/JobConfiguration.groovy +++ b/src/ru/pulsar/jenkins/library/configuration/JobConfiguration.groovy @@ -59,6 +59,10 @@ class JobConfiguration implements Serializable { @JsonPropertyDescription("Настройки трансформации результатов анализа") ResultsTransformOptions resultsTransformOptions; + @JsonProperty("notifications") + @JsonPropertyDescription("Настройки рассылки результатов сборки") + NotificationsOptions notificationsOptions; + @JsonProperty("logosConfig") @JsonPropertyDescription("Конфигурация библиотеки logos. Применяется перед запуском каждой стадии сборки") String logosConfig; @@ -81,6 +85,7 @@ class JobConfiguration implements Serializable { ", syntaxCheckOptions=" + syntaxCheckOptions + ", smokeTestOptions=" + smokeTestOptions + ", resultsTransformOptions=" + resultsTransformOptions + + ", notificationOptions=" + notificationsOptions + ", logosConfig='" + logosConfig + '\'' + '}'; } diff --git a/src/ru/pulsar/jenkins/library/configuration/NotificationsOptions.groovy b/src/ru/pulsar/jenkins/library/configuration/NotificationsOptions.groovy new file mode 100644 index 0000000..5b94294 --- /dev/null +++ b/src/ru/pulsar/jenkins/library/configuration/NotificationsOptions.groovy @@ -0,0 +1,31 @@ +package ru.pulsar.jenkins.library.configuration + +import com.cloudbees.groovy.cps.NonCPS +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonPropertyDescription +import ru.pulsar.jenkins.library.configuration.notification.EmailNotificationOptions +import ru.pulsar.jenkins.library.configuration.notification.TelegramNotificationOptions + +@JsonIgnoreProperties(ignoreUnknown = true) +class NotificationsOptions implements Serializable { + + @JsonProperty("email") + @JsonPropertyDescription("Настройки рассылки результатов сборки через email") + EmailNotificationOptions emailNotificationOptions; + + @JsonProperty("telegram") + @JsonPropertyDescription("Настройки рассылки результатов сборки через telegram") + TelegramNotificationOptions telegramNotificationOptions; + + @Override + @NonCPS + String toString() { + return "NotificationOptions{" + + "emailNotificationOptions=" + emailNotificationOptions + + ", telegramNotificationOptions=" + telegramNotificationOptions + + '}'; + } +} + + diff --git a/src/ru/pulsar/jenkins/library/configuration/Secrets.groovy b/src/ru/pulsar/jenkins/library/configuration/Secrets.groovy index 51941df..b3b9340 100644 --- a/src/ru/pulsar/jenkins/library/configuration/Secrets.groovy +++ b/src/ru/pulsar/jenkins/library/configuration/Secrets.groovy @@ -15,12 +15,20 @@ class Secrets implements Serializable { @JsonPropertyDescription("Данные авторизации в хранилище конфигурации") String storage + @JsonPropertyDescription("Идентификатор telegram-чата для отправки уведомлений") + String telegramChatId + + @JsonPropertyDescription("Токен авторизации telegram-бота для отправки уведомлений") + String telegramBotToken + @Override @NonCPS String toString() { return "Secrets{" + "storagePath='" + storagePath + '\'' + ", storage='" + storage + '\'' + + ", telegramChatId='" + telegramChatId + '\'' + + ", telegramBotToken='" + telegramBotToken + '\'' + '}'; } } diff --git a/src/ru/pulsar/jenkins/library/configuration/StageFlags.groovy b/src/ru/pulsar/jenkins/library/configuration/StageFlags.groovy index f52cb76..28f23ad 100644 --- a/src/ru/pulsar/jenkins/library/configuration/StageFlags.groovy +++ b/src/ru/pulsar/jenkins/library/configuration/StageFlags.groovy @@ -24,6 +24,12 @@ class StageFlags implements Serializable { @JsonPropertyDescription("Запуск BDD сценариев включен") Boolean bdd + @JsonPropertyDescription("Выполнять рассылку результатов сборки на email") + Boolean email + + @JsonPropertyDescription("Выполнять рассылку результатов сборки в telegram") + Boolean telegram + @Override @NonCPS String toString() { @@ -34,6 +40,8 @@ class StageFlags implements Serializable { ", smoke=" + smoke + ", initSteps=" + initSteps + ", bdd=" + bdd + + ", email=" + email + + ", telegram=" + telegram + '}'; } diff --git a/src/ru/pulsar/jenkins/library/configuration/notification/EmailNotificationOptions.groovy b/src/ru/pulsar/jenkins/library/configuration/notification/EmailNotificationOptions.groovy new file mode 100644 index 0000000..305987b --- /dev/null +++ b/src/ru/pulsar/jenkins/library/configuration/notification/EmailNotificationOptions.groovy @@ -0,0 +1,46 @@ +package ru.pulsar.jenkins.library.configuration.notification + +import com.cloudbees.groovy.cps.NonCPS +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonPropertyDescription +import ru.pulsar.jenkins.library.configuration.notification.email.EmailExtConfiguration + +@JsonIgnoreProperties(ignoreUnknown = true) +class EmailNotificationOptions implements Serializable { + + @JsonPropertyDescription("Отправлять всегда") + Boolean onAlways + @JsonPropertyDescription("Отправлять при успешной сборке") + Boolean onSuccess + @JsonPropertyDescription("Отправлять при падении сборки") + Boolean onFailure + @JsonPropertyDescription("Отправлять при нестабильной сборке") + Boolean onUnstable + + @JsonProperty("alwaysOptions") + EmailExtConfiguration alwaysEmailOptions + @JsonProperty("successOptions") + EmailExtConfiguration successEmailOptions + @JsonProperty("failureOptions") + EmailExtConfiguration failureEmailOptions + @JsonProperty("unstableOptions") + EmailExtConfiguration unstableEmailOptions + + @Override + @NonCPS + String toString() { + return "EmailNotificationOptions{" + + "onAlways=" + onAlways + + ", onSuccess=" + onSuccess + + ", onFailure=" + onFailure + + ", onUnstable=" + onUnstable + + ", alwaysEmailOptions=" + alwaysEmailOptions + + ", successEmailOptions=" + successEmailOptions + + ", failureEmailOptions=" + failureEmailOptions + + ", unstableEmailOptions=" + unstableEmailOptions + + '}'; + } +} + + diff --git a/src/ru/pulsar/jenkins/library/configuration/notification/TelegramNotificationOptions.groovy b/src/ru/pulsar/jenkins/library/configuration/notification/TelegramNotificationOptions.groovy new file mode 100644 index 0000000..0ae6ad6 --- /dev/null +++ b/src/ru/pulsar/jenkins/library/configuration/notification/TelegramNotificationOptions.groovy @@ -0,0 +1,31 @@ +package ru.pulsar.jenkins.library.configuration.notification + +import com.cloudbees.groovy.cps.NonCPS +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonPropertyDescription + +@JsonIgnoreProperties(ignoreUnknown = true) +class TelegramNotificationOptions implements Serializable { + + @JsonPropertyDescription("Отправлять всегда") + Boolean onAlways + @JsonPropertyDescription("Отправлять при успешной сборке") + Boolean onSuccess + @JsonPropertyDescription("Отправлять при падении сборки") + Boolean onFailure + @JsonPropertyDescription("Отправлять при нестабильной сборке") + Boolean onUnstable + + @Override + @NonCPS + String toString() { + return "TelegramNotificationOptions{" + + "onAlways=" + onAlways + + ", onSuccess=" + onSuccess + + ", onFailure=" + onFailure + + ", onUnstable=" + onUnstable + + '}'; + } +} + + diff --git a/src/ru/pulsar/jenkins/library/configuration/notification/email/EmailExtConfiguration.groovy b/src/ru/pulsar/jenkins/library/configuration/notification/email/EmailExtConfiguration.groovy new file mode 100644 index 0000000..4f23c37 --- /dev/null +++ b/src/ru/pulsar/jenkins/library/configuration/notification/email/EmailExtConfiguration.groovy @@ -0,0 +1,21 @@ +package ru.pulsar.jenkins.library.configuration.notification.email + +import com.cloudbees.groovy.cps.NonCPS +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +class EmailExtConfiguration implements Serializable { + Boolean attachLog; + String[] directRecipients + RecipientProvider[] recipientProviders + + @Override + @NonCPS + String toString() { + return "EmailExtConfiguration{" + + "attachLog=" + attachLog + + ", directRecipients=" + directRecipients + + ", recipientProviders=" + recipientProviders + + '}'; + } +} diff --git a/src/ru/pulsar/jenkins/library/configuration/notification/email/RecipientProvider.groovy b/src/ru/pulsar/jenkins/library/configuration/notification/email/RecipientProvider.groovy new file mode 100644 index 0000000..bca8b5e --- /dev/null +++ b/src/ru/pulsar/jenkins/library/configuration/notification/email/RecipientProvider.groovy @@ -0,0 +1,15 @@ +package ru.pulsar.jenkins.library.configuration.notification.email + +import com.fasterxml.jackson.annotation.JsonProperty + +enum RecipientProvider { + + @JsonProperty("developers") + DEVELOPERS, + @JsonProperty("requestor") + REQUESTOR, + @JsonProperty("brokenBuildSuspects") + BROKEN_BUILD_SUSPECTS, + @JsonProperty("brokenTestsSuspects") + BROKEN_TESTS_SUSPECTS +} \ No newline at end of file diff --git a/src/ru/pulsar/jenkins/library/steps/EmailNotification.groovy b/src/ru/pulsar/jenkins/library/steps/EmailNotification.groovy new file mode 100644 index 0000000..cb0838c --- /dev/null +++ b/src/ru/pulsar/jenkins/library/steps/EmailNotification.groovy @@ -0,0 +1,95 @@ +package ru.pulsar.jenkins.library.steps + +import hudson.model.Result +import ru.pulsar.jenkins.library.IStepExecutor +import ru.pulsar.jenkins.library.configuration.JobConfiguration +import ru.pulsar.jenkins.library.configuration.notification.email.EmailExtConfiguration +import ru.pulsar.jenkins.library.configuration.notification.email.RecipientProvider +import ru.pulsar.jenkins.library.ioc.ContextRegistry +import ru.pulsar.jenkins.library.utils.Logger +import ru.pulsar.jenkins.library.utils.StringJoiner + +class EmailNotification implements Serializable { + + private final JobConfiguration config; + + EmailNotification(JobConfiguration config) { + this.config = config + } + + def run() { + + Logger.printLocation() + + if (!config.stageFlags.email) { + Logger.println("Email notifications are disabled") + return + } + + IStepExecutor steps = ContextRegistry.getContext().getStepExecutor() + + def options = config.notificationsOptions.emailNotificationOptions + + def currentBuild = steps.currentBuild() + def currentResult = Result.fromString(currentBuild.getCurrentResult()) + + EmailExtConfiguration configuration = null; + if (options.onAlways) { + configuration = options.alwaysEmailOptions + } else if (options.onFailure && (currentResult == Result.FAILURE || currentResult == Result.ABORTED)) { + configuration = options.failureEmailOptions + } else if (options.onUnstable && currentResult == Result.UNSTABLE) { + configuration = options.unstableEmailOptions + } else if (options.onSuccess && currentResult == Result.SUCCESS) { + configuration = options.successEmailOptions + } + + sendEmail(configuration) + + } + + private static void sendEmail(EmailExtConfiguration configuration) { + + if (configuration == null) { + Logger.println("Unknown build result. Can't send an email!") + return + } + + IStepExecutor steps = ContextRegistry.getContext().getStepExecutor() + + String subject = '$DEFAULT_SUBJECT' + String body = '$DEFAULT_CONTENT' + + StringJoiner toJoiner = new StringJoiner(",") + configuration.directRecipients.each { + toJoiner.add(it) + } + String to = toJoiner.toString() + + List recipientProviders = new ArrayList(); + configuration.recipientProviders.each { + switch (it) { + case RecipientProvider.BROKEN_BUILD_SUSPECTS: + recipientProviders.add(steps.brokenBuildSuspects()) + break + case RecipientProvider.BROKEN_TESTS_SUSPECTS: + recipientProviders.add(steps.brokenTestsSuspects()) + break + case RecipientProvider.DEVELOPERS: + recipientProviders.add(steps.developers()) + break + case RecipientProvider.REQUESTOR: + recipientProviders.add(steps.requestor()) + break + } + } + + steps.emailext( + subject, + body, + to, + recipientProviders, + configuration.attachLog + ) + } +} diff --git a/src/ru/pulsar/jenkins/library/steps/PublishAllure.groovy b/src/ru/pulsar/jenkins/library/steps/PublishAllure.groovy index d504cbc..3e374e6 100644 --- a/src/ru/pulsar/jenkins/library/steps/PublishAllure.groovy +++ b/src/ru/pulsar/jenkins/library/steps/PublishAllure.groovy @@ -17,10 +17,16 @@ class PublishAllure implements Serializable { } def run() { - steps = ContextRegistry.getContext().getStepExecutor() Logger.printLocation() + if (config == null) { + Logger.println("jobConfiguration is not initialized") + return + } + + steps = ContextRegistry.getContext().getStepExecutor() + safeUnstash('init-allure') safeUnstash('bdd-allure') if (config.stageFlags.smoke && config.smokeTestOptions.publishToAllureReport) { diff --git a/src/ru/pulsar/jenkins/library/steps/SendNotifications.groovy b/src/ru/pulsar/jenkins/library/steps/SendNotifications.groovy new file mode 100644 index 0000000..603387c --- /dev/null +++ b/src/ru/pulsar/jenkins/library/steps/SendNotifications.groovy @@ -0,0 +1,30 @@ +package ru.pulsar.jenkins.library.steps + +import ru.pulsar.jenkins.library.configuration.JobConfiguration +import ru.pulsar.jenkins.library.utils.Logger + +class SendNotifications implements Serializable { + + private final JobConfiguration config; + + SendNotifications(JobConfiguration config) { + this.config = config + } + + def run() { + + Logger.printLocation() + + if (config == null) { + Logger.println("jobConfiguration is not initialized") + return + } + + def emailNotification = new EmailNotification(config); + emailNotification.run() + + def telegramNotification = new TelegramNotification(config); + telegramNotification.run(); + + } +} diff --git a/src/ru/pulsar/jenkins/library/steps/TelegramNotification.groovy b/src/ru/pulsar/jenkins/library/steps/TelegramNotification.groovy new file mode 100644 index 0000000..ecd85a9 --- /dev/null +++ b/src/ru/pulsar/jenkins/library/steps/TelegramNotification.groovy @@ -0,0 +1,251 @@ +package ru.pulsar.jenkins.library.steps + +import com.cloudbees.groovy.cps.NonCPS +import com.fasterxml.jackson.databind.ObjectMapper +import hudson.model.Result +import hudson.scm.ChangeLogSet +import io.jenkins.blueocean.rest.impl.pipeline.FlowNodeWrapper +import io.jenkins.blueocean.rest.impl.pipeline.PipelineNodeGraphVisitor +import io.jenkins.blueocean.rest.model.BlueRun +import io.jenkins.cli.shaded.org.apache.commons.lang.time.DurationFormatUtils +import jenkins.plugins.http_request.HttpMode +import jenkins.plugins.http_request.MimeType +import org.jenkinsci.plugins.workflow.actions.TimingAction +import org.jenkinsci.plugins.workflow.graph.BlockStartNode +import org.jenkinsci.plugins.workflow.job.WorkflowRun +import org.jenkinsci.plugins.workflow.support.steps.build.RunWrapper +import ru.pulsar.jenkins.library.IStepExecutor +import ru.pulsar.jenkins.library.configuration.JobConfiguration +import ru.pulsar.jenkins.library.configuration.Secrets +import ru.pulsar.jenkins.library.ioc.ContextRegistry +import ru.pulsar.jenkins.library.utils.Logger +import ru.pulsar.jenkins.library.utils.RepoUtils +import ru.pulsar.jenkins.library.utils.StringJoiner + +import static ru.pulsar.jenkins.library.configuration.Secrets.UNKNOWN_ID + +class TelegramNotification implements Serializable { + + private final JobConfiguration config; + + TelegramNotification(JobConfiguration config) { + this.config = config + } + + def run() { + + Logger.printLocation() + + if (!config.stageFlags.telegram) { + Logger.println("Telegram notifications are disabled") + return + } + + IStepExecutor steps = ContextRegistry.getContext().getStepExecutor() + + def options = config.notificationsOptions.telegramNotificationOptions + + def currentBuild = steps.currentBuild() + def currentResult = Result.fromString(currentBuild.getCurrentResult()) + + String message = getMessage(currentBuild) + + if (options.onAlways) { + sendMessage(message) + } else if (options.onFailure && (currentResult == Result.FAILURE || currentResult == Result.ABORTED)) { + sendMessage(message) + } else if (options.onUnstable && currentResult == Result.UNSTABLE) { + sendMessage(message) + } else if (options.onSuccess && currentResult == Result.SUCCESS) { + sendMessage(message) + } else { + Logger.println("Unknown build result! Can't send a message to telegram") + } + + } + + private void sendMessage(message) { + IStepExecutor steps = ContextRegistry.getContext().getStepExecutor() + def env = steps.env(); + + String repoSlug = RepoUtils.getRepoSlug() + + Secrets secrets = config.secrets + + String telegramChatIdCredentials = secrets.telegramChatId == UNKNOWN_ID ? repoSlug + "_TELEGRAM_CHAT_ID" : secrets.telegramChatId + String telegramBotTokenCredentials = secrets.telegramBotToken == UNKNOWN_ID ? "TELEGRAM_BOT_TOKEN" : secrets.telegramBotToken + + steps.withCredentials([ + steps.string(telegramBotTokenCredentials, 'TOKEN'), + steps.string(telegramChatIdCredentials, 'CHAT_ID') + ]) { + + def mapper = new ObjectMapper() + + def body = [ + chat_id : env.CHAT_ID, + text : message, + disable_web_page_preview: true, + parse_mode : 'MarkdownV2' + ] + + def bodyString = mapper.writeValueAsString(body) + String url = "https://api.telegram.org/bot${env.TOKEN}/sendMessage" + + steps.echo(message) + steps.echo(bodyString) + + steps.httpRequest( + url, + HttpMode.POST, + MimeType.APPLICATION_JSON_UTF8, + bodyString, + '200', + true + ) + } + } + + private static String getMessage(RunWrapper currentBuild) { + + IStepExecutor steps = ContextRegistry.getContext().getStepExecutor() + def env = steps.env(); + + def currentResult = Result.fromString(currentBuild.getCurrentResult()) + + def messageJoiner = new StringJoiner('\n\n') + + def displayName = escapeStringForMarkdownV2(currentBuild.fullDisplayName) + String header = "[$displayName]($env.BUILD_URL)" + messageJoiner.add(header) + + String result = "" + if (currentResult == Result.SUCCESS) { + result = "✅ Сборка прошла успешно!" + } else if (currentResult == Result.FAILURE) { + result = "❌ Сборка завершилась с ошибкой!" + } else if (currentResult == Result.ABORTED) { + result = "🛑 Сборка прервана!" + } else if (currentResult == Result.UNSTABLE) { + result = "💩 Есть упавшие тесты!" + } + + result = escapeStringForMarkdownV2(result) + messageJoiner.add(result) + + String stageResults = getStageResultsMessage(currentBuild) + if (stageResults.length() > 0) { + stageResults = escapeStringForMarkdownV2(stageResults) + messageJoiner.add(stageResults) + } + + def duration = "Длительность сборки: ${currentBuild.getDurationString()}".replace(" and counting", "") + duration = escapeStringForMarkdownV2(duration) + messageJoiner.add(duration) + + def changeSet = getChangeSet(currentBuild) + steps.echo(changeSet) + if (changeSet.length() > 0) { + changeSet = 'Изменения с последней сборки:\n\n' + changeSet + messageJoiner.add(changeSet) + } + + String buildUrl = "[Лог сборки](${env.BUILD_URL}console)" + messageJoiner.add(buildUrl) + + steps.echo(messageJoiner.toString()) + + return messageJoiner.toString() + } + + @NonCPS + private static String getChangeSet(RunWrapper currentBuild) { + String changeSetText = '' + + int counter = 0 + currentBuild.changeSets.each { changeSet -> + changeSetText += "Набор изменений \\#${++counter}:\n" + changeSet.items.each { ChangeLogSet.Entry entry -> + String commit = '' + def commitId = entry.commitId; + if (commitId != null) { + if (isValidSHA1(commitId)) { + commitId = commitId.substring(0, 7) + } + + def link = changeSet.browser?.getChangeSetLink(entry) + if (link != null) { + commit = "[$commitId]($link)" + } else { + commit = commitId + } + } + + def author = escapeStringForMarkdownV2(entry.author.displayName) + def authorLink = entry.author.absoluteUrl + + def message = escapeStringForMarkdownV2(entry.getMsgAnnotated()) + changeSetText += "\\* $commit $message \\([$author]($authorLink)\\)\n" + } + changeSetText += '\n' + } + return changeSetText.trim() + } + + @NonCPS + private static String getStageResultsMessage(RunWrapper currentBuild) { + def visitor = new PipelineNodeGraphVisitor(currentBuild.rawBuild as WorkflowRun) + def stages = visitor.pipelineNodes.findAll { it.type != FlowNodeWrapper.NodeType.STEP } + + def stageResultMessage = "" + for (FlowNodeWrapper stage in stages) { + if (stage.status.result == BlueRun.BlueRunResult.SUCCESS || stage.status.result == BlueRun.BlueRunResult.NOT_BUILT) { + continue + } + + + long duration + def endNode = stage.node.getExecution().getEndNode(stage.node as BlockStartNode) + if (endNode != null) { + def startTime = TimingAction.getStartTime(stage.node) + def endTime = TimingAction.getStartTime(endNode) + + duration = endTime - startTime + } else { + duration = stage.timing.totalDurationMillis + } + + def time = DurationFormatUtils.formatDuration(duration, "H:mm:ss") + stageResultMessage += "$stage.displayName: $stage.status.result, затрачено времени $time \n" + } + + return stageResultMessage.trim() + } + + @NonCPS + private static String escapeStringForMarkdownV2(String incoming) { + return incoming.replace('_', '\\-') + .replace('*', '\\*') + .replace('[', '\\[') + .replace(']', '\\]') + .replace('(', '\\(') + .replace(')', '\\)') + .replace('~', '\\~') + .replace('`', '\\`') + .replace('>', '\\>') + .replace('#', '\\#') + .replace('+', '\\+') + .replace('-', '\\-') + .replace('=', '\\=') + .replace('|', '\\|') + .replace('{', '\\{') + .replace('}', '\\}') + .replace('.', '\\.') + .replace('!', '\\!') + } + + @NonCPS + private static boolean isValidSHA1(String s) { + return s.matches('^[a-fA-F0-9]{40}$'); + } +} diff --git a/test/unit/groovy/ru/pulsar/jenkins/library/configuration/ConfigurationReaderTest.java b/test/unit/groovy/ru/pulsar/jenkins/library/configuration/ConfigurationReaderTest.java index 6434e26..35fd821 100644 --- a/test/unit/groovy/ru/pulsar/jenkins/library/configuration/ConfigurationReaderTest.java +++ b/test/unit/groovy/ru/pulsar/jenkins/library/configuration/ConfigurationReaderTest.java @@ -63,6 +63,18 @@ class ConfigurationReaderTest { assertThat(jobConfiguration.getTimeoutOptions().getBdd()).isEqualTo(120); assertThat(jobConfiguration.getTimeoutOptions().getZipInfoBase()).isEqualTo(123); + + assertThat(jobConfiguration.getNotificationsOptions().getEmailNotificationOptions().getOnAlways()).isTrue(); + assertThat(jobConfiguration.getNotificationsOptions().getEmailNotificationOptions().getOnSuccess()).isFalse(); + assertThat(jobConfiguration.getNotificationsOptions().getEmailNotificationOptions().getAlwaysEmailOptions().getAttachLog()).isTrue(); + assertThat(jobConfiguration.getNotificationsOptions().getEmailNotificationOptions().getAlwaysEmailOptions().getRecipientProviders()).hasSize(2); + assertThat(jobConfiguration.getNotificationsOptions().getEmailNotificationOptions().getAlwaysEmailOptions().getDirectRecipients()).hasSize(2); + + assertThat(jobConfiguration.getNotificationsOptions().getEmailNotificationOptions().getFailureEmailOptions().getDirectRecipients()).isEmpty(); + assertThat(jobConfiguration.getNotificationsOptions().getEmailNotificationOptions().getFailureEmailOptions().getRecipientProviders()).hasSize(1); + + assertThat(jobConfiguration.getNotificationsOptions().getTelegramNotificationOptions().getOnAlways()).isFalse(); + assertThat(jobConfiguration.getNotificationsOptions().getTelegramNotificationOptions().getOnFailure()).isTrue(); } @Test diff --git a/test/unit/resources/jobConfiguration.json b/test/unit/resources/jobConfiguration.json index ba6c526..fbf6f88 100644 --- a/test/unit/resources/jobConfiguration.json +++ b/test/unit/resources/jobConfiguration.json @@ -31,5 +31,26 @@ "publishToAllureReport": false, "publishToJUnitReport": true }, + "notifications": { + "email": { + "onAlways": true, + "alwaysOptions": { + "attachLog": true, + "directRecipients": [ + "1@1.com", + "2@1.com" + ] + }, + "failureOptions": { + "recipientProviders": [ + "developers" + ] + } + }, + "telegram": { + "onAlways": false, + "onFailure": true + } + }, "logosConfig": "logger.rootLogger=DEBUG" } \ No newline at end of file diff --git a/vars/pipeline1C.groovy b/vars/pipeline1C.groovy index 1ed0f64..4fc87d2 100644 --- a/vars/pipeline1C.groovy +++ b/vars/pipeline1C.groovy @@ -238,6 +238,7 @@ void call() { always { node('agent') { saveResults config + sendNotifications(config) } } } diff --git a/vars/sendNotifications.groovy b/vars/sendNotifications.groovy new file mode 100644 index 0000000..cd0afea --- /dev/null +++ b/vars/sendNotifications.groovy @@ -0,0 +1,10 @@ +import ru.pulsar.jenkins.library.configuration.JobConfiguration +import ru.pulsar.jenkins.library.ioc.ContextRegistry +import ru.pulsar.jenkins.library.steps.SendNotifications + +def call(JobConfiguration config) { + ContextRegistry.registerDefaultContext(this) + + def sendNotifications = new SendNotifications(config) + sendNotifications.run() +} \ No newline at end of file