Привет, Хабр!

Меня зовут Егор, я руководитель разработки таск-менеджера АИПлан. В комментах к прошлой статье были вопросы про экспорт из Jira на наш аналог, платформу АИПлан.

Мы решили поделиться своим опытом решения проблем, с которыми на этой пути сталкивается идущий. Пойдем по пунктам: проблема – решение.

Эта статья может быть интересна тем, кто сейчас в поисках рабочих костылей, а еще – тем, кто уже решил проблему экспорта по-своему.

Получение пользователей с доступом к проекту

Начнем с того, что у Джиры в принципе нет понятия «член проекта». Принадлежность юзера к проекту определяется правами доступа, а вот админского API, чтобы получить список пользователей с доступом к проекту, просто нет.

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

Отсутствие единого стандарта идентификации

Итак, нам нужно развеять туман над Гангом и уточнить каждого юзера. Однозначного ID пользователя в Жире нет как такового (в облачной Джире, например, используется поле accountID, а в локальной — username). При этом accountId может отсутствовать, если есть username, и наоборот.

Решили методом проверки первого юзера на наличие нужного поля и дальше уже ориентировались на него.

func (c *ImportContext) getJiraUserUsername(user interface{}) string {
	switch v := user.(type) {
	case jira.User:
		if v.Name != "" {
			c.usernamesSearch = true
			return v.Name
		}
		return v.AccountID
	case jira.Watcher:
		if v.Name != "" {
			c.usernamesSearch = true
			return v.Name
		}
		return v.AccountID
	}
	return ""
}

...

// Непосредственно запрос пользователя
req, _ := c.client.NewRequest("GET", fmt.Sprintf("/rest/api/2/user?accountId=%s", accountId), nil)
if c.usernamesSearch {
	req, _ = c.client.NewRequest("GET", fmt.Sprintf("/rest/api/2/user?username=%s", accountId), nil)
}

Следующий неприятный сюрприз: Jira считает почту пользователя приватным полем, а настройкой видимости занимается исключительно админ. Как направить приглашение новым импортированным юзерам?

Решили, проставив почты сотрудникам вручную, через настройки пространства:

Вопрос приоритетов

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

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

Ненадежные вложения

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

Каждый блок представляет из себя span с классом image-wrap. В нем находятся превью картинки и ссылка на ее полный размер. Пример:

<span class="image-wrap" style="">
  <a id="attachmentID_thumb" href="attachmentURL" title="filename.PNG" file-preview-type="image" file-preview-id="attachmentID" file-preview-title="filename.PNG">
    <img src="thumbnailURL" style="border: 0px solid black" />
  </a>
</span>

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

  • Есть только thumbnail. Видимо, Жира сохраняет мелкие картинки без обработки, сразу воспринимая их как превью. Решение: тянем превью и верим в лучшее (опционально можно при этом молиться Вишну, но помогает не всегда).

  • Есть картинка, но без атрибутов. Почему Жира не всегда возвращает file-preview-id, по которому удобно вытягивать метаданные аттачмента? Это древня индусская тайна.
    Решение: парсим attachmentURL – вытягиваем attachmentID – уже по нему работаем.

  • У пикчи нет атрибута ширины. Так происходит, если картинку вставляли без ресайза. В таком случае Jira отрисовывает превью, которое генерит по своим алгоритмам.
    Наше решение: тянем превью, парсим заголовок картинки (спасибо стандартному пакету image и его методу DecodeConfig — не нужно читать всю картинку!) и сохраняем ширину у себя.

  • Некорректный attachmentURL — порой приходят встроенные иконки с кривыми URLами. Решение: игнорируем.

  • Пустой span — тайна, покрытая мраком. Решение: игнорируем.

HTML отлично парсится стандартной библиотекой net/html, никаких сторонних либ не нужно.

Download failed

Еще одна проблема на стороне Жиры – со скачиванием файлов. Организовать нормальный pipe сразу в наш minio чаще всего невозможно, из-за любви локальной Jira обрывать соединения без уточнения причин.
Вот так:

Разраб дуреет с этой ошибки ?
Разраб дуреет с этой ошибки ?

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

Трудности html’а

Тот html, который выдает Джира, в принципе вызывает в памяти индуистские обряды с обязательным использованием курильниц. Например, такой:

<div id="syntaxplugin" class="syntaxplugin">
  <table cellspacing="0" cellpadding="0" border="0">
	<tbody>
	  <tr id="syntaxplugin_code_and_gutter">
	    <td style=" line-height: 1.4em !important; padding: 0em; vertical-align: top;">
	      <pre><span>// заполнитель кода</span></pre>
		</td>
	  </tr>
	  <tr id="syntaxplugin_code_and_gutter">
	    <td style=" line-height: 1.4em !important; padding: 0em; vertical-align: top;">
	 	  <pre><span>код</span></pre>
		</td>
	  </tr>
	  <tr id="syntaxplugin_code_and_gutter">
	    <td style=" line-height: 1.4em !important; padding: 0em; vertical-align: top;">
	      <pre><span>код</span></pre>
	    </td>
	  </tr>
	  <tr id="syntaxplugin_code_and_gutter">
	    <td style=" line-height: 1.4em !important; padding: 0em; vertical-align: top;">
	      <pre><span>код</span></pre>
	    </td>
	  </tr>
	  <tr id="syntaxplugin_code_and_gutter">
	    <td style=" line-height: 1.4em !important; padding: 0em; vertical-align: top;">
	      <pre><span>код</span></pre>
	    </td>
	  </tr>
    </tbody>
  </table>
</div>

Код <pre> хранится очень странно. Выглядит как таблица, каждая строка которой - строчка кода, обернутая в <pre>.
Мы пришли к тому, чтобы вытаскивать все <pre> и склеивать воедино с нормальным переносом строки через /n.
Отдельная боль - табуляции (/n/t), которые крошат отображение. Такие чистим простой регуляркой. Пример:

<p>
   <ul>
    /n/t<li>
  	    Текст
      </li>
    /n/t<li>
  	   Текст
      </li>
  </ul>
<p/>
/n<p>Текст</p>

После всех замен и чисток прогоняем получившийся html через sanitizer bluemonday с правилами ugc (с кастомными настройками под наш редактор) и strict.

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

Послесловие

Это не полный перечень сложностей, конечно. Скорее из серии «самого-самого», краткий перечень того, с чем мы столкнулись. В результате удалось добиться главного: сейчас можно перенести свой проект в АИПлан из Джиры, указав пространство в системе, приоритеты и выбрав блокирующую связь. Без лишних танцев с бубном.

Будет здорово, если в комментах подбросите вопросов или расскажете, как сталкивались с похожими задачами и как их решали. Вдвойне здорово – если кому-то наш опыт поможет.

С праздниками, Хабр!

Комментарии (2)


  1. karrakoliko
    02.01.2025 18:38

    Меня зовут Егор, я руководитель разработки таск-менеджера АИПлан

    Звучно. Осталось продать сервис яндексу, и стать "ЯИплан".

    Простите


  1. sattvadigit
    02.01.2025 18:38

    С начала подумал, что это open source, а оказалось какая-то реклама проприетарщины, очень очень посредственной поделки, да и вообще никак не интересной для разработчиков на go. Идите лучше в рекламные сети и там пиарьтесь. Для сообщества нулевая польза от статьи.