Когда 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 - вендор поощряет доработку коробочного функционала. Нужно пользоваться этим, поскольку в ряде случаев совсем не сложно разработать кастомный модуль под свои потребности.

Успехов вам на этом пути!

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


  1. iliks84
    30.07.2024 12:52

    Они разве не ушли из России?


    1. ignat_n Автор
      30.07.2024 12:52

      Границы России нигде не заканчиваются. Из нее невозможно уйти


  1. Italia1235
    30.07.2024 12:52

    Побольше бы такого контента. В русс сообществе вообще нет инфы про плагины.