Пишем хук для Atlassian Stash

Date August 27th, 2013 Author Vitaly Agapov

Ничто так не способствует карьере творца, как немного смерти и безвестности.

Дэн Симмонс «Падение Гипериона»

Раньше я тесно взаимодействовал с системой контроля версий Subversion и с системой для багтрекинга (точнее issue-трекинга) Atlassian Jira. Они были тесно переплетены друг с другом. Jira показывала привязанные к задачам коммиты в SVN, SVN спрашивал у Jira разрешения на коммит с учётом главного правила – сообщение к коммиту должно содержать валидный ключ задачи в Jira. Последняя фича реализовывалась с помощью плагина JIRA Commit Acceptance и pre-commit хука для SVN, написанного на Perl'е.

Когда же SVN отошёл и уступил место git'у под управлением Atlassian Stash, возникла необходимость реализовать ту же самую тёплую ламповую функциональность, которая существовала и раньше. Каково же было моё удивление, когда я узнал, что плагина для Stash с аналогичным хуком не существует (да-да, хуки здесь существуют в виде плагинов). А это значило, что следует засучить рукава и разобраться, как написать свой собственный плагин под Stash и научить его делать то, что нам надо.

Плагины для продуктов Atlassian пишутся на Java, и лично для меня основной сложностью и преградой было именно это обстоятельство, ведь я джавой головного мозга не страдаю и за всю жизнь не написал на Java ни строчки кода, если не считать кое-каких HelloWorld'ов по разным туториалам. Но всё бывает в первый раз. Так что этот текст может пригодиться (а может и не пригодиться) людям совершенно далёким от java-разработки, плагиностроения для Atlassian'овсих продуктов и вообще от быдлокодинга.

Atlassian SDK

Atlassian удивляет качеством софта, который они пишут. Но ещё сильнее они удивляют своей техподдержкой, своим комьюнити и своим SDK. Всё сделано для удобства и быстроты вовлечения. В общем, мы начнём с установки этого самого SDK. Все необходимые инструкции есть здесь. А так как в моём случае всё происходит под Ubuntu, то я делаю следующее:

sudo sh -c 'echo "deb https://sdkrepo.atlassian.com/debian/ stable contrib" >> /etc/apt/sources.list'
sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys B07804338C015B73
sudo apt-get install apt-transport-https
sudo apt-get update
sudo apt-get install atlassian-plugin-sdk

Всё, Atlassian SDK стоит и его можно использовать. Конечно, остаётся вопрос с тем, какой IDE мы хотим использовать. Можно ставить Eclipse или что-то вроде того, но это отдельная тема. Я для своего маленького эксперимента использовал vim, который при должной сноровке может превратиться в почти полноценный IDE.

В общем, теперь с помощью набора команд, начинающихся с atlas-, можно производить разные манипуляции вроде такой:

$ atlas-version
ATLAS Version: 4.2.7
...

Создание шаблона модуля

Но мы лучше создадим новый проект модуля:

mkdir stash
cd stash
atlas-create-stash-plugin

Maven скачает нужные исходники и задаст разные вопросы по проекту:

[INFO] using latest stable data version: 2.7.0
	Define value for groupId: : com.example.stash
	Define value for artifactId: : jira-acceptance
	Define value for version:  [1.0-SNAPSHOT]:
	Define value for package:  [com.example.stash]:
...
[INFO] BUILD SUCCESSFUL

Готово. Утилита создала готовый проект, который хоть сейчас можно билдить и использовать в качестве плагина. Правда, он ничего не будет делать. Но это уже детали. Проект лежит в отдельной директории с названием jira-acceptance (в соответствии с artifactId), в которой лежит готовый maven'овский pom.xml, а также пути с ресурсами и исходниками. Уже на этом этапе можно погрузиться в документацию для разработчиков и начать пилить собственный хук. Я изначально так и сделал, и даже добился рабочего состояния. Но SDK даёт ещё одну интересную возможность – создать отдельный модуль в проекте под нужную нам функциональность.

$ atlas-create-stash-plugin-module
...
	Choose Plugin Module:
