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

И так. Началось все с достаточно невинной просьбы архитектора моего проекта - "Денчик, смотри, нужно взять все задачи, сделанные в этом спринте - то есть с версии 0.0.1.01 до версии 0.0.2.01 и выписать в статью".

Ну... подумал я...полез в жиру и стал думать - как же это сделать? И начал я просто копировать номер задачи и ее заголовок в текстовик. Очень неэффективно, и с огромной погрешностью. Что же делать?

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

#!/usr/bin/env bash

# Переменные, хранящие начальную и конечную версии, а также название продукта
RELEASE_VERSION_FROM="$1"
RELEASE_VERSION_TO="$2"
RELEASE_PRODUCT="$3"

# Проверка наличия аргументов
if [[ -z "$1" && -z "$2" && -z "$3" ]]
then
    echo 'No version or product name provided' # Вывод сообщения об отсутствии версии или названия продукта
    exit # Завершение работы скрипта
fi

# Цикл для обработки каждого подкаталога в текущем рабочем каталоге
for entry in $(ls -d */)
do
  echo "$entry" # Вывод имени текущего обрабатываемого подкаталога
  cd "$entry" # Переход в текущий подкаталог
  git pull # Извлечение изменений из удаленного репозитория в текущую ветку

  # Вывод списка коммитов между заданными версиями, отфильтрованных по названию продукта
  # Результат отформатирован так, чтобы выводить только идентификаторы коммитов, соответствующие шаблону RELEASE_PRODUCT-число
  git log "$1".."$2" --format=%s | grep -i '^'$RELEASE_PRODUCT | sed -r 's/('$RELEASE_PRODUCT'-[0-9]+).*/\1/' | sort | uniq

  cd .. # Возврат на уровень выше
done

Работает этот скрипт безумно просто - он находится на одном уровне с папками-репозиторями. До его исполнения я вывожу git tag, получаю список версий и запускаю скрипт командой:

sh test.sh v.0.01.01 v.0.0.1.01 projectname

Где:

v.0.0.1.01 - Номер первой версии

v.0.0.2.01 - Номер конечной версии

projectname - имя проекта (тег в Jirа которым же помечается и кормит)

Этот скрипт, как я писал выше, должен находится на одном уровне с папками-репозиторями проекта. В качестве примера привожу проект с тремя приложениями - app, printer, ui:

├── app
├── printer
├── ui
└── main.sh

После исполнения этого скрипта я получаю примерно следующий вывод:

app/
projectname-426
projectname-432
projectname-453
projectname-471
printer/
projectname-352
projectname-369
ui/
projectname-321
projectname-420
projectname-422
projectname-425
projectname-431

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

Вот так вот ручками я нахожу задачку в джире
Вот так вот ручками я нахожу задачку в джире

В целом схема рабочая, но первый вопрос, которым я задался - "А зачем мне вручную вводить git tag, если это может делать скрипт!"
Тут появилась идея разработки более интерактивно реализации скрипта, за что в последствии я получил оплеух от архитектора, но мне все равно нравится подобное исполнение.

И так. Я отказался от bash в пользу Python и тут началась разработка полноценного и функционального скрипта. Рассмотрим уже написанный скрипт более подробно.

Я поставил задачу запихнуть весь функционал в один файл в угоду удобства запуска - удобно закинуть один скриптик в папку с репозиториями, запустить и получить вывод. Очень не хотелось плодить модули, хотя такой вариант рассматривался.

Первая функция - инициализация работы с апи Jira. Это нужно для того, что бы скрипт, получив номер коммита сам запросил у джиры заголовок задачи (ну и любые другие данные)

class JiraAPI:
    def __init__(self, jira_url, username, password):
        # Инициализация JiraAPI с URL Jira, логином и паролем
        self.jira_url = jira_url
        self.username = username
        self.password = password
        self.auth = (self.username, self.password)  # Создание кортежа для аутентификации
        self.headers = {
            "Content-Type": "application/json"  # Установка типа содержимого
        }

    def get_issue_summary(self, issue_key):
        # Получение краткого описания задачи по ключу задачи
        try:
            issue_url = f"{self.jira_url}/rest/api/2/issue/{issue_key}"  # Сборка URL для запроса задачи
            response = requests.get(issue_url, headers=self.headers,
                                    auth=self.auth)  # Выполнение GET-запроса с аутентификацией

            if response.status_code == 200:  # Проверка успешного ответа
                issue_data = response.json()  # Получение данных задачи
                return issue_data['fields']['summary']  # Возврат краткого описания задачи
            else:
                return None
        except Exception as e:  # Обработка исключений
            return None


jira_url = "https://jira.com"  # Захардкодим URL Jira

# Получение логина и пароля от пользователя
username = input("Введите ваш логин JIRA: ")
password = input("Введите ваш пароль Jira: ")


