Badoo Jira API Client: магия в Jira на PHP

в 15:01, , рубрики: api, jira, open source, php, Блог компании Badoo

Если в строке поиска на Хабре ввести “Jira Badoo”, результаты займут не одну страницу: мы упоминаем её почти везде, потому что она играет важную роль в наших процессах. Причём каждый из нас хочет от нее немножко разного.

Badoo Jira API Client: магия в Jira на PHP - 1

Разработчик, которому пришла задача на ревью, ожидает что в задаче указана ветка, есть ссылки на дифф и лог изменений. Разработчик, который писал код, ожидает увидеть в Jira комментарии по итогам ревью. Тестировщик, который получает задачу после них, хочет видеть результаты тестов и иметь возможность запустить необходимые сборки, не переходя в другие интерфейсы. Продакт-менеджеры вообще хотят создавать десять задач по разработке одновременно, нажав на одну кнопку.

И всё это сегодня доступно и происходит автоматически. Большую часть магии мы реализовали на PHP с помощью постоянно развивающегося API Jira и с использованием её webhook’а. И сегодня мы хотим поделиться с сообществом нашей версией клиента для этого API.

Сначала мы хотели просто рассказать об идеях и подходе, который мы используем, а потом решили, что к такой статье решительно не хватает кода для наглядности. Так появилась open-source-версия Badoo Jira PHP Client. Огромное спасибо ShaggyRatte за то, что помог с ее описанием. И добро пожаловать под кат!

Больше подробностей и контекста

Если вам нужно больше подробностей относительно того, что мы делаем с Jira, их можно найти в других наших статьях:

habr.com/ru/company/badoo/blog/169417
habr.com/ru/company/badoo/blog/433540
habr.com/ru/company/badoo/blog/424655

Что он умеет?

По сути, Badoo Jira PHP Client — это набор готовых классов-обёрток для ответов Jira API, большинство из которых умеют вести себя как ActiveRecord: знают, как получить о себе данные и как их обновить на сервере, поддерживают ленивую инициализацию и кеширование данных на уровне кода. Обёрнуты все сущности Jira, с которыми приходится работать постоянно: Issue, Status, Priority, Changelog, User, Version, Component и т. п. (почти всё, что вы видите в интерфейсе).

Кроме того, Badoo Jira PHP Client предоставляет единую иерархию классов для всех кастомных полей Jira и способен генерировать определения классов для каждого кастомного поля, которое вам потребуется.

$Issue = new BadooJiraIssue('SMPL-1');

$Issue->addComment('Sample comment text');
$Issue->attachFile('kitten.jpeg', 'pretty!', 'image/jpeg');

if ($Issue->getStatus()->getName() === 'Open') {
   $Issue->step('In Progress');
}

$DeveloperField = new ExampleCustomFieldsDeveloper($Issue);
$DeveloperField->setValue('username')->save();

$User = BadooJiraUser::get('username');
$User->watchIssue('SMPL-1');
$User->assign('SMPL-2');

Благодаря этому взаимодействие с API из PHP становится проще и удобнее, а документация к вашей Jira перемещается прямо в код, что позволяет использовать автодополнение в IDE для многих стандартных действий.

Но обо всём по порядку.

Особенности API, с которыми мы столкнулись

Когда мы начали активно использовать API Jira, он был доступен только по протоколу SOAP. Его REST-версия появилась позже, и мы были в числе её первых пользователей. В то время публично доступных REST-клиентов, написанных на PHP, было очень мало. Ещё сложнее было найти что-то, что можно было бы легко интегрировать в нашу кодовую базу, постепенно переехав с SOAP на REST. Так что выбора у нас не было: мы решили продолжить развитие собственного клиента.

Так мы и жили, перетаскивая всякие хаки и костылики из SOAP-клиента и обрастая новыми из-за особенностей REST. В результате у нас выросло несколько весьма жирных классов с кучей дублированного кода и назрела необходимость этот бардак разгрести.

Самым больным местом для нас всегда были кастомные поля: их у нас больше 300 (на момент написания статьи — 338), и это число потихоньку растёт.

