company_banner

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



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

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

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

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

    Запрос от «экскурсоводов»


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

    • 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: ограничиваем создание экскурсий и добавляем баннер с ссылкой на календарь.

    Чтобы этого добиться, потребуется 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, записывать в ответственных за этап автора перехода.

    Реализация


    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 нет значений.

    В заключение


    Эти решения не появились бы без активного участия коллег — в первую очередь тех, кто активно пользуется готовыми инструментами или сталкивается в своей работе с задачами, которые нужно автоматизировать. Часто оказывается, что интересная задача — это уже половина решения: далее нужно лишь подобрать инструмент, который наиболее эффективно, просто и легко (для конечного пользователя) удовлетворяет поставленным запросам. Теперь, возможно, у вас появились вопросы и предложения, которые могли бы сделать представленные плагины ещё лучше — пишите в комментариях.
    • +33
    • 5,4k
    • 5
    Mail.ru Group
    1 049,83
    Строим Интернет
    Поделиться публикацией

    Комментарии 5

      0

      Года два назад Jira не поддерживала markdown разметку. Всё еще не завезли?

        +1
        скорее года 2 назад она уже давно отказалась от маркдауна, как и конфлюенс
        +1
        Спасибо за JS Includer вместе со Script Runner — самый востребованный плагин
          0

          Спасибо за статью. Подскажите, а что вы используете для организации ведения открытых вопросов по постановкам? Для возможности централизованного контроля скорости ответов и возможностью уточнения и переписки по вопросу?

            0
            По разному, нет единого подхода, все зависит от потребностей заказчика
            В большинстве своем, обходимся штатными средствами Jira

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое