Привет, Хабр! На связи Кирилл Веркин. Вообще, я занимаю в СберМаркете должность Senior QA, но ради большей производительности команды жизнь заставила стать немного кодером.

Эта статья может быть интересна тем, кто замечает, что задачи в команде часто теряются, и хочет автоматизировать процесс напоминалок. Я делюсь кодом, поясняя ключевые моменты для таких же новичков в Go. Мой код написан для сочетания GitLab, Jira и Mattermost (корпоративный мессенджер, которым мы пользуемся в СберМаркете), но подобное решение можно реализовать и с другими сервисами.

Зачем нужен бот

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

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

Если на этом моменте вам показалось, что это точно не входит в работу тестировщика, то доля правды в этом есть :) Но такой я человек, с активной жизненной позицией. Я уже писал на Хабре о том, как повысил инженерную культуру своей команды по модели ТММ, не обладая «властью» тимлида. Прочитать можно вот тут.

Первая версия бота

После мини-исследования проджект выяснила, что мы не единственные, кто столкнулся с такой проблемой, и что внутри компании уже есть код для подобных уведомлений. Я подумал, что воспользоваться работающим ботом не так трудно, Ctrl+C — Ctrl+V, и взял на себя задачу по его внедрению в канал команды на платформе Mattermost.


Изначальный код был написан на Ruby и запускался через Schedules в GitLab. Раз в сутки, по будням, соответствующая Job включалась и присылала в канал табличку с мердж-реквестами. Пара правок — и бот начал работать в канале нашей команды. Я, наивный, почувствовал в себе прогерскую мощь :)

На этом текст мог бы завершиться, но…

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

Таблица, которая приходила в канал. Справа ссылки на мердж-реквесты в GitLab, слева — их авторы
Таблица, которая приходила в канал. Справа ссылки на мердж-реквесты в GitLab, слева — их авторы

Так в игру вступил новый бот.

Рождение нового бота

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

Также бот должен был быть связан со статусами в Jira:

  • Code Review — присылает автору уведомление, что можно переводить задачу в тестирование.

  • Ready for Test — присылает уведомление QA, то есть мне, что можно начинать тестирование.

  • Tested — присылает автору уведомление, что код протестирован и его можно деплоить.

  • Needs Refinement — присылает автору уведомление, что есть баги и требуется доработка.

  • Ready for Deploy — присылает автору уведомление, что задача ждет выхода на продакшен. Это полезно, когда релиз-инженеру нужно дополнительное напоминание от автора, чтобы забрать задачу, или когда разработчики забывают проставить нужные лейблы для выкатки в GitLab.

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

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

В СберМаркете работает менторская система. В ней участвует несколько десятков сотрудников из разных направлений. Каждому присвоен профиль в корпоративной вики — с перечислением скиллов и вопросов, с которыми можно обратиться. Я попросил разработчика из своей команды взять меня в менти, вот тут Лешин профиль на Хабре.

А дальше закрутилось: кипа внутренних материалов, внутренний курс по Go, много парного программирования и менторского терпения. Прошел я и общедоступный курс A Tour of Go: он бесплатный, занимает примерно пять часов. Мне кажется, это хороший способ получить представление о языке.

Непосредственно код

Вот что у нас получилось:
package main
import (
"encoding/json"
  "fmt"
  "notification-bot/utils"
  "os"
  "regexp"
)

const (
  statusCodeReview      = "5"
  statusNeedsRefinement = "6"
  statusReadyForTest    = "7"
  statusTested          = "8"
  statusReadyForDeploy  = "9"
  statusTesting         = "10"
)

var Authors = []string{
  "vasya.ytkin",
  "vasya.pupkin",
  "vasya.ymkin",
}

var HeadersGitLab = map[string]string{"PRIVATE-TOKEN": os.Getenv("GITLAB_TOKEN")}
var HeadersJira = map[string]string{"Authorization": os.Getenv("JIRA_TOKEN")}
var GitlabProjectsUrl = os.Getenv("GITLAB_PROJECTS_URL")}
var GitlabMergeRequestsUrl = os.Getenv("GITLAB_MERGE_REQUESTS_URL")}
var JiraProjectUrl = os.Getenv("JIRA_PROJECT_URL")}
var JiraTaskUrl = os.Getenv("JIRA_TASK_URL")}

