Мы в Mail.ru Group вкладываем много сил в развитие продуктов компании Atlassian и, в частности, Jira. Благодаря нашим усилиям свет увидели плагины My Groovy, JS Includer, My Calendar, My ToDo и другие. Все эти плагины мы развиваем и активно используем внутри компании.
Мы получаем массу запросов от смежных подразделений по внедрению новых фич. Иногда это выливается в новые плагины, но чаще мы решаем поставленные задачи используя уже существующие плагины, так как большинство повседневных задач ими легко покрываются.
Для проведения экскурсий в офисе нужно было предусмотреть создание запросов с проверкой пересекающихся экскурсий. Для тестировщиков — сделать механизм отслеживания этапов тестирования с ответственным за выполнение. Техподдержка хотела получить автоматический доступ к базе знаний.
Сегодня я расскажу, как путем комбинирования плагинов удалось решить эти задачи.
Запрос от «экскурсоводов»
Инструменты:
- My Calendar
- JS Includer
Проблема
В офисе Mail.ru Group много «экскурсоводов», которые договариваются с гостями и затем ставят задачи на АХО. Иногда случается так, что несколько экскурсий могут образоваться в одно и тоже время — тогда по офису одновременно ходят несколько групп, либо одному экскурсоводу отказывают, и он идет передоговариваться с гостями.
Решение
- Появление в задаче «слотов» (даты и времени из набора свободных вариантов) для выбора при создании заявки на экскурсию На день — 3 слота. Например:
- 9:00-10:00
- 17:30-18:30
- 20:00-21:00
Если слот был выбран в другой задаче, нельзя предлагать его для выбора в новой. Также нужна возможность убрать слоты из выбора руками (в случае, например, когда экскурсии в офисе невозможны в принципе).
- Появление календаря, формируемого из свободных и занятых слотов, который можно расшарить на экскурсоводов.
Реализация
Шаг 1: добавляем необходимые поля на экран создания запроса.
Для этого создадим поле «Дата» типа Date и поле «Время экскурсии» типа Radiobutton для выбора одного значения из 3 вариантов (9:00-10:00; 17:30-18:30; 20:00-21:00).
Шаг 2: создаем календарь.
Делаем новый календарь. Нацеливаем через JQL его на наш проект с экскурсиями,
указываем Event start созданное ранее поле «Дата», а так же добавляем в отображение созданное ранее поле «Время экскурсии».

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

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

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

Когда есть 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()) { $('input:radio[name=customfield_52500]').closest('.group').hide(); } /* Иначе берем значение из поля даты и переводим в удобный для подстановки в 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.$);
Запрос от тестировщиков
Инструмент:
- My Groovy
Проблема
В запросе нужно настроить отображение этапов тестирования с указанием ответственного за задачу сотрудника. Должно быть видно, что этап еще не завершен, либо этап завершен (и кто его проводил).
Решение
Настроить поле типа scripted field на отображение этапов тестирования и связать с workflow, записывать в ответственных за этап автора перехода.
Реализация
- Создаем поле «Ход выполнения» типа scripted field.
- Создаем поля типа UserPicker, соответствующие этапам тестирования.
Для примера определим следующие этапы и создадим поля UserPicker с теми же названиями:
- Базовая информация собрана
- Локализовано
- Логи собраны
- Воспроизведено
- Ответственный найден
- Настраиваем workflow так, чтобы на переходах заполнялись ответственные.
Например переход «Локализовано» записывает currentUser в поле UserPicker «Локализовано».
- Настраиваем отображение при помощи 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: подготовка
- Создаем поле Text Field (multi-line) с wiki разметкой — Links.
- Создаем поле типа Select List (cascading) — «Тип обращения».
Для примера используем следующие значения:
- ACCOUNT
- HARDWARE
- Заготовим лейблы для статей, которыми будем связывать статьи на 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, проставить им лейблы.
- Подготавливаем 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 нет значений.
В заключение
Эти решения не появились бы без активного участия коллег — в первую очередь тех, кто активно пользуется готовыми инструментами или сталкивается в своей работе с задачами, которые нужно автоматизировать. Часто оказывается, что интересная задача — это уже половина решения: далее нужно лишь подобрать инструмент, который наиболее эффективно, просто и легко (для конечного пользователя) удовлетворяет поставленным запросам. Теперь, возможно, у вас появились вопросы и предложения, которые могли бы сделать представленные плагины ещё лучше — пишите в комментариях.
ссылка на оригинал статьи https://habr.com/ru/company/mailru/blog/476446/
Добавить комментарий