Хабрахабр

Плагины Jira: несколько примеров успешного изобретения велосипеда

Благодаря нашим усилиям свет увидели плагины My Groovy, JS Includer, My Calendar, My ToDo и другие. Мы в Mail.ru Group вкладываем много сил в развитие продуктов компании Atlassian и, в частности, Jira. Все эти плагины мы развиваем и активно используем внутри компании.

Иногда это выливается в новые плагины, но чаще мы решаем поставленные задачи используя уже существующие плагины, так как большинство повседневных задач ими легко покрываются. Мы получаем массу запросов от смежных подразделений по внедрению новых фич.

Для тестировщиков — сделать механизм отслеживания этапов тестирования с ответственным за выполнение. Для проведения экскурсий в офисе нужно было предусмотреть создание запросов с проверкой пересекающихся экскурсий. Техподдержка хотела получить автоматический доступ к базе знаний.

Сегодня я расскажу, как путем комбинирования плагинов удалось решить эти задачи.

Инструменты:

  • My Calendar
  • JS Includer

Проблема

В офисе Mail.ru Group много «экскурсоводов», которые договариваются с гостями и затем ставят задачи на АХО. Иногда случается так, что несколько экскурсий могут образоваться в одно и тоже время — тогда по офису одновременно ходят несколько групп, либо одному экскурсоводу отказывают, и он идет передоговариваться с гостями.

Решение

  1. Появление в задаче «слотов» (даты и времени из набора свободных вариантов) для выбора при создании заявки на экскурсию На день — 3 слота. Например:
    • 9:00-10:00
    • 17:30-18:30
    • 20:00-21:00

    Если слот был выбран в другой задаче, нельзя предлагать его для выбора в новой. Также нужна возможность убрать слоты из выбора руками (в случае, например, когда экскурсии в офисе невозможны в принципе).

  2. Появление календаря, формируемого из свободных и занятых слотов, который можно расшарить на экскурсоводов.

Реализация

Шаг 1: добавляем необходимые поля на экран создания запроса.

Для этого создадим поле «Дата» типа Date и поле «Время экскурсии» типа Radiobutton для выбора одного значения из 3 вариантов (9:00-10:00; 17:30-18:30; 20:00-21:00).

Шаг 2: создаем календарь.

Нацеливаем через JQL его на наш проект с экскурсиями,
указываем Event start созданное ранее поле «Дата», а так же добавляем в отображение созданное ранее поле «Время экскурсии». Делаем новый календарь.

Теперь наши экскурсии можно просматривать в календаре. Сохраняем календарь.

Шаг 3: ограничиваем создание экскурсий и добавляем баннер с ссылкой на календарь.

Когда выбрана дата, мы должны подставить ее в jql-функцию и получить все запросы на эту дату, затем узнаем какое время занято и прячем эти варианты на экране, чтобы лишить возможности выбрать занятое время. Чтобы этого добиться, потребуется JS, который будет отслеживать изменение в поле «Дата».


Когда нет запросов


Когда есть 2 запроса на 9 утра и на 20 вечера

(function($){
/* Пояснение: Дата — customfield_19620 Время экскурсии — customfield_52500 Опции поля «Время экскурсии»: 9:00-10:00 — 47611 17:30-18:30 — 47612 20:00-21:00 — 47613
*/ /* Сначала добавляем проверку значения в поле дата. Весь дальнейший код будет внутри этого блока.
*/ $("input[name=customfield_19620]").on("click change", function(e) { var idOptions = []; var url = "/rest/api/latest/search"; /* Если «Дата» не выбрана, то скрываем выбор времени.
*/ if (!$("#customfield_19620").val())
/* Иначе берем значение из поля даты и переводим в удобный для подстановки в jql вид, так же выводим на экран все значения времени.
*/ else { var temp = $("#customfield_19620").val(); var arrDate = temp.split('.'); var result = "" + arrDate[2].trim() + "-" + arrDate[1].trim() + "-" + arrDate[0].trim(); $('input:radio[name=customfield_52500][value="-1"]').parent().remove(); $('input:radio[name=customfield_52500]').closest('.group').show(); $('input:radio[name=customfield_52500][value="47611"]').parent().show(); $('input:radio[name=customfield_52500][value="47612"]').parent().show(); $('input:radio[name=customfield_52500][value="47613"]').parent().show();
/* Затем подставляем в jql.
*/ var params = { jql: "issuetype = Events and cf[52500] is not EMPTY and cf[19620] = 20" + result, fields: "customfield_52500" };
/* Далее в полученном JSON находим все запросы и скрываем использованное в них время с экрана.
*/ $.getJSON(url, params, function (data) { var issues = data.issues for (var i = 0; i < issues.length; i++) { idOptions.push(issues[i].fields.customfield_52500.id) } for (var k = 0; k < idOptions.length; k++) { $('input:radio[name=customfield_52500][value=' + idOptions[k] + ']').parent().hide(); } }); } });
/* Добавляем баннер с ссылкой на календарь.
*/ $('div.field-group:has(#customfield_19620)').last().before(` <div id="bannerWithInfo" class="aui-message info"> <p class="title"> Как работать с календарем </p> <p>Выберите дату планируемой экскурсии</p> <p>Затем выберите время экскурсии из доступных вариантов</p> <p>По ссылке ниже вы можете посмотреть запланированные экскурсии в календаре</p> <p><a href='https://jira.ru/secure/MailRuCalendar.jspa#calendars=492' target="_blank">Календарь экскурсий</a></p> </div> `);
})(AJS.$);