...
	7: REST Plugin Module
	8: Repository Hook
	9: SCM Request Check
...
	Choose a number (1/2/3/4/5/6/7/8/9/10/11/12/13/14/15/16/17/18/19/20): 8
...
	Enter Hook Type (pre/post/merge) [post]: pre
	Enter New Classname [MyPreReceiveRepositoryHook]: JiraPreReceiveHook
	Enter Package Name [com.example.stash.hook]:
	Show Advanced Setup? (Y/y/N/n) [N]:
...
	Add Another Plugin Module? (Y/y/N/n) [N]:

Доработка логики плагина

Посомтрим, что изменилось в проекте. Во-первых в дескрипторе плагина по пути jira-acceptance/src/main/resources/atlassian-plugin.xml появился новый блок:

<repository-hook name="Jira Pre Receive Hook" i18n-name-key="jira-pre-receive-hook.name" key="jira-pre-receive-hook" class="com.example.stash.hook.JiraPreReceiveHook">
    <description key="jira-pre-receive-hook.description">The Jira Pre Receive Hook Plugin</description>
    <icon>icon-example.png</icon>
  </repository-hook>

Мы в ней изменим description, логотип и добавим форму конфигурации:

  <repository-hook name="Jira Pre Receive Hook" i18n-name-key="jira-pre-receive-hook.name" key="jira-pre-receive-hook" class="com.example.stash.hook.JiraPreReceiveHook">
    <description key="jira-pre-receive-hook.description">Jira Commit Acceptance</description>
    <icon>images/stash.png</icon>
        <config-form name="Simple Hook Config" key="simpleHook-config">
            <view>stash.config.example.hook.simple.formContents</view>
            <directory location="/static/"/>
        </config-form>
  </repository-hook>

Естественно, здесь же в директорию images надо подложить заготовленный логотип размером 96×96 точек. А в директорию static подложить файл simple.soy с шаблоном формы конфигурации:

{namespace stash.config.example.hook.simple}

/**
 * @param config
 * @param? errors
 */
{template .formContents}
    {call aui.form.textField}
        {param id: 'jiraBaseURL' /}
        {param value: $config['jiraBaseURL'] /}
        {param labelContent}
            {stash_i18n('stash.web.test.hook.config.label', 'Jira Base URL')}
        {/param}
        {param descriptionText: stash_i18n('stash.web.test.hook.config.description', 'Example: http://jira.example.com') /}
        {param extraClasses: 'long' /}
        {param errorTexts: $errors ? $errors['jiraBaseURL'] : null /}
    {/call}
    {call aui.form.textField}
        {param id: 'projectKey' /}
        {param value: $config['projectKey'] ? $config['projectKey'] : '*' /}
        {param labelContent}
            {stash_i18n('stash.web.test.hook.config.label', 'projectKey')}
        {/param}
        {param descriptionText: stash_i18n('stash.web.test.hook.config.description', 'Can contain multiple comma-separated JIRA project keys. * forces using the global commit acceptance settings') /}
        {param extraClasses: 'long' /}
        {param errorTexts: $errors ? $errors['projectKey'] : null /}
    {/call}
{/template}

В этой форме у нас будет поле для пути к Jira и шаблон имени проекта, в котором должна быть искомая задача.

В java у нас появился новый путь к файлу JiraPreReceiveHook.java с классом JiraPreReceiveHook и методом onReceive, служащим в качестве хука.

Тут стоит сделать небольшое отступление. Так как git следует парадигме распределённой системы контроля версий (в отличие от централизованной SVN), здесь неприменимы понятия pre-commit и post-commit, так как с каждым push'ем в репозиторий может отправляться не один коммит, а целый набор (changeset). В связи с этим здесь применяются понятия pre-receive (или on-receive) и post-receive. Причём в нашем случае на каждый pre-receive придётся проверять скорее всего не один коммит с одним комментарием и одним jira-issue-key, а целый набор, каждый из которых должен удовлетворять нашим условиям.

В общем, используя заготовку, подготовленную нам командой atlas-create-stash-plugin-module, преобразим её в такой вот вид:

