Пишем хук для 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 'BadMessage' $ 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: Atlassian, Java
Category:
Java |
No comments »