Инструмент:

Проблема

В запросе нужно настроить отображение этапов тестирования с указанием ответственного за задачу сотрудника. Должно быть видно, что этап еще не завершен, либо этап завершен (и кто его проводил).

Решение

Настроить поле типа scripted field на отображение этапов тестирования и связать с workflow, записывать в ответственных за этап автора перехода.

Реализация

  1. Создаем поле «Ход выполнения» типа scripted field.
  2. Создаем поля типа UserPicker, соответствующие этапам тестирования.

    Для примера определим следующие этапы и создадим поля UserPicker с теми же названиями:

    • Базовая информация собрана
    • Локализовано
    • Логи собраны
    • Воспроизведено
    • Ответственный найден

  3. Настраиваем workflow так, чтобы на переходах заполнялись ответственные.

    Например переход «Локализовано» записывает currentUser в поле UserPicker «Локализовано».

  4. Настраиваем отображение при помощи scripted field.

Заполняем блок groovy:

import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.config.properties.APKeys baseUrl = ComponentAccessor.getApplicationProperties().getString(APKeys.JIRA_BASEURL)
colorApprove = "#D2F0C2"
colorNotApprove = "#FDACAC"
return getHTMLApproval() def getHTMLApproval(){ def approval = getApproval() def html = "<table class='aui'>" approval.each{k,v-> html += """<tr> <td ${v?"bgcolor='${colorApprove}'":"bgcolor='${colorNotApprove}'"}>${k}</td> <td ${v?"bgcolor='${colorApprove}'":"bgcolor='${colorNotApprove}'"}>${v?displayUser(v):""}</td> </tr>""" } html += "</table>" return html
} def displayUser(user){ "<a href=${baseUrl}/secure/ViewProfile.jspa?name=${user.name}>${user.displayName}</a>"
} def getApproval(){ def approval = [:] as LinkedHashMap if (issue.getIssueTypeId() == '10001'){ //Тип запроса - Тестирование approval.put("Базовая информация собрана", getCfValue(54407)) approval.put("Логи собраны", getCfValue(54409)) approval.put("Воспроизведено", getCfValue(54410)) approval.put("Ответственный найден", getCfValue(54411)) approval.put("Локализовано", getCfValue(54408)) } return approval
} def getCfValue(id){ ComponentAccessor.customFieldManager.getCustomFieldObject(id).getValue(issue)
}

В блоке velocity выводим $value. Получаем такой результат:

Инструменты:

  • JS Includer
  • My Groovy

Проблема

У техподдержки есть своя база знаний на Confluence. Нужна возможность отображать связанные с проблемой статьи из базы знаний в запросе Jira. Так же нужен механизм поддержки базы в актуальном состоянии — если статья не была полезной, нужно поставить запрос техническому писателю в Jira на написание актуальной статьи. При закрытии запроса должны остаться только статьи относящиеся к запросу. Ссылки могут быть видны только техподдержке.

Решение

При выборе определенного типа обращения в Jira (поле каскадного типа) в запросе должны отображаться статьи с Confluence, которые ему соответствуют в отдельном поле с wiki разметкой.

Статья при успешном использовании выбирается как актуальная с помощью отметки чекбокса.

При решении задачи, если оно не описано в прикрепленной статье, должна создаваться задача в Jira с типом «Документация», связанная с текущим запросом.

Реализация

Шаг 1: подготовка

  1. Создаем поле Text Field (multi-line) с wiki разметкой — Links.
  2. Создаем поле типа Select List (cascading) — «Тип обращения».

    Для примера используем следующие значения:

    • ACCOUNT
    • HARDWARE
  3. Заготовим лейблы для статей, которыми будем связывать статьи на Confluence с запросами в Jira:
    • Изменение членства в группах AD — officeit_jira_изменение_членства_в_группах_ad
    • Подписка/отписка от рассылки — officeit_jira_подписка_отписка_от_рассылки
    • Предоставление доступа к папке — officeit_jira_предоставление_доступа_к_папке
    • Сброс пароля от доменной УЗ — officeit_jira_сброс_пароля_от_доменной_уз
    • Сброс пароля от почты — officeit_jira_сброс_пароля_от_почты
    • Выдача временного оборудования — officeit_jira_выдача_временного_оборудования
    • Выдача новой техники — officeit_jira_выдача_новой_техники
    • Замена жесткого диска и установка системы с нуля — officeit_jira_замена_жесткого_диска_и_установка_системы_с_нуля
    • Замена жесткого диска с переносом информации — officeit_jira_замена_жесткого_диска_с_переносом_информации
    • Замена неисправного/устаревшего оборудования — officeit_jira_замена_неисправного_устаревшего_оборудования

    Далее необходимо создать статьи на Confluence, проставить им лейблы.

  4. Подготавливаем workflow.

    Тип обращения будем заполнять при создании.

    Links добавляем на отдельный экран и помещаем на переход в закрыть (в примере переход называется «Check actual Links»), запоминаем id перехода (необходимо в дальнейшем для настройки js).

Шаг 2: MyGroovy post-function (добавляем статьи в запрос)

/*
Пояснение: Тип обращения — customfield_40001
Links — customfield_50001
*/ /*
Указываем куда, под кем и как будем подключаться.
*/ def usr = "bot"
def pas = "qwerty"
def url = "https://confluence.ru"
def browse = "/pages/viewpage.action?pageId=" /*
Добавляем методы
*/ def updateCustomFieldValue(issue, Long customFieldId, newValue) { def customField = ComponentAccessor.customFieldManager.getCustomFieldObject(customFieldId) customField.updateValue(null, issue, new ModifiedValue(customField.getValue(issue), newValue), new DefaultIssueChangeHolder()) return issue
}
def getCustomFieldObject(Long fieldId) { ComponentAccessor.customFieldManager.getCustomFieldObject(fieldId)
}
def parseText(text) { def jsonSlurper = new JsonSlurper() return jsonSlurper.parseText(text)
}
def getCustomFieldValue(issue, Long fieldId) { issue.getCustomFieldValue(ComponentAccessor.customFieldManager.getCustomFieldObject(fieldId))
} /*
Указываем скрипту, как соотносить типы обращения с лейблами.
*/ def getLabelFromMap(String main, String sub){ def mapLabels = [ "ACCOUNT": [ "Изменение членства в группах AD" :["officeit_jira_изменение_членства_в_группах_ad"], "Подписка/отписка от рассылки" :["officeit_jira_подписка_отписка_от_рассылки"], "Предоставление доступа к папке" :["officeit_jira_предоставление_доступа_к_папке"], "Сброс пароля от доменной УЗ" :["officeit_jira_сброс_пароля_от_доменной_уз"], "Сброс пароля от почты" :["officeit_jira_сброс_пароля_от_почты"] ], "HARDWARE": [ "Выдача временного оборудования" :["officeit_jira_выдача_временного_оборудования"], "Выдача новой техники" :["officeit_jira_выдача_новой_техники"], "Замена жесткого диска и установка системы с нуля":["officeit_jira_замена_жесткого_диска_и_установка_системы_с_нуля"], "Замена жесткого диска с переносом информации":["officeit_jira_замена_жесткого_диска_с_переносом_информации"], "Замена неисправного/устаревшего оборудования":["officeit_jira_замена_неисправного_устаревшего_оборудования"] ] ] def labels = mapLabels[main][sub] def result = "" if(!labels){ return "" } for (def i=0;i<labels.size;i++){ if(i<labels.size-1){ result += "\"" +labels[i]+ "\"," }else{ result += "\"" +labels[i]+ "\"" } } result = URLEncoder.encode(result, "utf-8") return result
} /*
Берем значение поля — тип обращения.
*/ def wikiLinkFieldId = 50001L
def requestTypeFieldValue = getCustomFieldValue(issue, 40001) if(!requestTypeFieldValue){ return "required field is empty"
} def mainType = requestTypeFieldValue.getAt(null).toString()
def subType = requestTypeFieldValue.getAt('1').toString() /*
Получаем необходимые для запроса лейблы, формируем ссылку для итоговой записи в виде: [TEST изменение сетевых доступов 1 (Изменение членства в группах AD)|https://confluence.ru/pages/viewpage.action?pageId=500001].
*/ String labels = getLabelFromMap(mainType,subType) if(labels==""){ return "no avalible position on LabelMap"
} def api = "/rest/api/content/search?cql=label%20in(${labels})"
def URL = (url+api) def wikiString = "" def resp = "curl -u ${usr}:${pas} -X GET ${URL}".execute().text
def result = parseText(resp)
def ids = result.results.id
def title = result.results.title for (def i=0;i<ids.size;i++){ wikiString += "[${title[i]}|${url+browse+ids[i]}]\n"
} updateCustomFieldValue(issue,wikiLinkFieldId,wikiString)
return "Done"

Шаг 3: JS-скрипт

/*
Пояснение: Переход — Check actual Links
id перехода — 10
Links — customfield_50001
*/ (function($){ /* Вначале объявляем переменные, с которыми будем работать, прячем ненужное от посторонних глаз и делаем проверку что код будет выполняться для нужного нам перехода. */ var buttonNewArticle = 'Необходима новая статья'; var buttonDeleteUnchecked = 'Сохранить отмеченные'; var buttonNewArticleTitle = 'Автоматически будет создан таск на новую статью'; var buttonDeleteUncheckedTitle = 'Все неотмеченные статьи будут удалены.'; var avalibleTransitions = [10]; var currentTransition = parseInt(AJS.$('.hidden input[name^="action"]').val()); if(avalibleTransitions.indexOf(currentTransition)==-1){ console.log('Error: transition ' + currentTransition + ' is not avalible'); return; } var customFieldId = 50001; var labelTxt = 'Выберите актуальные статьи'; var idname = 'cblist'; var checkboxCounter = 'cbsq'; var text = '<div class="field-group"><label for="'+idname+'">' + labelTxt +'</label><div id="'+idname+'"></div></div>' AJS.$('.field-group label[for^="customfield_'+customFieldId+'"]').parent().hide(); AJS.$('.field-group label[for^="comment"]').parent().hide(); $('.jira-dialog-content div.form-body').prepend(text); /* Далее пишем следующие функции: */ /* renameButtonNeedNewArticle и renameButtonDeleteUnchecked — меняем кнопку « Закрыть» в зависимости от того выбраны ли статьи или нужно создать новую addCheckbox — рисуем чекбокс напротив каждой статьи. */ function arrayToString(arrays) { return arrays.join('\n'); } function renameButtonNeedNewArticle() { $('#issue-workflow-transition-submit').val(buttonNewArticle); $('#issue-workflow-transition-submit').attr("title",buttonNewArticleTitle); } function renameButtonDeleteUnchecked() { $('#issue-workflow-transition-submit').val(buttonDeleteUnchecked); $('#issue-workflow-transition-submit').attr("title",buttonDeleteUncheckedTitle); } function addCheckbox(array) { var value = array.join('|'); var name = array[0].replace('[',''); var link = array[1].replace(']',''); var container = $('#'+idname); var inputs = container.find('input'); var id = inputs.length+1; $('<input />', { type: 'checkbox', id: checkboxCounter+id, value: value }).appendTo(container); $('<label />', { for: checkboxCounter+id, text: ' ' }).appendTo(container); $('<a />', { href: link, text: name,target: "_blank" }).appendTo(container); $('<br>').appendTo(container); } /* Меняем отображение при загрузке экрана на то, что нам нужно: */ renameButtonNeedNewArticle(); $(document).ready(function() { var val = AJS.$('#customfield_'+customFieldId+'').val(); AJS.$('#customfield_'+customFieldId+'').val(''); if(val==""){return;} var i = val.split('\n'); i.forEach(function( index ) { if(index == ""){return;} var link = index.split('|'); addCheckbox(link); }); }); /* Отслеживаем выбранные чекбоксы и формируем итоговое значение для поля Links. */ $('#'+idname+' input[type="checkbox"]').change(function() { var prevalue = []; AJS.$('#'+idname+' input:checkbox:checked').each(function(){ prevalue.push(this.value); }); AJS.$('#customfield_'+customFieldId+'').val(arrayToString(prevalue)); if(prevalue.length<1){ renameButtonNeedNewArticle(); }else{ renameButtonDeleteUnchecked(); } });
})(AJS.$);

Так выглядит наш переход до обработки JS.

Так выглядит переход после обработки.

И так, если выбрана одна или несколько статей.

После выполнения перехода поле Links будет перезаписано новым значением.

Шаг 4: MyGroovy post-function (создаем запрос на новую статью)

На переходе Check actual Links пишем скрипт, который создает запрос с типом «Документация», если в поле Links нет значений.

В заключение

Эти решения не появились бы без активного участия коллег — в первую очередь тех, кто активно пользуется готовыми инструментами или сталкивается в своей работе с задачами, которые нужно автоматизировать. Часто оказывается, что интересная задача — это уже половина решения: далее нужно лишь подобрать инструмент, который наиболее эффективно, просто и легко (для конечного пользователя) удовлетворяет поставленным запросам. Теперь, возможно, у вас появились вопросы и предложения, которые могли бы сделать представленные плагины ещё лучше — пишите в комментариях.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»