Jira Server Scripting Best Practices

Hi all! My name is Sergey Troshin, I’m an Atlassian administrator at VKCO. I noticed that there is little concentrated information on the Internet about writing automations in Groovy using the Jira Java API. The topic is quite important, since no serious company can do without sophisticated business process automation tools. In most cases, this tool is the Scriptrunner plugin from Adaptavist, it is on it that the scripts are written, fragments of which are used in this article. But we will not get hung up on a tool that allows you to access the Jira Java API, this does not matter.
This kind of set of practices is advisory in nature. It will allow you to spend less time and memory, structure your code and improve readability.
Important comment! These are primarily recommendations based on personal experience, the experience of my colleagues and the experience of the Atlassian community. All advice is subject to criticism. I’ve sorted them in descending order of importance, from practically necessary to merely desirable.
Using the ChangeItem table
In almost every Listener that listens to an event Issue Updated
, you need to use the changeLog event. More:
// 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'}
Suppose we are trapping changes to one field − Testing
(second option). Let’s also assume it’s a custom field like Select List
with values (in parentheses option IDs) Yes
(id=10000) and No
(id=10001). As a result of updating the task, the value of the field was changed from Yes
on No
. By displaying the value of the variable in the logs changeOne log.error(changeOne)
We’ll see:
field="Testing" fieldtype="custom" group="6842042" id="9023773" newstring="No" newvalue="10001" oldstring="Yes" oldvalue="10000"
Let’s analyze the received data:
field
: stores the field name in String format.fieldtype
: the nature of the field. Meaning= jira
if the field is a system one, and= custom
if the field is custom.group
: A reference ID to an entry in the Jira database’s ChangeGroup table, which stores details about the subject issue, author, and modification date.id
: The unique number of the changeLog entry in the ChangeItem table of the Jira database.newvalue
Andoldvalue
: ID of the new and old values, respectively. These values are populated only if the values of the field being changed have an ID.For example, changing the field
Select List
Vnewvalue
Andoldvalue
option IDs will be written. changingStatus
status IDs will be written to these parameters.In the case of, for example, text fields, both values will equal
null
.
newstring
Andoldstring
: new and old value in String format. Whether it’s changeUser Picker
,Select List
,Text Field
– it does not matter, these fields will store the textual interpretation of the values: forUser Picker
—displayName
user, forSelect List
— text value of the option, forText Field
— text value of the field.
What an entry in the ChangeItem table would look like for our example:
id | group | field type | field | old value | oldstring | newvalue | newstring |
9023773 | 6842042 | custom | testing | 10000 | Yes | 10001 | no |
Variable changeOne
– nothing more than a log of changes in the field we need Testing
. There is a lot of useful information in the resulting changeLog.
Which? Forget about the field Testing
. Now, for example, if we want to track events when a priority changed its value from Medium
on High
then we will use oldstring
And newstring
.
Priority changed from Medium to High
GenericValue priorityChange = event?.changeLog?.getRelated("ChildChangeItem")?.find {it.field == 'priority'}
if (priorityChange.oldstring == 'Medium' && priorityChange.newstring == 'High') {
/* Наш код */
} else return
However, the most important benefit of using changeLogs is the saving of instance resources.
As you know, we can’t force a Listener to listen for a change in a particular field (at least in ScriptRunner v6.44.0). We can only tell it to listen for an update on the Issue Updated task as a whole. This results in wasted resources. And here changeLog comes to the rescue, which will be an empty array or null if the fields from monitoredfields
or field 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'}
By placing this expression on the next line:
// Первый вариант
if (!changeArr) return // changesArr = [], return
// Второй вариант
if (!changeOne) return // changesOne = null, return
…we will interrupt the execution of the script, saving the resources of the instance, because we will not execute the script if the necessary changes have not occurred.
In order for the trick to be as justified as possible, it is logical to place a block with a changeLog check immediately after we have defined this changeLog.
Return is our everything
This advice overlaps with the previous one in many ways. We also try to save resources by exiting the script on time. Again, we may need to automate a specific type of task or tasks with a specific attribute, the rest of the situations are not interesting to us. That is why it is worth first checking whether the task that called the Listener matches the conditions or not.
Here are a couple of examples:
We only need to automate for bugs:
Bug only
import com.atlassian.jira.issue.Issue
Issue issue = (Issue) event.issue
if (issue.issueType.id != "1") return // Bug id=1
We need to automate only for tasks that have security in the phase field:
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
It is also good practice to use a one-liner if return
. This allows you to reduce the level of nesting in the program code.
There is nesting:
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
No nesting:
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 tasks as much as needed
What does reindex affect? Without this task, there will be problems with searching through JQL. Therefore, it is possible and necessary to reindex after we have made changes to the database through the method updateIssue(...)
and those like him.
Examples:
Change Summary
:
// Установить Summary для Issue Object, но не записывать его в Database
currentIssue.setSummary('Hello, World!')
// Записать раннее установленные значения в Database
issueManager.updateIssue(user, currentIssue, EventDispatchOption.DO_NOT_DISPATCH, false)
// Ре-индексировать задачу
issueIndexingService.reIndex(currentIssue)
Change Custom Field
:
// Установить currentFieldValue в currentField для Issue Object, но не записывать его в Database
currentIssue.setCustomFieldValue(currentField, currentFieldValue)
// Записать раннее установленные значения в Database и вызвать Issue Updated Event, в результате которого произойдет ре-индексация
issueManager.updateIssue(user, currentIssue, EventDispatchOption.ISSUE_UPDATED, false)
It is worth noting that in one case I use issueIndexingService.reIndex(currentIssue)
, but not in the other. It depends on the specified EventDispatchOption
. At EventDispatchOption = DO_NOT_DISPATCH
(making a change does not trigger an event) automatic re-indexing is not performed – you need to do it manually. At EventDispatchOption != DO_NOT_DISPATCH
on the contrary, it is not necessary to reindex the task, this happens as a result of the event Issue Updated
.
Which EventDispatchOption
use? I recommend DO_NOT_DISPATCH
this will avoid looping automations when, for example, both Listeners are listening to the event Issue Updated
and both update the task by calling Issue Updated
thereby provoking the work of each other.
What is meant by “as much as needed”? Let’s say we have an array of fields and we need to synchronize the values from issueOne
V issueTwo
.
Bad Approach:
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)
}
You don’t have to do that. We do as shown below.
Good approach:
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)
Speaking of POST functions, you need to take care of the need to reindex the task. Each transition has two items by default:
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
Thus, if your script is placed before the items with updating and reindexing, then additionally apply the methods updateIssue
And reIndex
no need.