package com.example.stash.hook;


import com.atlassian.stash.content.Changeset;
import com.atlassian.stash.history.HistoryService;
import com.atlassian.stash.hook.HookResponse;
import com.atlassian.stash.util.PageRequestImpl;
import com.atlassian.stash.setting.*;
import java.util.List;
import com.google.common.collect.Lists;
import java.net.URL;

import org.apache.xmlrpc.client.*;
import org.apache.xmlrpc.util.*;
import java.util.Vector;

import com.atlassian.stash.hook.*;
import com.atlassian.stash.hook.repository.*;
import com.atlassian.stash.repository.*;
import java.util.Collection;

public class JiraPreReceiveHook implements PreReceiveRepositoryHook, RepositorySettingsValidator
{

    private static final PageRequestImpl PAGE_REQUEST = new PageRequestImpl(0, 100);
    private final HistoryService historyService;
    public JiraPreReceiveHook(HistoryService historyService) {
        this.historyService = historyService;
    }

    @Override
    public boolean onReceive(RepositoryHookContext context, Collection<RefChange> refChanges, HookResponse hookResponse) {
        List<Changeset> badChangesets = Lists.newArrayList();
        String url = context.getSettings().getString("jiraBaseURL");
        String RPC_PATH  = "/rpc/xmlrpc";
        String jiraLogin = "somelogin";
        String jiraPassword = "somepass";
        String projectKey = context.getSettings().getString("projectKey");

        if (url != null) {
          for (RefChange refChange : refChanges) {
            for (Changeset changeset : historyService.getChangesetsBetween(context.getRepository(), refChange.getFromHash(), refChange.getToHash(), PAGE_REQUEST).getValues()) {
                try {
                    XmlRpcClientConfigImpl config = new XmlRpcClientConfigImpl();
                    config.setServerURL(new URL(url + RPC_PATH));
                    XmlRpcClient rpcClient = new XmlRpcClient();
                    rpcClient.setConfig(config);
                    Vector jiraParams = new Vector(5);
                    jiraParams.add(jiraLogin);
                    jiraParams.add(jiraPassword);
                    jiraParams.add("admin");
                    jiraParams.add(projectKey);
                    jiraParams.add(changeset.getMessage());
                    String result = (String) rpcClient.execute("commitacc.acceptCommit", jiraParams);

                    hookResponse.out().println("Result: " + result);
                    if (!result.matches("true(.*)")) {
                       badChangesets.add(changeset);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
          }
          if (badChangesets.isEmpty()) {
              hookResponse.out().println("Jira Commit Acceptance Passed");
              return true;
          }
          for (Changeset changeset : badChangesets) {
              hookResponse.err().println(String.format("Bad changeset '%s' with message '%s'", changeset.getId(), changeset.getMessage()));
          }
          return false;
        }
        hookResponse.out().println("Jira URL is empty");
        return false;
    }

    @Override
    public void validate(Settings settings, SettingsValidationErrors errors, Repository repository) {
        if (settings.getString("jiraBaseURL", "").isEmpty()) {
            errors.addFieldError("jiraBaseURL", "jiraBaseURL field is blank, please supply one");
        }
        if (settings.getString("projectKey", "").isEmpty()) {
            errors.addFieldError("projectKey", "projectKey field is blank, please supply one");
        }
    }
}

Функциональная часть плагина готова, но надо ещё поправить maven-зависимости, чтобы добавить в проект пакет org.apache.xmlrpc. Для этого в pom.xml добавляем:

        <dependency>
            <groupId>org.apache.xmlrpc</groupId>
            <artifactId>xmlrpc-client</artifactId>
            <version>3.1.3</version>
        </dependency>
        <dependency>
            <groupId>org.apache.xmlrpc</groupId>
            <artifactId>xmlrpc-common</artifactId>
            <version>3.1.3</version>
        </dependency>

Делаем atlas-run из корня проекта, и… чудеса – всё собирается. Более того – SDK запустил нам локальную копию Stash с установленным нашим свеженаписанным плагином. Достучаться до неё можно по пути http://127.0.0.1:7990/stash. Логин и пароль: admin/admin.

В списке плагинов мы видим наш плагин, а в списке хуков для репозитория мы видим наш хук. Включаем его, вводим в конфиге путь к Jira и, скажем, знак "*" в шаблон проекта. После этого можно попробовать поэкспериментировать.

$ git clone http://admin@127.0.0.1:7990/stash/scm/project_1/rep_1.git
$ cd rep_1/
$ touch test
$ git add test
$ git commit -a -m &#39;BadMessage&#39;
$ git push
	Password for 'http://admin@127.0.0.1:7990':
	remote: Communication breakdown with Stash.
	To http://admin@127.0.0.1:7990/stash/scm/project_1/rep_1.git
	! [remote rejected] master -> master (pre-receive hook declined)
	error: failed to push some refs to 'http://admin@127.0.0.1:7990/stash/scm/project_1/rep_1.git'

Проблема с org.apache.xerces.jaxp.SAXParserFactoryImpl cannot be cast to javax.xml.parsers.SAXParserFactory

Оп-па. Вместо нормального сообщения от нашего хука (метод hookResponse) мы получили какой-то Communication breakdown with Stash. А в логе самого Stash вылез стектрейс:

[INFO] [talledLocalContainer] Caused by: java.lang.ClassCastException: org.apache.xerces.jaxp.SAXParserFactoryImpl cannot be cast to javax.xml.parsers.SAXParserFactory
	[INFO] [talledLocalContainer] at javax.xml.parsers.SAXParserFactory.newInstance(Unknown Source) ~[na:1.6.0_27]
	[INFO] [talledLocalContainer] at org.apache.xmlrpc.util.SAXParsers.<clinit>(SAXParsers.java:34) ~[na:na]
	[INFO] [talledLocalContainer] ... 57 common frames omitted

Это из-за того, что XML-RPC имеет свои зависимости, конфликтующие с теми, что уже есть в Stash. В частности – SAX XML Parser из пакета xml-apis. На стадии билда эта проблема себя никак не проявляет из-за динамической модульной шины OSGi для Java. Из-за неё модуль xml-apis загружается только при обращении к соответствующему методу, в нашем случае – при попытке распарсить XML-ответ от XML-RPC интерфеса джировского плагина.

Посмотреть зависимости можно так:

atlas-mvn dependency:tree

[INFO] +- org.apache.xmlrpc:xmlrpc-client:jar:3.1.3:compile
[INFO] \- org.apache.xmlrpc:xmlrpc-common:jar:3.1.3:compile
[INFO]    \- org.apache.ws.commons.util:ws-commons-util:jar:1.0.2:compile
[INFO]       \- xml-apis:xml-apis:jar:1.0.b2:compile

Пофиксить проблему можно, добавив исключения в зависимости в pom.xml:

        <dependency>
            <groupId>org.apache.xmlrpc</groupId>
            <artifactId>xmlrpc-client</artifactId>
            <version>3.1.3</version>
              <exclusions>
                <exclusion>
                  <artifactId>xml-apis</artifactId>
                  <groupId>xml-apis</groupId>
                </exclusion>
              </exclusions>
        </dependency>
        <dependency>
            <groupId>org.apache.xmlrpc</groupId>
            <artifactId>xmlrpc-common</artifactId>
            <version>3.1.3</version>
              <exclusions>
                <exclusion>
                  <artifactId>xml-apis</artifactId>
                  <groupId>xml-apis</groupId>
                </exclusion>
              </exclusions>
        </dependency>

После внесения изменений надо выполнить команду atlas-clean, а потом запускать сборку снова: atlas-run. После пересборки всё работает как по маслу.

Установка плагина

В директории target в проекте у нас лежит собранный jar-ник jira-acceptance-1.0-SNAPSHOT.jar. Мы качаем его на сервер с боевым Stash, подкладываем в plugins/installed-plugins внутри директории STASH_HOME и перезапускаем Stash.

Готово.

Tags: ,
Category: Java | No comments »

Comments

Leave a comment

 Comment Form