Странные сообщения об ошибках

За долгую историю взаимодействия с API мы много разного видели. Большинство сообщений об ошибках адекватные, но попадаются такие, для понимания которых приходится сильно напрягать мозг.

Например, если Jira вдруг распознает в вашем пользователе робота, она начнёт показывать ему капчу. В этом случае при обращениях к API она бессовестно игнорирует заголовок Accept-Encoding: application/json и выдаёт вам HTML. Естественно, что клиент, который ждёт JSON, к такому “здрасьте” может оказаться не готов.

А вот пример работы с кастомным полем:

Badoo Jira API Client: магия в Jira на PHP - 2

Когда вы пишете код и «вот прямо сейчас» его тестируете, понять, что customfield_12664 — это Developers, очень легко. А если такая ошибка вылезает где-нибудь на production (например, из-за того, что кто-то поменял конфигурацию и изменил список допустимых значений Select-поля), часто единственный способ идентифицировать поле — залезть в Jira и узнать название оттуда. Причём в её интерфейсе ID не отображаются — только имена.

Получается, что, когда вы хотите узнать название поля, которое привело к ошибке, и исправить его конфигурацию, сделать это можно либо через запрос к API, либо с помощью каких-то других неочевидных методов: покопавшись в исходном коде страницы, открыв настройки произвольного поля и исправив URL в адресной строке и т. п. Удобным этот процесс не назовёшь, и каждый раз он занимает слишком много времени для столь простой задачи.

Но непонятными именами полей проблемы не ограничиваются. Вот как выглядит взаимодействие с API, если вы ошиблись в структуре данных для обновления поля:

Badoo Jira API Client: магия в Jira на PHP - 3
Badoo Jira API Client: магия в Jira на PHP - 4

Много разных форматов данных

А ещё не стоит забывать о том, что для обновления полей разных типов требуются разные структуры данных.

$Jira->issue()->edit(
    'SMPL-1',
    [
        'customfield_10200' => ['name' => 'denkoren'],
        'customfield_10300' => ['value' => 'PHP'],
        'customfield_10400' => [['value' => 'Android'], ['value' => 'iOS']],
        'security' => ['id' => 100500],
        'description' => 'Just text',
    ],
);

Ответы API для них, разумеется, тоже разные.

Держать это всё в голове получается, только если вы постоянно работаете с API Jira и не отвлекаетесь надолго на решение других задач. Иначе эти особенности вылетают из памяти за пару недель. Кроме того, вам необходимо помнить тип нужного поля, чтобы «скормить» ему правильную структуру. Когда у вас сотни кастомных полей, часто приходится либо искать в коде, где же оно ещё использовалось, либо лезть в админку Jira.

До того как мы написали наш клиент, Stack Overflow и Atlassian Community в вопросах обновления кастомных полей были моими лучшими друзьями. Сейчас эта информация гуглится легко и быстро, но мы переходили на REST API, когда он был ещё достаточно новым и активно дорабатывался: на поиск подходящего cURL-запроса в гугле можно было потратить минут десять, а потом эту кучу скобок надо было распарсить глазами и преобразовать в правильную структуру для PHP, что часто получалось не с первой попытки.

В общем, взаимодействие с кастомными полями — тот процесс, реорганизация которого требовалась нам в первую очередь.

Из чего состоит клиент?

Классы для работы с кастомными полями

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

В итоге мы создали единую иерархию классов для всех кастомных полей. Получилось три слоя:

  1. Общий абстрактный родитель для всех: BadooJiraCustomFieldsCustomField.
  2. По абстрактному классу для каждого типа полей: SelectField, UserField, TextField и т. д.
  3. По классу для каждого конкретного поля: например, Developer или Reviewer.

Эти классы можно писать самостоятельно, а можно создавать автоматически с помощью специального скрипта-генератора (к нему мы ещё вернёмся).
Badoo Jira API Client: магия в Jira на PHP - 5

Благодаря такой структуре, для того чтобы научить код обновлять значение вашего кастомного поля типа Select List (multiple choice), достаточно создать PHP-класс, унаследованный от SelectField. По сути, каждое кастомное поле Jira превращается в обычный ActiveRecord в PHP-коде.