def retrieve_issue_summary(issue_key):
    jira = JiraAPI(jira_url, username, password)
    issue_summary = jira.get_issue_summary(issue_key)
    return issue_summary

Здесь вы должны заменить jira_url на ссылку на свою джиру.

Так же я реализовал пользовательский ввод авторизационных данных. API джиры позволяет логинится не только по «логопасу» но и по токены, но так как моим скриптом пользуются разные коллеги с разных проектов, гораздо проще для использования именно такая реализация. Всю полезную и нужную документацию по работе с API джиры вы можете найти на официальном сайте.

Далее идет основной код программы:

def list_repository_tags(repo_directory):
    # Получение списка тегов в репозитории
    os.chdir(repo_directory)  # Изменение директории на переданный репозиторий
    tag_output = subprocess.check_output(["git", "tag"], text=True)  # Получение списка тегов
    os.chdir(os.path.pardir)  # Возврат на уровень выше
    return tag_output  # Возвращение списка тегов


class GitCommitExtractor:
    def __init__(self):
        self.RELEASE_VERSION_TO = None
        self.RELEASE_VERSION_FROM = None
        self.RELEASE_PRODUCT = input("Введите код продукта: ")  # Продукт (префикс) для поиска в коммитах
        self.unique_commits = {}  # Словарь для хранения уникальных коммитов в каждом репозитории

    def get_versions_from_user(self):
        # Получение версий от пользователя
        self.RELEASE_VERSION_FROM = input("Введите изначальную версию (из списка выше): ")
        self.RELEASE_VERSION_TO = input("Введите конечную версию (из списка выше): ")

        if not self.RELEASE_VERSION_FROM or not self.RELEASE_VERSION_TO:
            print('Введенная версия отсутствует. Выход...')
            exit()

    def process_repositories(self):  # Функция для обработки репозиториев
        current_directory = os.getcwd()  # Получение текущего рабочего каталога

        # Перебор всех элементов в текущем каталоге
        for entry in os.listdir(current_directory):
            if os.path.isdir(entry):  # Проверка, является ли элемент директорией
                repo_directory = os.path.join(current_directory, entry)

                if os.path.exists(os.path.join(repo_directory, '.git')):
                    # Получение списка тегов и вывод названия репозитория
                    tags = list_repository_tags(repo_directory)
                    print(f'Репозиторий: {entry}')
                    print(f'Версии:\n{tags}')

                    # Добавление информации о репозитории и его тегах в словарь
                    if entry not in self.unique_commits:
                        self.unique_commits[entry] = {"tags": tags, "commits": set()}

        # Получение версий от пользователя
        self.get_versions_from_user()

        # Обработка коммитов в каждом репозитории
        for repo, info in self.unique_commits.items():
            self.process_repository(repo, info["tags"])

        # Вывод уникальных коммитов для каждого репозитория
        self.print_unique_commits()

    def process_repository(self, repo_directory, tags):
        os.chdir(repo_directory)
        # Обновление репозитория с помощью git pull & git fetch
        subprocess.run(["git", "fetch"])
        subprocess.run(["git", "pull"])

        # Проверка существования указанных версий в репозитории
        if not self.version_exists(self.RELEASE_VERSION_FROM, tags):
            print(f"Error: Version {self.RELEASE_VERSION_FROM} not found in {repo_directory}.")
        elif not self.version_exists(self.RELEASE_VERSION_TO, tags):
            print(f"Error: Version {self.RELEASE_VERSION_TO} not found in {repo_directory}.")
        else:
            # Извлечение и сохранение коммитов в указанном диапазоне версий
            self.extract_commits(repo_directory)

        os.chdir(os.path.pardir)

    def version_exists(self, version, tags):
        # Проверка существования версии в списке тегов
        return version in tags

    def extract_commits(self, repo_directory):
        # Функция для извлечения и сохранения коммитов с номерами задач

        # Получение вывода команды git log для указанного диапазона версий
        log_output = subprocess.check_output(
            ["git", "log", f"{self.RELEASE_VERSION_FROM}..{self.RELEASE_VERSION_TO}", "--format=%s"], text=True)

        # Разделение вывода на отдельные сообщения коммитов
        commit_messages = log_output.split('\n')

        # Перебор каждого сообщения коммита
        for message in commit_messages:
            if message.lower().startswith(self.RELEASE_PRODUCT.lower()):  # Проверка на соответствие продукту
                task_number = message.split('-')[1].split()[0]  # Извлечение номера задачи
                task_prefix = self.RELEASE_PRODUCT + '-' + task_number  # Сборка префикса задачи
                # Добавление коммита в множество коммитов репозитория
                self.unique_commits[repo_directory]["commits"].add(task_prefix)

    def print_unique_commits(self):
        # Вывод уникальных коммитов для каждого репозитория
        for repo, info in self.unique_commits.items():
            print(f'\n\nРепозиторий: {repo}')
            print("Коммиты:")
            for commit in info["commits"]:
                commit = commit[:-1]
                issue_summary = retrieve_issue_summary(commit)
                print(commit, issue_summary)