func main() {
  table := "| Автор | МР | Ревьюеры |\n|:-------------|:---------------:|:---------------:|\n"
  var showTable bool
  var mr string
  taskIdTemplate := regexp.MustCompile(`[A-Z]+-[0-9]+`)
  taskList, err := getTaskList()
  if err != nil {
     panic(fmt.Sprintf("getTaskList: %s", err.Error()))
  }

  // Цикл перебирает авторов/пользователей
  for i := 0; i < len(Authors); i++ {
     // GetRequest получает данные о МР пользователя
     text, err := utils.GetRequest(GitlabMergeRequestsUrl+Authors[i]+
        "&scope=all&state=opened&page=1&per_page=50&wip=no", HeadersGitLab)
     if err != nil {
        panic(fmt.Sprintf("getRequest: %s", err.Error()))
     }

     //Список МР пользователя
     var data []map[string]any
     // Мапим json в структуру
     err = json.Unmarshal(text, &data)
     if err != nil {
        panic(fmt.Sprintf("json.Unmarshal: %s", err.Error()))
     }

     //Если есть МР
     if len(data) != 0 {
        // Буфер для хранения МР
        var usersMrs string
        //Перебирает МР
        for j := 0; j < len(data); j++ {
           //Получаем список тех, кто должен поставить апрув
           approvers, err := getApprovers(data[j]["project_id"].(float64), data[j]["iid"].(float64))
           if err != nil {
              panic(fmt.Sprintf("getApprovers: %s", err.Error()))
           }

           //Список тех, кто еще не поставил апрув
           reviewers := getReviewers(Authors[i], approvers)

           //Если все поставили апрув
           if reviewers == "" {
              title := data[j]["title"].(string)
              //Получает ID задачи из названия МР
              taskId := taskIdTemplate.FindString(title)
              //Если мы не нашли ID
              if taskId == "" {
                 continue
              }
              status := taskList[taskId]

              //Определяем строку для вывода в зависимости от статуса задачи
              switch status {
              case statusCodeReview:
                 reviewers = fmt.Sprintf("@%s переведи в тестирование задачу "+
                    "[%s](JiraTaskUrl%s)", Authors[i], taskId, taskId)
              case statusNeedsRefinement:
                 reviewers = fmt.Sprintf("@%s нужны уточнения по задаче "+
                    "[%s](JiraTaskUrl%s)", Authors[i], taskId, taskId)
              case statusTested:
                 reviewers = fmt.Sprintf("@%s протестирована задача, можно катить "+
                    "[%s](JiraTaskUrl%s)", Authors[i], taskId, taskId)
              case statusReadyForDeploy:
                 reviewers = fmt.Sprintf("@%s готова к выкатке задача "+
                    "[%s](JiraTaskUrl%s)", Authors[i], taskId, taskId)
              case statusReadyForTest:
                 reviewers = fmt.Sprintf("@kirill.verkin можно тестировать задачу "+
                    "[%s](JiraTaskUrl%s)", taskId, taskId)
              case statusTesting:
                 continue
              default:
                 reviewers = fmt.Sprintf("@%s проверь статус задачи "+
                    "[%s](JiraTaskUrl%s)", Authors[i], taskId, taskId)
              }
           }
           //Запоминаем МР
           mr = fmt.Sprintf("[№-%s](%s)", (data[j]["reference"]).(string), data[j]["web_url"])
           usersMrs += fmt.Sprintf("|  | %s | %s |\n", mr, reviewers)
        }
        // Добавляем в таблицу автора и МР, если есть МР
        if usersMrs != "" {
           //Флаг для отображения таблицы
           showTable = true

           table += fmt.Sprintf("| %s |\n", Authors[i])
           table += usersMrs
        }
     }
  }
  // Определяем, есть ли данные в таблице
  if !showTable {
     table = "#### Если появятся МР в течение дня, напиши в тред"
  }

  // Выводим, если рабочий день
  if utils.IsWorkingDay() {
     err := utils.Send(table, "MR(ы) команды", "gull_scream")
     if err != nil {
        panic(fmt.Sprintf("Send: %s", err.Error()))
     }
  }
}

А теперь по частям!