namespace ExampleCustomFields;

class Developer extends BadooJiraCustomFieldsSingleUserField
{
	const ID = 'customfield_10200';
	const NAME = 'Developer';
}

// Всё, класс готов, можно пользоваться!

В этом же классе мы храним информацию о поле: по умолчанию это ID, имя поля и список доступных значений, если он ограничен (например, для Checkbox и Select).

Примеры поля в интерфейсе Jira и соответствующего ему класса

Badoo Jira API Client: магия в Jira на PHP - 6

class IssueFor extends BadooJiraCustomFieldsSingleSelectField
{
   const ID    = 'customfield_10662';
   const NAME  = 'Issue for';

   /* Available field values. */
   const VALUE_BI = 'BI';
   const VALUE_C_C = 'CC++';
   const VALUE_HTML_CSS = 'HTMLCSS';
   const VALUE_JS = 'JS';
   const VALUE_OTHER = 'Other';
   const VALUE_PHP = 'PHP';
   const VALUE_TRANSLATION = 'Translation';

   const VALUES = [
       self::VALUE_BI,
       self::VALUE_C_C,
       self::VALUE_HTML_CSS,
       self::VALUE_JS,
       self::VALUE_OTHER,
       self::VALUE_PHP,
       self::VALUE_TRANSLATION,
   ];

   public function getItemsList() : array
   {
       return static::VALUES;
   }
}

Получается этакая документация именно к вашей Jira, расположенная прямо в PHP-коде. Когда она настолько близко, это очень удобно и существенно ускоряет разработку, уменьшая при этом количество ошибок.

Кроме того, сообщения об ошибках становятся более понятными: вместо ничего не говорящего ‘customfield_12664’ вылетает, например, что-то такое:

Uncaught BadooJiraExceptionCustomField: User 'asdkljfh' not found in Jira. Can't change 'Developer' field value.

Классы для работы с системными объектами

В Jira много данных со сложной структурой: например, системные поля Status и Security, линки между задачами, пользователи, версии, вложения (файлы).

Их мы тоже обернули в классы:

// статус задачи
$Status = $Issue->getStatus();

$Status->getName();
$Status->getId();

// changelog задачи
$History = BadooJiraIssueHistory::forIssue('SMPL-1');
$seconds_in_status = $History->getTimeInStatus('Open');

// пользователь Jira
$User = new BadooJiraUser('sampleuser');
$User->assign('SMPL-1');

Такие обёртки дают вашей IDE возможность подсказывать, какие данные доступны и позволяют строго формализовать интерфейсы функций в вашем коде. Мы активно используем type declarations, почти всегда это позволяет видеть ошибку ещё во время написания кода благодаря подсветке IDE. А если ошибку всё-таки пропустили, она вылезет ровно в том месте, где впервые появилась, а не там, где в конце концов уронила ваш код.

Ещё есть статические методы, позволяющие быстро получить объект по какому-то критерию:

$users = BadooJiraUser::search('<pattern>'); // поиск пользователя по login, email или display name

$Version = BadooJiraVersion::byName('<project>', '<version name>'); // поиск версии по имени в конкретном проекте
$components = BadooJiraComponent::forProject('<project>'); // полный список компонентов в проекте

Эти методы подчиняются общим правилам, чтобы их было легко найти:

  • ::search(), если вам нужно найти объекты по нескольким полям: BadooJiraIssue::search() ищет задачи с помощью JQL, где вы можете указать много критериев поиска, а BadooJiraUser::search() ищет пользователя одновременно по ‘name’ (логину), ‘email’ и ‘displayName’ (тому имени, которое отрисовывается в вебе);
  • ::by*(), если нужно получить объект не по ID, а по какому-то другому критерию: BadooJiraUser::byEmail() ищет пользователя по его email-адресу;
  • ::for*() ищет все объекты, связанные с чем-то: BadooJiraVersion::forProject
    отдаёт все версии из конкретного проекта;
  • ::fromStdClass() создаст объект из сырых данных, имеющих подходящую структуру, но полученных не из API, а, например, из webhook’а: в теле POST-запроса Jira отправляет JSON с разной информацией о событии, в том числе с телом задачи, включающем все поля. На основе этих данных можно создать объект BadooJiraIssue и пользоваться им как обычно.