Using reindexing in Scripted Fields is not recommended!
Use IDs where possible
There are two ways to get CustomField
using class 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')
As an admin, I had to change the names of the fields more than once. Changing the name will immediately break the script using the second scripting approach. In addition, two or more fields of the same name of different types can simultaneously exist in an instance, so it is preferable to use first way. A similar approach should be used not only for custom fields, but also for other objects: task types, events, and so on. This not only helps to avoid errors in the code, but also reduces it. For example, field Select List Testing
takes in Option
. Thus, we can get the desired Option in two ways Yes
and assign it to a field:
First option:
FieldConfig testingFieldConfig = testingField.getRelevantConfig(issue)
List<Option> testingFieldOptions = ComponentAccessor.optionsManager.getOptions(testingFieldConfig)
Option testingFieldValue = testingFieldOptions.find{it.value == 'Yes'}
issue.setCustomFieldValue(testingField, testingFieldValue)
Second option:
Option testingFieldValue = ComponentAccessor.optionsManager.findByOptionId(10000)
issue.setCustomFieldValue(testingField, testingFieldValue)
The second option looks clearly more advantageous.
If you use some ID more than once, then it is better to allocate a variable for it. Variable under a constant:
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)
/* Код с дальнейшими преобразованиями и сохранением изменений в базу*/
What user’a to use when changing tasks?
In most of the actions on the task, we need to define the ApplicationUser on whose behalf the change will be made. Every instance has a tech account, in ours it’s Jellyrunner. We have two options for execution: on behalf of a technical account and on behalf of a real user. In the script it will be written like this:
import com.atlassian.jira.user.ApplicationUser
// Определяем пользователя, чьи действия инициировали работу скрипта
ApplicationUser user = ComponentAccessor.getJiraAuthenticationContext().getLoggedInUser()
// Определяем техническую учетку (в нашем примере это Jellyrunner)
ApplicationUser user = ComponentAccessor.userManager.getUserByName("Jellyrunner")
So when to use what? Everything is simple! If automation is cross-project and at a certain event in the first project, changes are made to the second, then we use technical accounting, it has access everywhere. Otherwise, the script may throw an error if the real user does not have rights in the second project.
But why not always use the technical account? In fact, it is possible, no one forbids it, but this deprives us of some advantages. Since changes are written to the task’s change history, we can always track whose actions triggered the script. And if we see scripted changes and the author of Jellyrunner, then we will have to compare changes in related tasks by timings of changes in order to find the root cause. And if we immediately see the user, then we have more information, and less time is spent on finding out the reasons for the behavior.
Using typing
To make AutoComplete work, make the code more readable and have fewer red lines, I prefer to resort to strong typing. Compare two options for working scripts:

Yes, in the first case, I hid the veil of imports at the beginning. However, it is now more readable and maintainable code.
Thank you all for your patience in reading this!
Useful links:
JavaDoc: Java classes and methods are described here. When reading the documentation, make sure that the first series of numbers, and preferably the first two, match the numbers of your version. For example, if your version is 8.20.10, then any documentation 8.20.XX will do for you, in extreme cases 8.XX.XX.
Adaptavist Library: a script library from the vendor of the most popular tool, ScriptRunner. There you can see examples of scripts.
Atlassian Community Moscow: thematic Telegram channel. Here you can find like-minded people and ask for help (but only after you yourself have reached a dead end).