Для лучшего восприятия кода мы разделили его по функциям. В функции getApprovers() происходит перебор тех, кто должен поставить апрув:

func getApprovers(projectId, iid float64) ([]string, error) {
  var result []string

  text, err := utils.GetRequest(fmt.Sprintf(GitlabProjectsUrl,
     int32(projectId), int32(iid)), HeadersGitLab)
  if err != nil {
     return nil, fmt.Errorf("getRequest: %w", err)
  }

  var data map[string]any
  err = json.Unmarshal(text, &data)
  if err != nil {
     return nil, fmt.Errorf("json.Unmarshal: %w", err)
  }

  rules := data["rules"].([]interface{})

  for i := 0; i < len(rules); i++ {
     rule := rules[i].(map[string]any)
     approvers := rule["approved_by"].([]interface{})

     for j := 0; j < len(approvers); j++ {
        approver := approvers[j].(map[string]any)
        result = append(result, approver["username"].(string))
     }
  }

  return result, nil
}

В функции getReviewers() перебирает авторов, которые не поставили апрув в мердж-реквесте:

func getReviewers(author string, approvers []string) string {
  var reviewers string
Peoples:
  for i := 0; i < len(Authors); i++ {
     if author == Authors[i] {
        continue
     }

     for j := 0; j < len(approvers); j++ {
        if Authors[i] == approvers[j] {
           continue Peoples
        }
     }

     reviewers += fmt.Sprintf("@%s ", Authors[i])
  }

  return reviewers
}

В функции getTaskList() происходит перебор задач в проекте:

func getTaskList() (map[string]string, error) {
  tasks := make(map[string]string)

  jiraReq, err := utils.GetRequest(JiraProjectUrl, HeadersJira)
  if err != nil {
     return nil, fmt.Errorf("getRequest: %w", err)
  }

  var jiraData map[string]any
  err = json.Unmarshal(jiraReq, &jiraData)
  if err != nil {
     return nil, fmt.Errorf("json.Unmarshal: %w", err)
  }

  issuesData := jiraData["issuesData"].(map[string]any)
  issues := issuesData["issues"].([]interface{})
  for i := 0; i < len(issues); i++ {
     item := issues[i].(map[string]any)
     key := item["key"].(string)
     statusId := item["statusId"].(string)
     tasks[key] = statusId
  }

  return tasks, nil
}

Участки кода залогированы, чтобы было понятно, на что смотреть, если возникнет ошибка. Поэтому во всех вызываемых функциях возвращаем ошибки. Например, в функции getApprovers() это выглядит так:

var data map[string]any
err = json.Unmarshal(text, &data)
if err != nil {
  return nil, fmt.Errorf("json.Unmarshal: %w", err)
}

Вот так в главную функцию main зашита паника (непредвиденная ошибка, которая приводит прекращению работы и закрытию Go-программы):

taskList, err := getTaskList()
if err != nil {
  panic(fmt.Sprintf("getTaskList: %s", err.Error()))
}

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

func IsWorkingDay() bool {
  countryCode := isdayoff.CountryCodeRussia

  day, err := isdayoff.New().Today(isdayoff.Params{
     CountryCode: &countryCode,
  })
  if err != nil {
     fmt.Printf("IsWorkingDay: %s", err.Error())

     return true
  }

  return *day != isdayoff.DayTypeNonWorking
}

Общие куски кода вынесли в отдельный пакет, так как в корневом пакете у нас лежат боты и утилиты там мешались.

package utils

import (
  "bytes"
  "encoding/json"
  "fmt"
  "io"
  "net/http"
  "os"

  "github.com/anatoliyfedorenko/isdayoff"
)

В функции Send() происходит отправка сообщения с атрибутами (заголовок, описание, эмодзи) в канал Mattermost:

func Send(text, username, emoji string) error {
  message := map[string]string{
     "text":       text,
     "username":   username,
     "icon_emoji": emoji,
  }

  data, err := json.Marshal(message)
  if err != nil {
     return fmt.Errorf("json.Marshal: %w", err)
  }

  r := bytes.NewReader(data)
  _, err = http.Post(os.Getenv("MATTERMOST_HOOK_URL"),
     "application/json", r)
  if err != nil {
     return fmt.Errorf("http.Post: %w", err)
  }

  return nil
}

