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:

  1. field: stores the field name in String format.

  2. fieldtype: the nature of the field. Meaning = jiraif the field is a system one, and = customif the field is custom.

  3. 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.

  4. id: The unique number of the changeLog entry in the ChangeItem table of the Jira database.

  5. newvalue And oldvalue: ID of the new and old values, respectively. These values ​​are populated only if the values ​​of the field being changed have an ID.

    1. For example, changing the field Select ListV newvalue And oldvalue option IDs will be written. changing Statusstatus IDs will be written to these parameters.

    2. In the case of, for example, text fields, both values ​​will equal null.

  6. newstring And oldstring: new and old value in String format. Whether it’s change User Picker, Select List, Text Field – it does not matter, these fields will store the textual interpretation of the values: for User PickerdisplayName user, for Select List — text value of the option, for Text 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 Highthen 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_DISPATCHon 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_DISPATCHthis will avoid looping automations when, for example, both Listeners are listening to the event Issue Updated and both update the task by calling Issue Updatedthereby 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:

  1. Update change history for an issue and store the issue in the database.

  2. 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).

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *