Стандартные типы кастомных полей в Jira покрывают большинство потребностей, но иногда нужны поля с более сложной логикой: вычисляемые значения, динамические опции на основе других полей, интеграции с внешними системами. ScriptRunner позволяет создавать полностью кастомные поля, которые делают практически всё, что можно представить. В этой статье разберу практические примеры создания таких полей.
Типы кастомных полей в ScriptRunner
ScriptRunner позволяет создавать несколько типов кастомных полей:
- Scripted Fields — вычисляемые поля, значения которых рассчитываются автоматически
- Scripted Fields (Read Only) — только для чтения, показывают вычисляемые значения
- Scripted Fields (Editable) — можно редактировать, с кастомной логикой сохранения
- Template Fields — поля на основе шаблонов
Наиболее часто используемые — Scripted Fields, которые вычисляют значения на основе других полей задачи.
Создание простого вычисляемого поля
Начнём с простого примера: поле, которое показывает количество дней с момента создания задачи.
Создайте новое поле через: Administration → Issues → Custom fields → Create Custom Field. Выберите тип "Scripted Field (read-only)" и добавьте скрипт:
import com.atlassian.jira.component.ComponentAccessor
import java.util.concurrent.TimeUnit
def created = issue.created
def now = new Date()
def diff = now.time - created.time
def days = TimeUnit.DAYS.convert(diff, TimeUnit.MILLISECONDS)
return "${days} дней"
Это поле будет автоматически вычисляться при каждом просмотре задачи и показывать количество дней с момента создания.
Вычисление на основе других полей
Часто нужно вычислять значение на основе других кастомных полей. Например, вычислять общее время на основе времени разработки и тестирования:
import com.atlassian.jira.component.ComponentAccessor
def customFieldManager = ComponentAccessor.getCustomFieldManager()
def devTimeField = customFieldManager.getCustomFieldObjectByName("Время разработки")
def testTimeField = customFieldManager.getCustomFieldObjectByName("Время тестирования")
def devTime = issue.getCustomFieldValue(devTimeField) as Integer ?: 0
def testTime = issue.getCustomFieldValue(testTimeField) as Integer ?: 0
def totalTime = devTime + testTime
return "${totalTime} часов"
Обратите внимание на использование as Integer ?: 0 — это безопасное приведение типа
с значением по умолчанию, если поле пустое.
Работа с датами и временем
Работа с датами — частая задача в вычисляемых полях. Например, поле, показывающее дедлайн на основе даты создания и стандартного срока выполнения:
import java.util.Calendar
def created = issue.created
def standardDays = 14 // Стандартный срок выполнения в днях
def calendar = Calendar.getInstance()
calendar.setTime(created)
calendar.add(Calendar.DAY_OF_MONTH, standardDays)
def deadline = calendar.getTime()
// Форматирование даты
def formatter = new java.text.SimpleDateFormat("dd.MM.yyyy")
return formatter.format(deadline)
Можно также вычислять, просрочена ли задача:
import java.util.Calendar
def dueDateField = ComponentAccessor.getCustomFieldManager().getCustomFieldObjectByName("Due Date")
def dueDate = issue.getCustomFieldValue(dueDateField) as Date
if (!dueDate) {
return "Нет дедлайна"
}
def now = new Date()
if (dueDate.before(now) && issue.resolution == null) {
def diff = now.time - dueDate.time
def daysOverdue = TimeUnit.DAYS.convert(diff, TimeUnit.MILLISECONDS)
return "Просрочена на ${daysOverdue} дней"
} else if (issue.resolution) {
return "Выполнена"
} else {
return "В срок"
}
Агрегация данных из подзадач
Полезный пример — вычисление суммарного времени из подзадач:
import com.atlassian.jira.component.ComponentAccessor
def issueManager = ComponentAccessor.getIssueManager()
def subTasks = issueManager.getSubTaskObjects(issue.id)
if (subTasks.empty) {
return "Нет подзадач"
}
def timeField = ComponentAccessor.getCustomFieldManager().getCustomFieldObjectByName("Время выполнения")
def totalTime = 0
subTasks.each { subTask ->
def time = subTask.getCustomFieldValue(timeField) as Integer ?: 0
totalTime += time
}
return "${totalTime} часов"
Аналогично можно вычислять процент выполнения на основе подзадач:
import com.atlassian.jira.component.ComponentAccessor
def issueManager = ComponentAccessor.getIssueManager()
def subTasks = issueManager.getSubTaskObjects(issue.id)
if (subTasks.empty) {
return "0%"
}
def completed = subTasks.count { it.resolution != null }
def total = subTasks.size()
def percent = (completed * 100 / total) as Integer
return "${percent}% (${completed}/${total})"
Работа с связанными задачами
Можно вычислять значения на основе связанных задач. Например, показывать количество открытых блокеров:
import com.atlassian.jira.component.ComponentAccessor
def issueLinkManager = ComponentAccessor.getIssueLinkManager()
def links = issueLinkManager.getInwardLinks(issue.id)
def blockers = links.findAll { link ->
def linkedIssue = link.getSourceObject()
link.issueLinkType.name == "Blocks" && linkedIssue.resolution == null
}
return blockers.size() > 0 ? "${blockers.size()} блокеров" : "Нет блокеров"
Кастомные поля с редактированием
Scripted Fields могут быть редактируемыми. В этом случае нужно определить логику получения и сохранения значения.
Для редактируемого поля нужно указать два скрипта: один для получения значения (getter), другой для сохранения (setter). Создайте поле типа "Scripted Field (editable)".
Getter (получение значения)
// Получаем сохранённое значение из другого поля или вычисляем
def valueField = ComponentAccessor.getCustomFieldManager().getCustomFieldObjectByName("Hidden Value Field")
def value = issue.getCustomFieldValue(valueField) as String
return value ?: ""
Setter (сохранение значения)
import com.atlassian.jira.issue.Issue
import com.atlassian.jira.issue.MutableIssue
import com.atlassian.jira.component.ComponentAccessor
def issueManager = ComponentAccessor.getIssueManager()
def mutableIssue = issueManager.getIssueObject(issue.id) as MutableIssue
// Сохраняем значение в скрытое поле для последующего использования
def valueField = ComponentAccessor.getCustomFieldManager().getCustomFieldObjectByName("Hidden Value Field")
mutableIssue.setCustomFieldValue(valueField, fieldValue)
def currentUser = ComponentAccessor.getJiraAuthenticationContext().getLoggedInUser()
issueManager.updateIssue(currentUser, mutableIssue, com.atlassian.jira.event.type.EventDispatchOption.DO_NOT_DISPATCH, false)
Интеграция с внешними системами
Кастомные поля могут получать данные из внешних систем. Например, показывать статус деплоя из CI/CD:
import groovy.json.JsonSlurper
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.net.URI
def issueKey = issue.key
def ciUrl = "https://ci.company.com/api/deploy-status/${issueKey}"
try {
def client = HttpClient.newHttpClient()
def request = HttpRequest.newBuilder()
.uri(URI.create(ciUrl))
.GET()
.build()
def response = client.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() == 200) {
def json = new JsonSlurper().parseText(response.body())
return json.status ?: "Неизвестно"
} else {
return "Ошибка получения статуса"
}
} catch (Exception e) {
log.error("Failed to get deploy status: ${e.message}")
return "Недоступно"
}
Важно: вызовы внешних API в вычисляемых полях могут замедлить загрузку задач. Используйте кэширование или делайте такие поля обновляемыми по требованию, а не при каждом просмотре.
Условная логика в полях
Поля могут показывать разное значение в зависимости от условий. Например, показывать разные форматы времени в зависимости от типа задачи:
def issueType = issue.issueType.name
if (issueType == "Bug") {
def priority = issue.priority?.name ?: "Medium"
return "Приоритет: ${priority}"
} else if (issueType == "Story") {
def storyPoints = ComponentAccessor.getCustomFieldManager()
.getCustomFieldObjectByName("Story Points")
def points = issue.getCustomFieldValue(storyPoints)
return "Story Points: ${points ?: 'Не оценено'}"
} else {
return "Тип: ${issueType}"
}
Производительность вычисляемых полей
Вычисляемые поля выполняются при каждом просмотре задачи. Поэтому важно следить за производительностью:
- Избегайте сложных операций — не делайте множественные запросы к БД или внешним API
- Кэшируйте результаты — для тяжёлых вычислений используйте кэширование
- Оптимизируйте запросы — если нужны данные из других задач, старайтесь минимизировать количество запросов
Пример кэширования результата:
import com.atlassian.cache.Cache
import com.atlassian.cache.CacheManager
import com.atlassian.jira.component.ComponentAccessor
def cacheManager = ComponentAccessor.getComponent(CacheManager)
def cache = cacheManager.getCache("scripted-field-cache", String.class, String.class)
def cacheKey = "field-${issue.id}-${issue.updated.time}"
def cachedValue = cache.get(cacheKey)
if (cachedValue) {
return cachedValue
}
// Выполняем вычисление
def computedValue = performExpensiveCalculation()
// Кэшируем на 5 минут
cache.put(cacheKey, computedValue)
return computedValue
Типичные ошибки
Вот ошибки, которые часто допускают при создании кастомных полей:
Ошибка 1: Null pointer exceptions
Всегда проверяйте значения на null перед использованием. Используйте безопасный доступ (?.)
и оператор Elvis (?:).
Ошибка 2: Медленные вычисления
Вычисления выполняются при каждом просмотре задачи. Если вычисление тяжёлое, пользователи будут ждать. Оптимизируйте или кэшируйте.
Ошибка 3: Изменение задачи в getter
В getter (скрипт получения значения) нельзя изменять задачу. Это приведёт к ошибкам и может вызвать бесконечные циклы. Изменения делайте только в setter или через слушатели событий.
Выводы
ScriptRunner открывает огромные возможности для создания кастомных полей с любой логикой. Вычисляемые поля позволяют показывать агрегированные данные, значения из внешних систем, сложные вычисления на основе других полей.
Начинайте с простых вычисляемых полей, постепенно усложняйте логику. Помните о производительности — поля выполняются при каждом просмотре задачи, поэтому они должны быть быстрыми.
Используйте кэширование для тяжёлых вычислений, всегда проверяйте значения на null, тестируйте поля на разных типах задач и в разных контекстах.
Если нужна помощь с созданием кастомных полей — свяжитесь со мной.