Всем привет! Меня зовут Сергей Трошин, я администратор Atlassian в VKCO. Заметил, что в интернете мало концентрированной информации про написание автоматизаций на Groovy с помощью API Jira Java. Тема достаточно важная, так как ни одна серьёзная компания не обходится без сложных средств автоматизации бизнес-процессов. В большинстве случаев таким средством является плагин Scriptrunner от Adaptavist, именно на нём написаны скрипты, фрагменты из которых используются в этой статье. Но мы не будем зацикливаться на инструменте, позволяющим обращаться к Jira Java API, это не играет роли.
Этот, своего рода, набор практик носит рекомендательный характер. Он позволит тратить меньше времени и памяти, структурировать написание кода и улучшить читабельность.
Важный комментарий! Это в первую очередь рекомендации, основанные на личном опыте, опыте моих коллег и опыте Atlassian сообщества. Все советы могут критиковаться. Я отсортировал их по уменьшению важности: от практически необходимых до просто желательных.
Использование таблицы ChangeItem
Практически в каждом Listener'е, который слушает событие Issue Updated
, нужно использовать changeLog события. Подробнее:
// Testing, Deploy - абстрактные названия полей
Collection<String> monitoredfields = ['Testing', 'Deploy']
// Первый вариант: отлавливаем changeLog сразу двух полей - Testing, Deploy
List<GenericValue> changeArr = event?.changeLog?.getRelated("ChildChangeItem")?.findAll {it.field in monitoredfields}
// Второй вариант: отлавливаем changeLog только одного поля - Testing
GenericValue changeOne = event?.changeLog?.getRelated("ChildChangeItem")?.find {it.field == 'Testing'}
Предположим, что мы отлавливаем изменения одного поля — Testing
(второй вариант). Также предположим, что это пользовательское поле типа Select List
со значениями (в скобках ID опций) Yes
(id=10000) и No
(id=10001). В результате обновления задачи значение поля было изменено с Yes
на No
. Выведя в логи значение переменной changeOne log.error(changeOne)
, мы увидим:
field="Testing" fieldtype="custom" group="6842042" id="9023773" newstring="No" newvalue="10001" oldstring="Yes" oldvalue="10000"
Разберём полученные данные:
field
: хранит название поля в формате String.fieldtype
: природа поля. Значение= jira
, если поле системное, и= custom
, если поле пользовательское.group
: ссылочный ID на записи в таблице ChangeGroup базы данных Jira, где хранятся подробности о задаче-субъекте, авторе и дате изменения.id
: уникальный номер записи changeLog'а в таблице ChangeItem базы данных Jira.newvalue
иoldvalue
: ID нового и старого значений соответственно. Эти значения заполняются только в том случае, если у значений изменяемого поля есть ID.Например, изменяя поле
Select List
, вnewvalue
иoldvalue
будут записаны ID опций. ИзменяяStatus
, в эти параметры будут записаны ID статусов.В случае, например, текстовых полей оба значения будут равняться
null
.
newstring
иoldstring
: новое и старое значение в формате String. Будь то измененияUser Picker
,Select List
,Text Field
— не важно, в этих полях будет храниться текстовая интерпретация значений: дляUser Picker
—displayName
пользователя, дляSelect List
— текстовое значение опции, дляText Field
— текстовое значение поля.
Как бы выглядела запись в таблице ChangeItem для нашего примера:
id | group | fieldtype | field | oldvalue | oldstring | newvalue | newstring |
9023773 | 6842042 | custom | Testing | 10000 | Yes | 10001 | No |
Переменная changeOne
— ни что иное, как лог изменения нужного нам поля Testing
. В полученном changeLog очень много полезной информации.
Какой? Забываем про поле Testing
. Теперь, например, если мы хотим отследить события, когда приоритет изменил своё значение с Medium
на High
, то мы воспользуемся oldstring
и newstring
.
Приоритет изменился с Medium на High
GenericValue priorityChange = event?.changeLog?.getRelated("ChildChangeItem")?.find {it.field == 'priority'}
if (priorityChange.oldstring == 'Medium' && priorityChange.newstring == 'High') {
/* Наш код */
} else return
Однако самое главное преимущество при использовании changeLog'ов — это экономия ресурсов инстанса.
Как известно, мы не можем заставить Listener слушать изменение в конкретном поле (по крайне мере в ScriptRunner v6.44.0). Мы можем лишь сказать ему слушать обновление по задаче Issue Updated в целом. Это приводит к трате лишних ресурсов. И тут на помощь приходит changeLog, который будет пустым массивом или null, если при обновлении задачи не изменялись поля из monitoredfields
или поле Testing
.
Collection<String> monitoredfields = ['Testing', 'Deploy']
//Если поля из monitoredfields не изменились в процессе события, спровоцировавшего Listener, то .findAll вернет [] - пустой массив
List<GenericValue> changeArr = event?.changeLog?.getRelated("ChildChangeItem")?.findAll {it.field in monitoredfields}
//Если поле Testing не изменилось в процессе события, спровоцировавшего Listener, то .find вернет null
GenericValue changeOne = event?.changeLog?.getRelated("ChildChangeItem")?.find {it.field == 'Testing'}
Разместив в следующей же строчке это выражение:
// Первый вариант
if (!changeArr) return // changesArr = [], return
// Второй вариант
if (!changeOne) return // changesOne = null, return
...мы прервём выполнения скрипта, сэкономив ресурсы инстанса, потому что не будем выполнять скрипт, если нужных изменений не произошло.
Чтобы трюк был максимально оправдан, логично размещать блок с проверкой changeLog'а сразу после того, как мы этот changeLog определили.
Return — наше всё
Этот совет во многом пересекается с предыдущим. Мы также стараемся экономить ресурсы, вовремя выходя из скрипта. Опять же, нам может понадобиться автоматизировать конкретный тип задач или задачи с конкретным атрибутом, остальные ситуации нам не интересны. Именно поэтому сто̒ит сперва проверять, подходит ли вызвавшая Listener задача под условия или нет.
Вот пара примеров:
Нам нужно автоматизировать только для багов:
Bug only
import com.atlassian.jira.issue.Issue
Issue issue = (Issue) event.issue
if (issue.issueType.id != "1") return // Bug id=1
Нам нужно автоматизировать только для задач, у которых в поле phase значения security:
Security phase only
import com.atlassian.jira.issue.Issue
import com.atlassian.jira.issue.CustomFieldManager
import com.atlassian.jira.component.ComponentAccessor
Issue issue = (Issue) event.issue
//ID поля "phase"=16553, Select List (Single Choice)
String phaseValue = issue.getCustomFieldValue(ComponentAccessor.customFieldManager.getCustomFieldObject(16553)).value
if (phaseValue != 'security') return
Также хорошим тоном будет использование однострочного if return
. Это позволяет сократить уровень вложенности в программном коде.
Есть вложенность:
import com.atlassian.jira.issue.Issue
import com.atlassian.jira.component.ComponentAccessor
Issue issue = (Issue) event.issue
//ID поля "phase"=16553, Select List (Single Choice)
String phaseValue = issue.getCustomFieldValue(ComponentAccessor.customFieldManager.getCustomFieldObject(16553)).value
if (issue.issueType.id == '1') {
if (phaseValue == 'security') {
/* Наш код */
} else return
} else return
Нет вложенности:
import com.atlassian.jira.issue.Issue
import com.atlassian.jira.component.ComponentAccessor
Issue issue = (Issue) event.issue
//ID поля "phase"=16553, Select List (Single Choice)
String phaseValue = issue.getCustomFieldValue(ComponentAccessor.customFieldManager.getCustomFieldObject(16553)).value
if(issue.issueType.id != '1') return
if(phaseValue != 'security') return
/* Наш код */
Reindex'ировать задачи столько, сколько нужно
На что влияет reindex? Без этого задачи будут возникать проблемы с поиском через JQL. Поэтому реиндексировать можно и нужно после того, как мы внесли изменения в базу данных через метод updateIssue(...)
и ему подобные.
Примеры:
Изменяем Summary
:
// Установить Summary для Issue Object, но не записывать его в Database
currentIssue.setSummary('Hello, World!')
// Записать раннее установленные значения в Database
issueManager.updateIssue(user, currentIssue, EventDispatchOption.DO_NOT_DISPATCH, false)
// Ре-индексировать задачу
issueIndexingService.reIndex(currentIssue)
Изменяем Custom Field
:
// Установить currentFieldValue в currentField для Issue Object, но не записывать его в Database
currentIssue.setCustomFieldValue(currentField, currentFieldValue)
// Записать раннее установленные значения в Database и вызвать Issue Updated Event, в результате которого произойдет ре-индексация
issueManager.updateIssue(user, currentIssue, EventDispatchOption.ISSUE_UPDATED, false)
Стоит заметить, что в одном случае я использую issueIndexingService.reIndex(currentIssue)
, а в другом — нет. Это зависит от указанной EventDispatchOption
. При EventDispatchOption = DO_NOT_DISPATCH
(внесение изменения не вызывает события) автоматическая ре‑индексация не производится — нужно делать вручную. При EventDispatchOption != DO_NOT_DISPATCH
, напротив, реиндексировать задачу не надо, это происходит в результате события Issue Updated
.
Какой EventDispatchOption
использовать? Я рекомендую DO_NOT_DISPATCH
, это позволит избежать зацикливания автоматизаций, когда, например, оба Listener'а слушают событие Issue Updated
и оба обновляют задачу, вызывая Issue Updated
, тем самым провоцируя работу друг друга.
Что подразумевается под «столько, сколько нужно»? Допустим, у нас есть массив полей и надо синхронизировать значения из issueOne
в issueTwo
.
Плохой подход:
Collection<CustomField> fieldToSync = [Field1, Field2, ..., FieldN]
fieldToSync.each { currentField ->
def currentFieldValue = issueOne.getCustomFieldValue(currentField)
issueTwo.setCustomFieldValue(currentField, currentFieldValue)
issueManager.updateIssue(user, issueTwo, EventDispatchOption.DO_NOT_DISPATCH, false)
issueIndexingService.reIndex(issueTwo)
}
Так делать не надо. Делаем, как показано ниже.
Хороший подход:
Collection<CustomField> fieldToSync = [Field1, Field2, ..., FieldN]
fieldToSync.each { currentField ->
def currentFieldValue = issueOne.getCustomFieldValue(currentField)
issueTwo.setCustomFieldValue(currentField, currentFieldValue)
}
issueManager.updateIssue(user, issueTwo, EventDispatchOption.DO_NOT_DISPATCH, false)
issueIndexingService.reIndex(issueTwo)
Говоря о POST-функциях, нужно позаботиться о необходимости реиндексации задачи. В каждом transition'е по умолчанию есть два пункта:
Update change history for an issue and store the issue in the database.
Re-index an issue to keep indexes in sync with the database
Таким образом, если ваш скрипт размещён до пунктов с обновлением и реиндексацией, то дополнительно применять методы updateIssue
и reIndex
не нужно.
Использования реиндексации в Scripted Field'ах не рекомендуется!
Использование Id-шников там, где это возможно
Есть два способа достать CustomField
с помощью класса CustomFieldManager
:
import com.atlassian.jira.issue.CustomFieldManager
import com.atlassian.jira.issue.fields.CustomField
import com.atlassian.jira.component.ComponentAccessor
// Первый способ - получение поля через его id
CustomField cf1 = ComponentAccessor.customFieldManager.getCustomFieldObject(Long customFieldId)
// Второй способ - получение поля по его названию
CustomField cf1 = ComponentAccessor.customFieldManager.getCustomFieldObjectByName(String 'customFieldName')
Мне, как админу, не раз приходилось менять названия полей. Изменение названия тут же поломает скрипт, использующий второй подход скриптования. Кроме того, в инстансе может одновременно существовать два и более одноимённых полей разный типов, поэтому предпочтительнее использовать первый способ. Аналогичный подход стоит применять не только для пользовательский полей, но и для других объектов: типов задач, событий и прочего. Это не только помогает избежать ошибок в коде, но и сокращает его. Например, поле Select List Testing
принимает в себя Option
. Таким образом, мы можем двумя способами получить нужную Option Yes
и присвоить её полю:
Первый вариант:
FieldConfig testingFieldConfig = testingField.getRelevantConfig(issue)
List<Option> testingFieldOptions = ComponentAccessor.optionsManager.getOptions(testingFieldConfig)
Option testingFieldValue = testingFieldOptions.find{it.value == 'Yes'}
issue.setCustomFieldValue(testingField, testingFieldValue)
Второй вариант:
Option testingFieldValue = ComponentAccessor.optionsManager.findByOptionId(10000)
issue.setCustomFieldValue(testingField, testingFieldValue)
Второй вариант выглядит явно выигрышнее.
Если используете какой-то ID более одного раза, то лучше выделить под него переменную. Переменная под константу:
final Long ISSUE_TYPE_ID = '1'
MutableIssue issue = (MutableIssue) event.issue
if (issue.issueTypeId == ISSUE_TYPE_ID) return
//Если id-шник типа задачи не равен '1', то меняем на него
issue.setIssueTypeId(ISSUE_TYPE_ID)
/* Код с дальнейшими преобразованиями и сохранением изменений в базу*/
Какого user'а использовать при изменении задач?
В большинстве действий над задачей нам нужно определить ApplicationUser'а, от лица которого будет производиться изменение. В любом инстансе есть техническая учётная запись, в нашем это Jellyrunner. У нас два вариант исполнения: от лица технической учётки и от лица настоящего user'а. В скрипте это запишется так:
import com.atlassian.jira.user.ApplicationUser
// Определяем пользователя, чьи действия инициировали работу скрипта
ApplicationUser user = ComponentAccessor.getJiraAuthenticationContext().getLoggedInUser()
// Определяем техническую учетку (в нашем примере это Jellyrunner)
ApplicationUser user = ComponentAccessor.userManager.getUserByName("Jellyrunner")
Так когда что использовать? Всё просто! Если автоматизация кросспроектная и при определённом событии в первом проекте изменения вносятся во второй, то используем техническую учётку, она имеет доступ везде. Иначе скрипт может выдать ошибку, если настоящий пользователь не имеет прав во втором проекте.
Но почему же тогда не использовать всегда техническую учётную запись? На самом деле можно, никто не запрещает, но это лишает нас некоторых преимуществ. Так как изменения записываются в историю изменений задачи, мы всегда можем отследить, чьи действия инициировали работу скрипта. И если мы видим скриптовое изменения и автора Jellyrunner, то придётся по таймингам изменений сопоставлять изменения в связанных задачах, чтобы найти первопричину. А если мы сразу видим пользователя, то информации у нас больше, а времени на выяснение причин поведения тратится меньше.
Использование типизации
Чтобы работал AutoComplete, код стал удобочитаем и было меньше красных строк, я предпочитаю прибегать к строгой типизации. Сравните два варианта рабочих скриптов:
Да, в первом случае я утаил пелену import'ов в начале. Однако теперь это более читаемый и поддерживаемый код.
Благодарю за терпение всех полностью прочитавших!
Полезные ссылки:
JavaDoc: здесь описаны Java-классы и методы. При чтении документации следите, чтобы первая серия цифр, а лучше первые две совпадали с цифрами вашей версии. Например, если ваша версия 8.20.10, то вам подойдёт любая документация 8.20.ХХ, в крайнем случае 8.ХХ.ХХ.
Adaptavist Library: библиотека скриптов от вендора самого популярного инструмента — ScriptRunner'а. Там можно посмотреть примеры скриптов.
Atlassian Community Moscow: тематический Telegram-канал. Здесь вы можете найти единомышленников и попросить о помощи (но только после того, как сами безнадежно упёрлись в тупик).