Как стать автором
Обновить
СберМаркет
Кодим будущее доставки товаров

Как я написал для своей команды бот-напоминалку на Golang и втрое сократил время на ревью задач

Уровень сложностиПростой
Время на прочтение9 мин
Количество просмотров5K

Привет, Хабр! На связи Кирилл Веркин. Вообще, я занимаю в СберМаркете должность 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-менеджеров.

Теги:
Хабы:
+10
Комментарии21

Публикации

Информация

Сайт
sbermarket.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
SberMarket Tech