Класс BadooJiraIssue

Мне кажется, следующий скриншот PhpStorm достаточно красноречив сам по себе:

Badoo Jira API Client: магия в Jira на PHP - 7

По сути, объект BadooJiraIssue связывает всё, что было описано выше, в единую систему. Он хранит в себе всю информацию о задаче, в нём есть методы для быстрого доступа к наиболее часто используемым данным, перевода задачи между статусами и т. д.

Для создания объекта в простейшем случае достаточно знать только ключик задачи.

Создаём объект, имея в кармане только ключ задачи

$Issue = new BadooJiraIssue('SMPL-1');

Можно также использовать любой фрагментарный набор данных. Например, информация о линке между задачами, прилетающая из API, содержит только несколько полей: id, summary, status, priority и issuetype. BadooJiraIssue позволяет собрать объект из этих данных так, чтобы отдавать их сразу, а для всего остального обращаться к API.

Создаём объект, кешируя значения для некоторых полей

        $IssueFromLink = BadooJiraIssue::fromStdClass(
            $LinkInfo,
            [
                'id',
                'key',
                'summary',
                'status',
                'priority',
                'issuetype',
            ]
        );

Это достигается за счёт ленивой инициализации и кеширования данных в коде. Такой подход особенно удобен тем, что вы можете обмениваться в своём коде только объектами BadooJiraIssue вне зависимости от того, с каким набором полей они были созданы.

Получаем недостающие данные о задаче

$IssueFromLink->getSummary(); // никакого запроса к API, возвращает данные сразу
$IssueFromLink->getDescription(); // запрашивает API и возвращает свежий description

Как мы ходим в API

В API Jira присутствует возможность получить для задачи не все поля, а только необходимые в данный момент: например, только key и summary. Однако мы намеренно не ходим в Jira только за одним полем в getter. В примере выше getDescription() обновит информацию сразу обо всех полях. Поскольку BadooJiraIssue не имеет ни малейшего представления о том, что ещё вам потребуется дальше, выгоднее получить из API сразу всё, раз уж мы всё равно туда пошли. Да, выполнение запроса «получить только description» и запроса «получить все поля по умолчанию» для пары сотен тикетов занимает разное время, но для одного эта разница уже не так заметна.

//Time for single field: 0.40271635055542 (second)
//Time for all default fields: 0.84159119129181 (second)

Из цифр видно, что при получении всего трёх полей (по одному в запросе) выгоднее получить сразу все, а не ходить в API за каждым. Результат этого измерения, на самом деле, зависит от конфигурации Jira и сервера, на котором она работает. От задачи к задаче и от измерения к измерению цифры меняются и Time for all default fields получается стабильно меньше трёх Time for single field, а часто даже меньше двух.

Однако при работе с большим количеством задач разница может измеряться секундами. Поэтому, когда вы знаете, что вам нужны только key и description для 500 тикетов, возможность получить их одним эффективным запросом остаётся в методах BadooJiraIssue::search() и BadooJiraIssue::byKeys().

BadooJiraIssue — вообще про задачи в какой-то абстрактной Jira. Но ваша (как и наша) Jira не абстрактная — в ней есть совершенно конкретный набор кастомных полей и свои workflow. Какие-то из полей и переходов вы используете чертовски часто, так что каждый раз ходить за ними длинными путями не очень удобно. Поэтому BadooJiraIssue можно легко расширять своими собственными методами, специфичными для конкретной конфигурации Jira.

Пример расширения класса методом для быстрого получения кастомного поля

namespace Deploy;

class Issue extends BadooJiraIssue {
    // …

    /**
     * Get ‘Developer’ custom field as object
     */
    function getDeveloperField() : DeployCustomFieldsDeveloper
    {
        return $this->getCustomField(DeployCustomFieldsDeveloper::class);
    }

    // ...
}

