Когда Jira обрастает кастомной логикой, автоматизациями и интеграциями, рано или поздно возникает потребность в отслеживании действий, которые произвели (или не произвели) с задачей все эти роботы.
Если вам периодически приходят баги о неработающей автоматизации и вы начинаете смотреть логи scriptrunner, automation и прочих JWME – этот момент настал.
Если заказчик просит фиксировать факт отправки сообщения во внешнюю систему в комментарии к задаче – этот момент точно настал.
Если вы уже и сами начали создавать комментарии из автоматизаций и groovy-скриптов – момент настал совершенно абсолютно точно.
Этот туториал будет полезен начинающим разработчикам в стеке Atlassian и администраторам Jira, пробующим себя в разработке плагинов.
Привет, Хабр! Меня зовут Игнат. В этом туториале я покажу, как написать плагин, закрывающий боль из тизера. Идея для этого функционала родилась в процессе обслуживания и доработки слабо документированного инстанса, логика в котором писалась с использованием разного стека (legacy Automation, Project Automation от Codebarrels, JMWE, Scriptrunner).
Сначала я начал выносить действия автоматизаций в комментарии задачи – это оказалось полезным для дебага, но засоряло комментарии и смущало пользователей. Нужно было реализовать это так, чтобы не засорять комментарии, но информация о совершенных с задачей автоматизациях была доступна широкому кругу пользователей в самой задаче. Исходный код примера доступен в репозитории.
Pre-requirements
Нам понадобятся:
базовые знания Java;
Atlassian SDK и JDK 11, установленные на рабочей станции.
Требования к результату
На форме задачи в Jira есть дополнительная вкладка LogMessages, куда можно логировать действия кастомных автоматизаций, интеграций и прочих роботов, как через Java API, так и через REST API.
Для того, чтобы предоставить возможность добавлять записи в эту вкладку, плагин предоставляет:
Два метода Java API для вызова как из groovy-скриптов, так и из других плагинов. Оверлод нужен, поскольку в некоторых сценариях удобно вызывать метод, передавая не саму задачу, а ее ключ.
boolean writeLogMessageToIssueHistory(Issue issue, String message);
boolean writeLogMessageToIssueHistory(String issueKey, String message);
REST-эндпоинт для внешних клиентов, который будет принимать в теле POST-запроса два параметра: ключ задачи и сообщение, которое нужно добавить.
{
"issueKey": "TEST-1",
"message": "информирование успешно разослано пользователям @ivanov, @petrov, @sidorov"
}
Основные шаги, которые нужно будет предпринять для реализации этого функционала:
подготовить skeleton-плагин с помощью Atlassian SDK;
реализовать репозиторий для хранения сообщений и реализации доступа к ним;
реализовать новую вкладку на окне задачи стандартным модулем Jira плагина;
объявить и экспортировать в хост-приложение Java-API;
добавить REST-контроллер для реализации REST-API.
Создаем заготовку плагина
Как и в предыдущей статье, командой atlas-create-jira-plugin
, выполняемой из папки, где будет располагаться проект, создаем заготовку плагина. Имена проекта и package задаем как:
Define value for groupId: : ru.samokat.atlassian.jira.tutorials
Define value for artifactId: : issue-history-writer-tutorial
Define value for package: ru.samokat.atlassian.jira.tutorials: : ru.samokat.atlassian.jira.tutorials.historywriter
Остальное прокликиваем по умолчанию.
SDK создал для нас заготовку проекта, с которой дальше работаем в IDE. Созданный плагин я сразу закоммитил в репозиторий, чтобы было возможно посмотреть все изменения в проекте, начиная с его генерации.
Сначала правим pom.xml
, устанавливая там актуальные названиe и сайт организации, а так же в разделе <properties>
задаем версию Jira и версию Java:
<jira.version>8.22.0</jira.version>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
Затем командой atlas-run из директории проекта запускаем приложение, убеждаясь, что билд проходит без ошибок, а по адресу http://localhost:2990/jira/ доступно веб-приложение. В моём случае этого не произошло, и для того, чтобы приложение запустилось с заданной версией Jira (8.22.0), я понизил версию jira-maven-plugin до 8.1.2, поменяв соответствующую property в pom.xml.
<properties>
...
<amps.version>8.1.2</amps.version>
...
После старта приложения создаем сэмпл-проект, где будет жить тестовая задача. Поскольку плагин предоставляет API, которое предполагается использовать из groovy-скриптов ScriptRunner, то устанавливаем и этот плагин.
При написании кода для уменьшения бойлерплейта я использую lombok, зависимости которого тоже нужно добавить:
<!-- lombok dependencies -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.5</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
<version>2.0.5</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${org.projectlombok.version}</version>
<scope>provided</scope>
</dependency>
...
<properties>
<org.projectlombok.version>1.18.30</org.projectlombok.version>
...
Для управления уровнями логирования в процессе разработки в раздел <configuration>
добавляем параметр - ссылку на конфигурационный файл логгеров - log4j.properties
:
<build>
<plugins>
<plugin>
<groupId>com.atlassian.maven.plugins</groupId>
<artifactId>jira-maven-plugin</artifactId>
...
<configuration>
<!-- properties file to set up loggers defined by @Slf4j lombok annotation -->
<log4jProperties>src/main/resources/log4j.properties</log4jProperties>
...
Сам файл log4j.properties
размещаем в папке src/main/resources
проекта. Внутри настраиваем и включаем логирование для наших пакетов.
log4j.rootLogger=WARN, STDOUT
log4j.appender.STDOUT=org.apache.log4j.ConsoleAppender
log4j.appender.STDOUT.layout=org.apache.log4j.PatternLayout
log4j.appender.STDOUT.layout.ConversionPattern=%-5p [%c{1}] : %m%n
log4j.appender.file=org.apache.log4j.RollingFileAppender
log4j.appender.file.File=historywriter.log
log4j.appender.file.layout=org.apache.log4j.PatternLayout
log4j.appender.file.layout.ConversionPattern=%-5p [%c{1}] : %m%n
log4j.logger.ru.samokat.atlassian.jira.tutorials.historywriter = TRACE, STDOUT, file
log4j.additivity.ru.samokat.atlassian.jira.tutorials.historywriter = false
Код туториала на 100% покрыт юнит-тестами. Для контроля покрытия я использовал Maven-плагин jacoco, добавив в раздел plugins pom.xml соответствующий блок:
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check-minimal</id>
<phase>package</phase>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>1.0</minimum> <!-- вот тут -->
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>1.0</minimum> <!-- и тут -->
</limit>
<limit>
<counter>CLASS</counter>
<value>MISSEDCOUNT</value>
<maximum>0</maximum> <!-- и тут -->
</limit>
<limit>
<counter>METHOD</counter>
<value>MISSEDCOUNT</value>
<maximum>0</maximum> <!-- и тут -->
</limit>
<limit>
<counter>LINE</counter>
<value>MISSEDCOUNT</value>
<maximum>0</maximum> <!-- и тут -->
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
...
Также добавляем нужные при написании тестов зависимости Junit и Mockito в раздел dependency
:
<!-- Mockito for unit testing -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>4.11.0</version>
<scope>test</scope>
</dependency>
<!-- Mockito JUnit Jupiter for integration with JUnit 5 -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>4.11.0</version>
<scope>test</scope>
</dependency>
<!-- JUnit 5 for testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.9.3</version>
<scope>test</scope>
</dependency>
Написание тест-кейсов не тема этого туториала – давайте договоримся, что сами тестовые классы я приводить в статье не буду. Их можно посмотреть в репозитории. Пишите в комментариях, если возникнут вопросы на их счёт.
Реализуем репозиторий сообщений лога
Для того, чтобы отображать на экране задачи сообщения лога, нужно реализовать их хранение в БД. Для этого будем использовать Atlassian ActiveObjects.
ActiveObjects или AO – механизм, который Atlassian SDK предоставляет разработчикам для реализации хранения данных. Подробнее о нем можно почитать в документации вендора. Если вкратце – объявляем интерфейс с нужными нам полями, фреймворк создает в БД хост-приложения соответствующую таблицу и DAO-класс, имплементирующий объявленный нами интерфейс для доступа к данным таблицы. Перед этим, нужно добавить в pom.xml соответствующую зависимость:
<!-- active objects dependency -->
<dependency>
<groupId>com.atlassian.activeobjects</groupId>
<artifactId>activeobjects-plugin</artifactId>
<version>3.5.1</version>
<scope>provided</scope>
</dependency>
Итак, начинаем с объявления интерфейса. У сущностей "запись лога", которые мы хотим отображать в создаваемой вкладке, нам понадобится всего три поля:
время события -
time
;его текстовое описание -
text
;индентификатор задачи, к которой относится запись -
issueId
.
Для корректной реализации интерфейс должен расширять класс net.java.ao.Entity
:
@Table("log_messages_tab")
public interface LogMessageEntry extends Entity {
Long getIssueId();
void setIssueId(Long id);
@StringLength(StringLength.UNLIMITED)
String getText();
@StringLength(StringLength.UNLIMITED)
void setText(String text);
Timestamp getTime();
void setTime(Timestamp time);
}
Аннотация на классе указывает имя таблицы в бд Jira, которую фреймворк создаст. К имени таблицы будет добавлен префикс - хэш ключа плагина. в нашем случае AO_423EA4
.
Для того чтобы модуль AO заработал, помимо объявления интерфейса и добавления зависимости, добаляем соответствующий блок в дескриптор плагина src/main/resources/atlassian-plugin.xml
:
<ao key="${atlassian.plugin.key}-ao-module">
<description>The AO module for storing issue log messages at db.</description>
<entity>ru.samokat.atlassian.jira.tutorials.historywriter.entity.LogMessageEntry</entity>
</ao>
Пробуем билдить проект и, конечно же, билд начинает падать из-за отсутствия покрытия тестами. Пока что просто удаляеам созданные Atlassian SDK демонстрационные юнит-тесты, как и пакет impl
с демонстрационным классом. При этом из atlassian-plugin.xml
в папке test
проекта нужно не забыть удалить импорт этого компонента.
После добавления классов юнит-тестов билдим проект еще раз и наблюдаем, что таблица AO_423EA4_LOG_MESSAGES_TAB
появилась в БД. Посмотреть это можно в консоли H2 БД по адресу:
http://localhost:2990/jira/plugins/servlet/database-console/login.do
Для того, чтобы создавать и читать записи из таблицы, создаем класс LogMessageRepository
c двумя методами – получение записей для issue с определенным id
, и создание новой записи также для issue с определенным id
.
@Named
public class LogMessageRepository {
private final ActiveObjects activeObjects;
public LogMessageRepository(@ComponentImport ActiveObjects activeObjects) {
this.activeObjects = activeObjects;
}
public List<LogMessageEntry> getLogMessageEntries(Issue issue) {
return Arrays.asList(activeObjects.find(LogMessageEntry.class,
Query.select().where("ISSUE_ID = ?", issue.getId()).order("ID")));
}
public void createLogMessage(Issue issue, String message) {
LogMessageEntry logMessage = activeObjects.create(LogMessageEntry.class);
logMessage.setIssueId(issue.getId());
logMessage.setText(message);
logMessage.setTime(new Timestamp(System.currentTimeMillis()));
logMessage.save();
}
}
Аннотация@Namedна классе указывает на то, что класс явлется бином Spring, а аннтотация @ComponentImportв конструкторе нужна для получения бина из хост-приложения.
Добавляем тесты, билдим, убеждаемся, что билд проходит без проблем.
С помощью стандартного модуля Jira плагина реализуем вкладку на экране задачи
Для того, чтобы на экране задачи появилась новая вкладка, необходимо реализовать:
модуль вкладки в дескрипторе плагина;
шаблон Apache Velocity, который будет отвечать за ее отображение;
классы для управления шаблоном (для передачи в него параметров - сообщений лога и таймстампов).
Дескриптор, который нужно добавить в atlassian-plugin.xml
, выглядит так:
<issue-tabpanel key="log-messages-issue-tab-panel"
name="Log Messages Issue Tab Panel"
i18n-name-key="log-messages-issue-tab-panel.name"
class="ru.samokat.atlassian.jira.tutorials.historywriter.tabpanel.LogMessagesIssueTabPanel">
<description key="log-messages-issue-tab-panel.description">The Log Messages Issue Tab Panel Plugin</description>
<label key="log-messages-issue-tab-panel.label"></label>
<order>10</order>
<resource type="velocity" name="view" location="templates/log-messages-issue-tab-panel.vm"/>
</issue-tabpanel>
В проперти-файле issue-history-writer-tutorial.properties
, автоматически созданом при изначальной генерации плагина с помощью SDK, нужно задать параметры, на которые ссылается дескриптор:
log-messages-issue-tab-panel.label=Log Messages
log-messages-issue-tab-panel.name=Log Messages Issue Tab Panel Name
log-messages-issue-tab-panel.description=The Log Messages Issue Tab Panel Plugin Description
Первый параметр - это отображаемое на экране задачи имя нашей новой вкладки. Второй и третий - имя и описание модуля, отображаемое в админке в перечне установленных плагинов.
Тег resource
в дескрипторе указывает относительный путь из папки src/main/resources
к шаблону, который отвечает за отображение вкладки. Шаблон не очень мудреный, за основу взят шаблон стандартной вкладки History, код которого я вытащил из исходников Jira.
<div class="issue-data-block" >
<div class="actionContainer">
<div class="changehistory action-body">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tbody>
<tr>
<td width = "20%" class="activity-name">$time</td>
<td width = "80%" class="activity-old-val">$message</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
В шаблоне присутствуют два параметра - time
и message
, которые нужно передавать в шаблон для каждой записи, которую нужно отобразить. Делать это будут два класса, которые мы разместим в tabpanel: LogMessageIssueTabPanel
и LogMessageIssueAction
.
Первый из этих классов отвечает за передачу в шаблон параметров. Именно на него ссылается параметр блока issue-tabpanel
, который мы добавили в дескриптор плагина.
@Slf4j
public class LogMessagesIssueTabPanel extends AbstractIssueTabPanel implements IssueTabPanel {
private final LogMessageRepository logMessageRepository;
public LogMessagesIssueTabPanel(LogMessageRepository logMessageRepository) {
this.logMessageRepository = logMessageRepository;
}
@Override
public List<IssueAction> getActions(Issue issue, ApplicationUser remoteUser) {
List<LogMessageEntry> logMessageEntries = logMessageRepository.getLogMessageEntries(issue);
return logMessageEntries.stream()
.map(logMessageEntry -> new LogMessageIssueAction(super.descriptor, logMessageEntry))
.collect(Collectors.toList());
}
@Override
public boolean showPanel(Issue issue, ApplicationUser remoteUser) {
return true;
}
}
Метод showPanel(Issue issue, ApplicationUser remoteUser)
отвечает за то, когда и кому показывать созданную вкладку. Я не стал ограничивать видимость для отдельных категорий задач или групп пользователей, поэтому метод просто всегда возвращает true
.
Метод getActions(Issue issue, ApplicationUser remoteUser)
получает из репозитория список объектов IssueAction
, которые принимает вкладка. Каждый из элементов соответствует отдельной записи лога.
Для имплементации объектов IssueAction
, которые представляют собой единичную запись лога, создаем класс LogMessageIssueAction
, наследуясь от абстракного класса AbstractIssueAction
, и переписываем в нем два метода - первый возвращает время, соотвествующее записи, второй - наполняет двумя параметрами (message
и time)
мапу, которая будет передана в шаблон.
public class LogMessageIssueAction extends AbstractIssueAction {
private final Date timePerformed;
private final String message;
@Getter
private final SimpleDateFormat dateFormat = new SimpleDateFormat("dd-MM-yyyy HH:mm");
public LogMessageIssueAction(IssueTabPanelModuleDescriptor descriptor,
LogMessageEntry logMessageEntry) {
super(descriptor);
this.timePerformed = new Date(logMessageEntry.getTime().getTime());
this.message = logMessageEntry.getText();
}
@Override
public Date getTimePerformed() {
return timePerformed;
}
@Override
protected void populateVelocityParams(Map map) {
map.put("message", message);
map.put("time", getDateFormat().format(timePerformed));
}
}
Чтобы билд запустился, оба класса должны быть покрыты тестами. После билда можно зайти в одну из задач тестового проекта и обнаружить там новую вкладку, которая пока пуста.
Объявляем Java API и экспортируем его в хост-приложение
Для того, чтобы можно было использовать Java API плагина из groovy-скриптов, необходимо объявить интерфейс, который плагин будет экспортировать в хост-приложение, и добавить инструкцию экспорта в pom.xml. Об инструкции уже позаботился SDK на этапе создания плагина, и в pom.xml
уже есть тэг.
<Export-Package>
ru.samokat.atlassian.jira.tutorials.historywriter.api,
</Export-Package>
Нам остается объявить экспортируемый интерфейс в этом пакадже:
import com.atlassian.jira.issue.Issue;
public interface HistoryWriter {
boolean writeLogMessageToIssueHistory(Issue issue, String message);
boolean writeLogMessageToIssueHistory(String issueKey, String message);
}
Сразу предусматриваем в нем два метода: для доступа к issue как по ключу, так и по ссылке. Возвращаемое значение показывает - удалось ли осуществить запись.
Имплиментировать эти методы интерфейса можно в самом репозитории, но поскольку там все же есть небольшая добавочная функциональность в виде получения зачачи по ключу и проверок ввода, я сделал небольшой фасад, который и реализует API.
Аннотация @ExportAsService(HistoryWriter.class) указывает на то, что этот класс является имплементацией интерфеса API, которое мы экспортируем в хост-приложение.
@Named
@Slf4j
@ExportAsService(HistoryWriter.class)
public class HistoryWriterFacade implements HistoryWriter {
private final LogMessageRepository logMessageRepository;
private final IssueManager issueManager;
public HistoryWriterFacade(LogMessageRepository logMessageRepository,
@ComponentImport IssueManager issueManager) {
this.logMessageRepository = logMessageRepository;
this.issueManager = issueManager;
}
@Override
public boolean writeLogMessageToIssueHistory(Issue issue, String message) {
log.debug("writeLogMessageToIssueHistory({}, {})", issue, message);
if (message == null) {
log.warn("trying to write NULL message to issue history. do not writing anything to Log Messages issue tab. check where caller takes it from");
return false;
}
if (issue == null) {
log.warn("issue provided is NULL. do not writing anything to Log Messages issue tab. check where caller takes it from");
return false;
}
log.debug("writeMessageToIssueHistory({}, {})", issue.getKey(), message);
logMessageRepository.createLogMessage(issue, message);
return true;
}
@Override
public boolean writeLogMessageToIssueHistory(String issueKey, String message) {
log.debug("writeMessageToIssueHistory({}, {})", issueKey, message);
if (issueKey == null) {
log.warn("issue key provided is NULL. check where caller takes it from");
return false;
}
Issue issue = issueManager.getIssueByCurrentKey(issueKey);
if (issue == null) {
log.warn("failed to pick issue by key {}. do not writing anything to Log Messages issue tab. " +
"check where caller takes it from", issueKey);
return false;
}
return writeLogMessageToIssueHistory(issue, message);
}
}
Реализуем REST-контроллер
Про реализацию контроллера я подробно рассказывал в предыдущем туториале, сейчас останавливаться на нем не буду. В контексте этой статьи важно только то, что контроллер осуществляет вызов метода Java API. Юнит-тест для контроллера можно посмотреть в репозитории. Для реализации юнит-теста я добавил в pom.xml
еще одну зависимость с используемыми в тесте классами Mockito.
<!-- mockito matchers for unit testing -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>4.11.0</version>
<scope>test</scope>
</dependency>
Проверяем реализованный функционал
Теперь осталось удостовериться, что Java API и REST API работают. Для тестирования Java API нужно установить на поднятый SDK ScriptRunner и выполнить в консоли скриптов следующий код:
import com.atlassian.jira.component.ComponentAccessor
import com.onresolve.scriptrunner.runner.customisers.WithPlugin
import com.onresolve.scriptrunner.runner.customisers.PluginModule
import ru.samokat.atlassian.jira.tutorials.historywriter.api.HistoryWriter
@WithPlugin("ru.samokat.atlassian.jira.tutorials.issue-history-writer-tutorial")
@PluginModule
HistoryWriter hw
def issue = ComponentAccessor.issueManager.getIssueByCurrentKey("TEST-1")
hw.writeLogMessageToIssueHistory(issue, "test1")
hw.writeLogMessageToIssueHistory("TEST-1", "test2")
Для тестирования REST API можно воспользоваться следующим курлом:
curl --location 'http://localhost:2990/jira/rest/issue_history_writer/1.0/write' \
-u admin:admin \
--header 'Content-Type: application/json' \
--data '{
"issueKey": "TEST-1",
"message": "test3"
}'
Открываем задачу и проверяем, что все три сообщения отображаются во вкладке:
Итоги
Оглянемся назад и посмотрим, что же мы натворили:
Решили конкретную прикладную задачу — организовали на экране задачи отдельную вкладку для сообщений автоматизаций. Теперь разбирать баги (или убеждаться что это фичи) бизнес логики стало проще, и делать это могут не только администраторы приложения но и различные пользователи (аналитики, владельцы проектов и остальные).
Применили на практике механизм сохранения данных в БД, используя для этого Java API хост-приложения.
Пробросили в хост-приложение Java API нашего плагина.
Реализовали REST API для обращения к функционалу плагина по http.
Существенный плюс Jira - вендор поощряет доработку коробочного функционала. Нужно пользоваться этим, поскольку в ряде случаев совсем не сложно разработать кастомный модуль под свои потребности.
Успехов вам на этом пути!