В функции GetRequest() получаем данные по атрибуту URL-назначения:

func GetRequest(url string, headers map[string]string) ([]byte, error) {
  req, err := http.NewRequest("GET", url, nil)
  if err != nil {
     return nil, fmt.Errorf("getRequest: %w", err)
  }

  for k, v := range headers {
     req.Header.Set(k, v)
  }

  client := http.Client{}
  resp, err := client.Do(req)
  if err != nil {
     return nil, fmt.Errorf("client.Do: %w", err)
  }

  text, err := io.ReadAll(resp.Body)
  if err != nil {
     return nil, fmt.Errorf("ReadAll: %w", err)
  }

  return text, nil
}

От Schedules мы отказываться не стали. Бот так же раз в сутки присылает уведомление в канал, но теперь делает запрос в производственный календарь для проверки нерабочих дней.

Вот так сейчас выглядит уведомление с мердж-реквестами:

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

Что было дальше

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

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

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

Одна команда из СберМаркета уже забрала наш код на переиспользование. Надеюсь, он будет полезен и кому-то из читателей здесь :)

Tech-команда СберМаркета ведет соцсети с новостями и анонсами. Если хочешь узнать, что под капотом высоконагруженного e-commerce, следи за нами в Telegram и на YouTube. А также слушай подкаст «Для tech и этих» от наших it-менеджеров.

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


  1. mobi
    05.06.2024 08:05
    +2

    А почему выбран тип float64 для идентификаторов?


  1. kirillverkin Автор
    05.06.2024 08:05

    Была паника с int32 поэтому поправил:
    panic: interface conversion: interface {} is float64, not int32


    1. mobi
      05.06.2024 08:05

      Это потому что в JSON (как и в JavaScript) все числа - это float64, поэтому нужно их читать как float64 и сразу конвертировать в int, или (предпочтительнее) сразу демаршалировать в аннотированную структуру вместо map[string]any.


  1. Orinaru
    05.06.2024 08:05

    А что если использовать более масштабное решение, у меня как раз сервис сейчас обрабатывает похожую информацию для оценки код ревью - PullKeeper? Он считает затраченное время на ревью, отправляет уведомления.
    Писал о нём статью - https://habr.com/ru/articles/794522/


    1. kirillverkin Автор
      05.06.2024 08:05

      Спасибо ознакомлюсь


  1. Vladek
    05.06.2024 08:05

    не совсем понял, в чём проблема была. Когда создаёшь запрос на инспекцию кода в любой системе контроля версий, то всем участникам инспекции кода на рабочую почту приходит письмо. Дальше люди инспектируют код. Код потом исправляется и/или благополучно сливается с основным кодом.

    Конечно, процесс застопорится, если люди игнорируют уведомления или забывают о них. Это, мягко говоря, довольно странно. Или людям нет дела до того, какой код оседает в репозитории. Или они загружены по самое не могу.

    Похоже на техническое решение управленческой проблемы.


    1. Yago
      05.06.2024 08:05

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

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


      1. Vladek
        05.06.2024 08:05
        +1

        надо читать все рабочие письма (раз в день хотя бы), смотреть новые коммиты в репозиториях, заходить на все рабочие порталы. Даже если не было письма, сам увидишь новую инспекцию кода. А чем ещё заниматься на работе?

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


        1. Yago
          05.06.2024 08:05

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

          Не понимаю вашего сопротивления более удобной формы уведомлений в виде агрегации всего кода, который необходимо посмотреть.

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


          1. Vladek
            05.06.2024 08:05

            не надо никого пинговать - всё видно в репозитории, кто чем занимался.

            Про 200 репозиториев не понял - как можно работать над 200 проектами с кодом одновременно? Один проект разбит на 200 репозиториев - тоже бред. Тысячи коммитов каждый день?

            Смотреть репозитории надо только те, над которыми сам работаешь на своей работе на одном-единственном проекте. Это 1-2 репозитория на практике.


            1. Yago
              05.06.2024 08:05

              А если не занимался? Тут и приходят на помощь доп. уведомления. И либо они вручную через чат вроде "посмотри плиз", либо через автоматизацию, которая на практике выигрывает человеческому фактору, что и показали метрики в посте.

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