Workflow — это сердце процесса работы с задачами в Jira. Стандартные возможности workflow мощные, но часто их недостаточно для сложных бизнес-процессов. ScriptRunner позволяет создавать кастомные валидаторы, пост-функции и условия перехода, которые открывают практически безграничные возможности автоматизации. В этой статье разберу практические примеры автоматизации workflow.
Компоненты workflow в ScriptRunner
ScriptRunner позволяет создавать три типа кастомных компонентов для workflow:
- Валидаторы — проверяют условия перед переходом по статусу
- Пост-функции — выполняют действия после перехода
- Условия перехода — определяют, доступен ли переход для конкретного пользователя
Каждый компонент — это Groovy-скрипт, который получает контекст перехода и может выполнять любые действия через API Jira.
Создание кастомных валидаторов
Валидаторы проверяют условия перед переходом. Если валидация не проходит, переход блокируется и показывается сообщение об ошибке.
Пример: проверка заполнения обязательных полей
Стандартные валидаторы Jira могут проверить, что поле заполнено, но не могут проверить сложные условия. Например, нужно проверить, что поле "Приоритет" заполнено только для задач типа Bug:
import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.issue.Issue
import com.opensymphony.workflow.InvalidInputException
def customFieldManager = ComponentAccessor.getCustomFieldManager()
def priorityField = customFieldManager.getCustomFieldObjectByName("Приоритет")
def issue = issue
def priority = issue.getCustomFieldValue(priorityField)
if (issue.issueType.name == "Bug" && !priority) {
throw new InvalidInputException("Для задач типа Bug необходимо указать приоритет")
}
Валидатор в ScriptRunner получает объект issue автоматически.
Если валидация не проходит, выбрасывается InvalidInputException с сообщением для пользователя.
Пример: проверка связанных задач
Можно проверить, что все связанные задачи закрыты перед закрытием текущей:
import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.issue.link.IssueLinkManager
import com.opensymphony.workflow.InvalidInputException
def issueLinkManager = ComponentAccessor.getIssueLinkManager()
def links = issueLinkManager.getInwardLinks(issue.id)
def openLinkedIssues = links.findAll { link ->
def linkedIssue = link.getSourceObject()
linkedIssue.resolution == null
}
if (openLinkedIssues) {
def keys = openLinkedIssues.collect { it.getSourceObject().key }.join(", ")
throw new InvalidInputException("Нельзя закрыть задачу, пока не закрыты связанные задачи: ${keys}")
}
Создание кастомных пост-функций
Пост-функции выполняются после успешного перехода. Они могут изменять задачу, создавать новые задачи, отправлять уведомления, вызывать внешние API и многое другое.
Пример: автоматическое назначение на основе компонента
Автоматически назначаем задачу на разработчика соответствующего компонента:
import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.issue.MutableIssue
import com.atlassian.jira.user.ApplicationUser
def issueManager = ComponentAccessor.getIssueManager()
def userManager = ComponentAccessor.getUserManager()
def mutableIssue = issueManager.getIssueObject(issue.id) as MutableIssue
def components = issue.components
// Маппинг компонентов на пользователей
def componentToUserMap = [
"Frontend": "frontend.dev",
"Backend": "backend.dev",
"Database": "db.admin"
]
def assignee = null
components.each { component ->
def userName = componentToUserMap[component.name]
if (userName) {
assignee = userManager.getUserByKey(userName)
}
}
if (assignee && mutableIssue.assignee != assignee) {
mutableIssue.setAssignee(assignee)
def currentUser = ComponentAccessor.getJiraAuthenticationContext().getLoggedInUser()
issueManager.updateIssue(currentUser, mutableIssue, com.atlassian.jira.event.type.EventDispatchOption.DO_NOT_DISPATCH, false)
}
Пример: автоматическое создание подзадач
При переходе задачи в статус "In Development" автоматически создаём подзадачи для каждого компонента:
import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.issue.IssueFactory
import com.atlassian.jira.issue.IssueManager
import com.atlassian.jira.user.ApplicationUser
import com.atlassian.jira.project.Project
def issueManager = ComponentAccessor.getIssueManager()
def issueFactory = ComponentAccessor.getIssueFactory()
def userManager = ComponentAccessor.getUserManager()
def currentUser = ComponentAccessor.getJiraAuthenticationContext().getLoggedInUser()
def components = issue.components
components.each { component ->
def subTask = issueFactory.getIssue()
subTask.setProjectObject(issue.projectObject)
subTask.setIssueTypeId("5") // ID типа подзадачи
subTask.setParentId(issue.id)
subTask.setSummary("Разработка компонента: ${component.name}")
subTask.setReporter(currentUser)
def createdIssue = issueManager.createIssueObject(currentUser, subTask)
log.warn("Created sub-task: ${createdIssue.key}")
}
Пример: обновление родительской задачи
При закрытии всех подзадач автоматически переводим родительскую задачу в статус "Done":
import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.issue.IssueManager
import com.atlassian.jira.issue.MutableIssue
import com.atlassian.jira.workflow.WorkflowManager
def issueManager = ComponentAccessor.getIssueManager()
def workflowManager = ComponentAccessor.getWorkflowManager()
if (issue.parentObject) {
def parentIssue = issue.parentObject
def subTasks = issueManager.getSubTaskObjects(parentIssue.id)
def allSubTasksClosed = subTasks.every { subTask ->
subTask.resolution != null
}
if (allSubTasksClosed && parentIssue.status.name != "Done") {
def parentMutable = issueManager.getIssueObject(parentIssue.id) as MutableIssue
def workflow = workflowManager.getWorkflow(parentMutable)
def action = workflow.getLinkedStatus(parentMutable.status.id)?.getActions().find {
it.getName() == "Close Issue"
}
if (action) {
def currentUser = ComponentAccessor.getJiraAuthenticationContext().getLoggedInUser()
workflowManager.transitionIssue(currentUser, parentMutable, action.getId(), null)
}
}
}
Создание условий перехода
Условия перехода определяют, виден ли переход конкретному пользователю. Полезно для скрытия переходов на основе сложных условий.
Пример: переход доступен только автору задачи
Переход "Reopen" доступен только автору задачи:
import com.atlassian.jira.component.ComponentAccessor
def currentUser = ComponentAccessor.getJiraAuthenticationContext().getLoggedInUser()
if (!currentUser) {
return false
}
return issue.reporter == currentUser
Пример: переход доступен только администраторам проекта
import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.security.roles.ProjectRoleManager
def projectRoleManager = ComponentAccessor.getComponent(ProjectRoleManager)
def currentUser = ComponentAccessor.getJiraAuthenticationContext().getLoggedInUser()
if (!currentUser) {
return false
}
def adminRole = projectRoleManager.getProjectRole("Administrators")
def hasRole = projectRoleManager.isUserInProjectRole(currentUser, adminRole, issue.projectObject)
return hasRole
Работа с комментариями и историей
В пост-функциях можно автоматически добавлять комментарии:
import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.issue.comments.CommentManager
def commentManager = ComponentAccessor.getCommentManager()
def currentUser = ComponentAccessor.getJiraAuthenticationContext().getLoggedInUser()
def comment = "Задача автоматически переведена в статус ${transition.name}."
commentManager.create(issue, currentUser, comment, false)
Интеграция с внешними системами
Пост-функции могут вызывать внешние API. Например, отправлять уведомления в Slack:
import groovy.json.JsonBuilder
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.net.URI
def slackWebhookUrl = "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
def message = [
text: "Задача ${issue.key} переведена в статус ${transition.name}",
attachments: [
[
color: "good",
fields: [
[title: "Задача", value: issue.key, short: true],
[title: "Статус", value: transition.name, short: true]
]
]
]
]
def json = new JsonBuilder(message).toPrettyString()
def client = HttpClient.newHttpClient()
def request = HttpRequest.newBuilder()
.uri(URI.create(slackWebhookUrl))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build()
try {
def response = client.send(request, HttpResponse.BodyHandlers.ofString())
log.warn("Slack notification sent: ${response.statusCode()}")
} catch (Exception e) {
log.error("Failed to send Slack notification: ${e.message}")
}
Типичные ошибки
Вот ошибки, которые часто допускают при работе с workflow-скриптами:
Ошибка 1: Блокирующие операции
Валидаторы и условия должны выполняться быстро. Не делайте долгих операций (вызовы внешних API, сложные запросы к БД) в валидаторах — это замедлит workflow.
Ошибка 2: Игнорирование ошибок
В пост-функциях всегда обрабатывайте ошибки. Если пост-функция падает с исключением, переход может откатиться или остаться в неконсистентном состоянии.
Ошибка 3: Бесконечные циклы
Пост-функция, которая переводит задачу в другой статус, может вызвать бесконечный цикл, если в новом статусе тоже есть такая пост-функция. Будьте осторожны.
Выводы
ScriptRunner открывает огромные возможности для автоматизации workflow. Кастомные валидаторы, пост-функции и условия перехода позволяют реализовать практически любой бизнес-процесс.
Начинайте с простых скриптов, тестируйте их на тестовых проектах, постепенно усложняйте. Помните о производительности — валидаторы должны быть быстрыми, пост-функции не должны блокировать пользователей.
Документируйте созданные скрипты — через полгода будет сложно вспомнить логику. Используйте комментарии в коде и ведите документацию.
Если нужна помощь с автоматизацией workflow — свяжитесь со мной.