Issue Create Request

Создание задачи в Jira — довольно сложная процедура. Когда вы делаете это через веб-интерфейс, вам показывается специальный скрин (Create Screen) с набором полей. Какие-то из них вы можете заполнить просто потому, что хотите, а какие-то обозначены как обязательные для заполнения. При этом Create Screen может быть свой для каждого проекта и даже для разных типов задач в одном проекте. Так что существуют всякие ограничения на значения полей и на саму возможность задать значение поля в процессе создания задачи.

Самое неприятное для разработчиков в этой ситуации то, что эти ограничения распространяются и на API. В последнем есть специальный запрос (create-meta доступен в REST API начиная с версии 5.0), с помощью которого можно получить список настроек полей, доступных при создании задачи. Однако разработчик, которому нужно «вот прям щас сделать простенькую штуку», заморачиваться с этим, скорее всего, не будет.

В итоге у нас происходило так: поскольку запрос на создание задачи может быть достаточно большим, мы часто добавляли в него данные постепенно, а ошибку получали уже при попытке отправить всё в Jira. После этого приходилось искать в коде все места, где в запросе что-то менялось, и долго и нудно пытаться понять, что именно пошло не так.

Поэтому мы и сделали BadooJiraIssueCreateRequest. Он позволяет увидеть ошибку раньше, прямо в том месте, где вы пытаетесь сделать что-то не то: дать полю какое-то кривое значение или изменить поле, которое недоступно. Например, если вы попытаетесь указать задаче компонент, которого не существует в проекте, exception вылетит в том месте, где вы это сделали, а не там, где в конечном итоге отправили запрос в API.

Примерно так выглядит flow работы с CreateRequest

$Request = new BadooJiraIssueCreateRequest('DD', 'Task', $Client);
$Request
   ->setSummary('summary')
   ->setDescription('description')
   ->setFieldValue('For QA', 'custom field with some comments for QA who will check the issue');

$Request->send();

Работа с API напрямую

Набор классов, который описан выше, закрывает большинство потребностей. Однако мы прекрасно понимаем, что большинство — это далеко не все. Поэтому у нас также есть небольшой клиент для работы с API напрямую — BadooJiraRESTClient.

Пример использования клиента

$Jira = BadooJiraRESTClient::instance();
$Jira->setJiraUrl('https://jira.example.com/');
$Jira->setAuth('user', 'password')

$IssueInfo = $Jira->issue()->get('SMPL-1');

Генератор классов для кастомных полей

Чтобы работа с кастомными полями была удобной, для каждого поля необходимо иметь свой собственный класс в коде. У себя мы создавали их вручную по мере необходимости, но перед публикацией клиента решили, что этот подход может быть не очень удобен новым пользователям. Поэтому мы сделали специальный генератор, который умеет ходить в Jira API за списком кастомных полей и создавать для известных ему типов полей классы по шаблонам.

Мы верим, что для большинства задач достаточно использовать CLI-скрипт bin/generate из нашего репозитория. Его можно попросить рассказать о себе через опцию --help/-h:

./bin/generate --help

В простейшем случае для генерации достаточно указать URL вашей Jira, пользователя, его пароль, namespace для классов и директорию, куда класть код:

./bin/generate -u user -p password --jira-url https://jira.mlan --target-dir custom-fields --namespace CustomFields

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

Заключение

Нам нравится то, что у нас получилось. С этой концепцией — свои классы для кастомных полей, обёртки для статусов, версий, пользователей и т. п. — мы живём уже больше года и чувствуем себя прекрасно. Перед публикацией кода мы даже расширили функционал и дописали прекрасные штуки, до которых долгое время не доходили руки, чтобы пользоваться клиентом было ещё удобнее: например, добавили возможность обновлять в Issue несколько полей за один запрос и написали генератор классов для кастомных полей.

На наш взгляд, получилась годная штука, которую однозначно стоит пощупать, чтобы понять, подходит ли она под ваши задачи и требования. Под наши — точно подошла.

Еще раз ссылка: github.com/badoo/jira-client.

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

Автор: Кореневский Денис

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js