if __name__ == "__main__":
    git_commit_extractor = GitCommitExtractor()
    # Запуск программы и обработка репозиториев
    git_commit_extractor.process_repositories()

Я постараться все максимально закомментировать на русском языке, так что думаю тут вопросов возникнуть не должно.

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

├── app
├── printer
├── ui
└── main.py

Запускаем его без каких либо флагов - python3 main.py и видим поля для пользовательского ввода

Введите ваш логин JIRA: my_jira_login
Введите ваш пароль Jira: my_jira_password
Введите код продукта: projectname

После заполнения полей сразу же происходит вывод git tag для каждого репозитория и снова предложение пользовательского ввода

Репозиторий: app
Версии:
v.0.0.2.01
v.0.0.2.02
v.0.0.2.04
v.0.0.3.01
v.0.0.4.01
v.0.1.0.01
v.0.1.0.04
v.0.1.0.05
v.0.1.0.07
v.0.2.0.01


Репозиторий: printer
Версии:
v.0.0.2.01
v.0.0.2.02
v.0.0.2.04
v.0.0.4.01
v.0.1.0.01
v.0.1.0.04
v.0.2.0.01

Репозиторий: ui
Версии:
v.0.0.2.01
v.0.0.2.02
v.0.0.2.04
v.0.0.4.01
v.0.1.0.01
v.0.1.0.04
v.0.2.0.01

Введите изначальную версию (из списка выше): v.0.1.0.01
Введите конечную версию (из списка выше): v.0.2.0.01

В конечном итоге видим вывод всех коммитов сделанных в рамках данного релиза, подписанных заголовком задачи, взятом из Jira:

Репозиторий: app
Коммиты:
projectname-426 Добавить поля input_date в таблицу document
projectname-432 Доработка внешнего API
projectname-453 Доработка раздела "Редактирование дела"
projectname-471 Интеграция с принтером (projectname-352)

Репозиторий: printer
Коммиты:
projectname-352 Доработка приложения Printer 
projectname-369 Доработка Печатной формы

Репозиторий: ui
Коммиты:
projectname-321 Перенос кнопки "Изменить"
projectname-420 Валидация полей на главной странице
projectname-422 Реализация предзаполненности полей на главной странице
projectname-425 Доработка экранной формы главной страницы
projectname-431 Создание страницы для принтера (projectname-352)

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

Коммит номер: projectname-420
  Описание задачи: Валидация полей на главной странице
  Приоритет задачи: 1
  Тип задачи: Баг
  Автор коммита: Denis Kirillov
  Исполнитель задачи: Кириллов Денис Владимирович:

Номер и автор коммита берется из гита.
Описание, приоритет, тип и исполнитель задачи из джиры.

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


  1. GospodinKolhoznik
    28.10.2023 08:30

    Я отказался от bash в пользу Python

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


    1. KirillovDV Автор
      28.10.2023 08:30

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


  1. Menya_zastavili
    28.10.2023 08:30
    +2

    У нас все проще: мы выстроили систему задач в джире, определили какие сущности являются единицами релиза, какие не являются. И пользуемся сущностью релиз. Когда определяем, что должно войти в релиз - крепим к каждой задаче версию релиза. На выходе у нас есть список релизов с датами выпуска и кратким описанием на вьюхе. Если провалиться внутрь конкретного релиза можно увидеть список задач, в которые тоже можно провалиться, посмтореть полную историю разработки и прикрепленную документацию. Там же в релизе можно в текстовом виде посмотреть, что вошло в релиз - задачи группируются по типам


    1. KirillovDV Автор
      28.10.2023 08:30

      Как идея - не плохо, но на стадии зарождения проекта у нас была заложена немного другая релизная политика. Отсюда и появилась нужда в подобном решении.
      Кстати описанная вами схема реализована в новой версии моего скрипта, но, так сказать, от обратного. После исполнения скрипт сам проставляет в трекере задач версию релиза в рамках которой была сделана задача. В следствии можно будет увидеть прям на вебморде трекера сделанные в релиз задачи)


  1. Noradan
    28.10.2023 08:30

    У атлассиан есть библиотека под питон. Хочешь - получай список задач по коммиту, хочешь - по задаче забирай/модифицируй инфу в джире.

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

    А вы изобрели велосипед, извините, дёргая апи напрямую.


    1. KirillovDV Автор
      28.10.2023 08:30

      Спасибо за ваш комментарий. Я не считаю, что в данном случае я изобрел велосипед. Мне такой подход удобен. Я обязательно изучу предложенную вами библиотеку. Спасибо