Пишем хук для 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, то я делаю следующее:

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

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

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

1.$ atlas-version
2.ATLAS Version: 4.2.7
3....

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

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

1.mkdir stash
2.cd stash
3.atlas-create-stash-plugin

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

1.[INFO] using latest stable data version: 2.7.0
2.    Define value for groupId: : com.example.stash
3.    Define value for artifactId: : jira-acceptance
4.    Define value for version:  [1.0-SNAPSHOT]:
5.    Define value for package:  [com.example.stash]:
6....
7.[INFO] BUILD SUCCESSFUL

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

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

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

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

1.<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">
2.    <description key="jira-pre-receive-hook.description">The Jira Pre Receive Hook Plugin</description>
3.    <icon>icon-example.png</icon>
4.  </repository-hook>

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

1.<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">
2.  <description key="jira-pre-receive-hook.description">Jira Commit Acceptance</description>
3.  <icon>images/stash.png</icon>
4.      <config-form name="Simple Hook Config" key="simpleHook-config">
5.          <view>stash.config.example.hook.simple.formContents</view>
6.          <directory location="/static/"/>
7.      </config-form>
8.</repository-hook>

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

01.{namespace stash.config.example.hook.simple}
02. 
03./**
04. * @param config
05. * @param? errors
06. */
07.{template .formContents}
08.    {call aui.form.textField}
09.        {param id: 'jiraBaseURL' /}
10.        {param value: $config['jiraBaseURL'] /}
11.        {param labelContent}
12.            {stash_i18n('stash.web.test.hook.config.label', 'Jira Base URL')}
13.        {/param}
14.        {param descriptionText: stash_i18n('stash.web.test.hook.config.description', 'Example: http://jira.example.com') /}
15.        {param extraClasses: 'long' /}
16.        {param errorTexts: $errors ? $errors['jiraBaseURL'] : null /}
17.    {/call}
18.    {call aui.form.textField}
19.        {param id: 'projectKey' /}
20.        {param value: $config['projectKey'] ? $config['projectKey'] : '*' /}
21.        {param labelContent}
22.            {stash_i18n('stash.web.test.hook.config.label', 'projectKey')}
23.        {/param}
24.        {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') /}
25.        {param extraClasses: 'long' /}
26.        {param errorTexts: $errors ? $errors['projectKey'] : null /}
27.    {/call}
28.{/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, преобразим её в такой вот вид:

01.package com.example.stash.hook;
02. 
03. 
04.import com.atlassian.stash.content.Changeset;
05.import com.atlassian.stash.history.HistoryService;
06.import com.atlassian.stash.hook.HookResponse;
07.import com.atlassian.stash.util.PageRequestImpl;
08.import com.atlassian.stash.setting.*;
09.import java.util.List;
10.import com.google.common.collect.Lists;
11.import java.net.URL;
12. 
13.import org.apache.xmlrpc.client.*;
14.import org.apache.xmlrpc.util.*;
15.import java.util.Vector;
16. 
17.import com.atlassian.stash.hook.*;
18.import com.atlassian.stash.hook.repository.*;
19.import com.atlassian.stash.repository.*;
20.import java.util.Collection;
21. 
22.public class JiraPreReceiveHook implements PreReceiveRepositoryHook, RepositorySettingsValidator
23.{
24. 
25.    private static final PageRequestImpl PAGE_REQUEST = new PageRequestImpl(0, 100);
26.    private final HistoryService historyService;
27.    public JiraPreReceiveHook(HistoryService historyService) {
28.        this.historyService = historyService;
29.    }
30. 
31.    @Override
32.    public boolean onReceive(RepositoryHookContext context, Collection<RefChange> refChanges, HookResponse hookResponse) {
33.        List<Changeset> badChangesets = Lists.newArrayList();
34.        String url = context.getSettings().getString("jiraBaseURL");
35.        String RPC_PATH  = "/rpc/xmlrpc";
36.        String jiraLogin = "somelogin";
37.        String jiraPassword = "somepass";
38.        String projectKey = context.getSettings().getString("projectKey");
39. 
40.        if (url != null) {
41.          for (RefChange refChange : refChanges) {
42.            for (Changeset changeset : historyService.getChangesetsBetween(context.getRepository(), refChange.getFromHash(), refChange.getToHash(), PAGE_REQUEST).getValues()) {
43.                try {
44.                    XmlRpcClientConfigImpl config = new XmlRpcClientConfigImpl();
45.                    config.setServerURL(new URL(url + RPC_PATH));
46.                    XmlRpcClient rpcClient = new XmlRpcClient();
47.                    rpcClient.setConfig(config);
48.                    Vector jiraParams = new Vector(5);
49.                    jiraParams.add(jiraLogin);
50.                    jiraParams.add(jiraPassword);
51.                    jiraParams.add("admin");
52.                    jiraParams.add(projectKey);
53.                    jiraParams.add(changeset.getMessage());
54.                    String result = (String) rpcClient.execute("commitacc.acceptCommit", jiraParams);
55. 
56.                    hookResponse.out().println("Result: " + result);
57.                    if (!result.matches("true(.*)")) {
58.                       badChangesets.add(changeset);
59.                    }
60.                } catch (Exception e) {
61.                    e.printStackTrace();
62.                }
63.            }
64.          }
65.          if (badChangesets.isEmpty()) {
66.              hookResponse.out().println("Jira Commit Acceptance Passed");
67.              return true;
68.          }
69.          for (Changeset changeset : badChangesets) {
70.              hookResponse.err().println(String.format("Bad changeset '%s' with message '%s'", changeset.getId(), changeset.getMessage()));
71.          }
72.          return false;
73.        }
74.        hookResponse.out().println("Jira URL is empty");
75.        return false;
76.    }
77. 
78.    @Override
79.    public void validate(Settings settings, SettingsValidationErrors errors, Repository repository) {
80.        if (settings.getString("jiraBaseURL", "").isEmpty()) {
81.            errors.addFieldError("jiraBaseURL", "jiraBaseURL field is blank, please supply one");
82.        }
83.        if (settings.getString("projectKey", "").isEmpty()) {
84.            errors.addFieldError("projectKey", "projectKey field is blank, please supply one");
85.        }
86.    }
87.}

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

01.<dependency>
02.    <groupId>org.apache.xmlrpc</groupId>
03.    <artifactId>xmlrpc-client</artifactId>
04.    <version>3.1.3</version>
05.</dependency>
06.<dependency>
07.    <groupId>org.apache.xmlrpc</groupId>
08.    <artifactId>xmlrpc-common</artifactId>
09.    <version>3.1.3</version>
10.</dependency>

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

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

01.$ git clone http://admin@127.0.0.1:7990/stash/scm/project_1/rep_1.git
02.$ cd rep_1/
03.$ touch test
04.$ git add test
05.$ git commit -a -m &#39;BadMessage&#39;
06.$ git push
07.    Password for 'http://admin@127.0.0.1:7990':
08.    remote: Communication breakdown with Stash.
09.    To http://admin@127.0.0.1:7990/stash/scm/project_1/rep_1.git
10.    ! [remote rejected] master -> master (pre-receive hook declined)
11.    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 вылез стектрейс:

1.[INFO] [talledLocalContainer] Caused by: java.lang.ClassCastException: org.apache.xerces.jaxp.SAXParserFactoryImpl cannot be cast to javax.xml.parsers.SAXParserFactory
2.    [INFO] [talledLocalContainer] at javax.xml.parsers.SAXParserFactory.newInstance(Unknown Source) ~[na:1.6.0_27]
3.    [INFO] [talledLocalContainer] at org.apache.xmlrpc.util.SAXParsers.<clinit>(SAXParsers.java:34) ~[na:na]
4.    [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:

01.<dependency>
02.    <groupId>org.apache.xmlrpc</groupId>
03.    <artifactId>xmlrpc-client</artifactId>
04.    <version>3.1.3</version>
05.      <exclusions>
06.        <exclusion>
07.          <artifactId>xml-apis</artifactId>
08.          <groupId>xml-apis</groupId>
09.        </exclusion>
10.      </exclusions>
11.</dependency>
12.<dependency>
13.    <groupId>org.apache.xmlrpc</groupId>
14.    <artifactId>xmlrpc-common</artifactId>
15.    <version>3.1.3</version>
16.      <exclusions>
17.        <exclusion>
18.          <artifactId>xml-apis</artifactId>
19.          <groupId>xml-apis</groupId>
20.        </exclusion>
21.      </exclusions>
22.</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 

Rich Text Editor, comment