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

от автора

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


ссылка на оригинал статьи https://habr.com/ru/articles/819067/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *