Пишем плагин для Atlassian Jira — добавляем на экран задачи вкладку с логом автоматизаций
Когда Jira обрастает кастомной логикой, автоматизациями и интеграциями, рано или поздно возникает потребность в отслеживании действий, которые произвели (или не произвели) с задачей все эти роботы.
Если вам периодически приходят баги о неработающей автоматизации и вы начинаете смотреть логи scriptrunner, automation и прочих JWME — этот момент настал.
Если заказчик просит фиксировать факт отправки сообщения во внешнюю систему в комментарии к задаче — этот момент точно настал.
Если вы уже и сами начали создавать комментарии из автоматизаций и groovy-скриптов — момент настал совершенно абсолютно точно.
Этот туториал будет полезен начинающим разработчикам в стеке Atlassian и администраторам Jira, пробующим себя в разработке плагинов.
Привет, Хабр! Меня зовут Игнат, в Samokat.tech я пишу плагины, автоматизации и интеграции для 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 и сайт организации, а так же в разделе
задаем версию Jira и версию Java:
8.22.0
11
11
Затем командой atlas-run из директории проекта запускаем приложение, убеждаясь, что билд проходит без ошибок, а по адресу http://localhost:2990/jira/ доступно веб-приложение. В моём случае этого не произошло, и для того, чтобы приложение запустилось с заданной версией Jira (8.22.0), я понизил версию jira-maven-plugin до 8.1.2, поменяв соответствующую property в pom.xml.
...
8.1.2
...
После старта приложения создаем сэмпл-проект, где будет жить тестовая задача. Поскольку плагин предоставляет API, которое предполагается использовать из groovy-скриптов ScriptRunner, то устанавливаем и этот плагин.
При написании кода для уменьшения бойлерплейта я использую lombok, зависимости которого тоже нужно добавить:
org.slf4j
slf4j-api
2.0.5
provided
org.slf4j
slf4j-log4j12
2.0.5
provided
org.projectlombok
lombok
${org.projectlombok.version}
provided
...
1.18.30
...
Для управления уровнями логирования в процессе разработки в раздел
добавляем параметр — ссылку на конфигурационный файл логгеров — log4j.properties
:
com.atlassian.maven.plugins
jira-maven-plugin
...
src/main/resources/log4j.properties
...
Сам файл 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 соответствующий блок:
org.jacoco
jacoco-maven-plugin
prepare-agent
prepare-agent
report
test
report
check-minimal
package
check
BUNDLE
INSTRUCTION
COVEREDRATIO
1.0
BRANCH
COVEREDRATIO
1.0
CLASS
MISSEDCOUNT
0
METHOD
MISSEDCOUNT
0
LINE
MISSEDCOUNT
0
...
Также добавляем нужные при написании тестов зависимости Junit и Mockito в раздел dependency
:
org.mockito
mockito-core
4.11.0
test
org.mockito
mockito-junit-jupiter
4.11.0
test
org.junit.jupiter
junit-jupiter-engine
5.9.3
test
Написание тест-кейсов не тема этого туториала — давайте договоримся, что сами тестовые классы я приводить в статье не буду. Их можно посмотреть в репозитории. Пишите в комментариях, если возникнут вопросы на их счёт.
Реализуем репозиторий сообщений лога
Для того, чтобы отображать на экране задачи сообщения лога, нужно реализовать их хранение в БД. Для этого будем использовать Atlassian ActiveObjects.
ActiveObjects или AO — механизм, который Atlassian SDK предоставляет разработчикам для реализации хранения данных. Подробнее о нем можно почитать в документации вендора. Если вкратце — объявляем интерфейс с нужными нам полями, фреймворк создает в БД хост-приложения соответствующую таблицу и DAO-класс, имплементирующий объявленный нами интерфейс для доступа к данным таблицы. Перед этим, нужно добавить в pom.xml соответствующую зависимость:
com.atlassian.activeobjects
activeobjects-plugin
3.5.1
provided
Итак, начинаем с объявления интерфейса. У сущностей «запись лога», которые мы хотим отображать в создаваемой вкладке, нам понадобится всего три поля:
время события —
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
:
The AO module for storing issue log messages at db.
ru.samokat.atlassian.jira.tutorials.historywriter.entity.LogMessageEntry
Пробуем билдить проект и, конечно же, билд начинает падать из-за отсутствия покрытия тестами. Пока что просто удаляеам созданные 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 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
, выглядит так:
The Log Messages Issue Tab Panel Plugin
10
В проперти-файле 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.
$time
$message
В шаблоне присутствуют два параметра — 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 getActions(Issue issue, ApplicationUser remoteUser) {
List 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
уже есть тэг.
ru.samokat.atlassian.jira.tutorials.historywriter.api,
Нам остается объявить экспортируемый интерфейс в этом пакадже:
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.
org.mockito
mockito-junit-jupiter
4.11.0
test
Проверяем реализованный функционал
Теперь осталось удостовериться, что 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 — вендор поощряет доработку коробочного функционала. Нужно пользоваться этим, поскольку в ряде случаев совсем не сложно разработать кастомный модуль под свои потребности.
Успехов вам на этом пути!