Привет, Хабр! На связи Кирилл Веркин. Вообще, я занимаю в СберМаркете должность Senior QA, но ради большей производительности команды жизнь заставила стать немного кодером.
Эта статья может быть интересна тем, кто замечает, что задачи в команде часто теряются, и хочет автоматизировать процесс напоминалок. Я делюсь кодом, поясняя ключевые моменты для таких же новичков в Go. Мой код написан для сочетания GitLab, Jira и Mattermost (корпоративный мессенджер, которым мы пользуемся в СберМаркете), но подобное решение можно реализовать и с другими сервисами.
Зачем нужен бот
Кросс-функциональная команда, к которой я отношусь, состоит из фронтенд-разработчика, мобильного разработчика и трех бэкендеров (с учетом тимлида). Фронтенд и мобильный код уходят на ревью за пределы команды, а вот бэкендеры сами проверяют друг друга, то есть ответственность за поставку бэкенда на тестирование лежит на них. С этим и была связана проблема: мои коллеги забывали проверять задачи на ревью, оно занимало по несколько дней и, соответственно, снижало скорость выхода на продакшен.
Мы видели проседания на графиках, тимлид и проджект выносили вопрос на ретроспективу. Изначально договорились, что каждый из бэкендеров по утрам должен смотреть мердж-реквесты. К сожалению, это не дало плодов, задачи на ревью продолжили ускользать от внимания. Тогда я решил пинговать ответственных в рабочем мессенджере. В мое отсутствие этим занимался тимлид. Проджект-менеджер заметила, что мы тратим время на ручные напоминания, и предложила автоматизировать процесс.
Если на этом моменте вам показалось, что это точно не входит в работу тестировщика, то доля правды в этом есть :) Но такой я человек, с активной жизненной позицией. Я уже писал на Хабре о том, как повысил инженерную культуру своей команды по модели ТММ, не обладая «властью» тимлида. Прочитать можно вот тут.
Первая версия бота
После мини-исследования проджект выяснила, что мы не единственные, кто столкнулся с такой проблемой, и что внутри компании уже есть код для подобных уведомлений. Я подумал, что воспользоваться работающим ботом не так трудно, Ctrl+C — Ctrl+V, и взял на себя задачу по его внедрению в канал команды на платформе Mattermost.
Изначальный код был написан на Ruby и запускался через Schedules в GitLab. Раз в сутки, по будням, соответствующая Job включалась и присылала в канал табличку с мердж-реквестами. Пара правок — и бот начал работать в канале нашей команды. Я, наивный, почувствовал в себе прогерскую мощь :)
На этом текст мог бы завершиться, но…
Довольно быстро стало понятно, что эти уведомления нам не подходят: бот пинговал не тех, кто должен был провести ревью, а авторов мердж-реквестов. Им же требовалось снова пинговать ревьюеров. Сами ревьюеры не смотрели таблицу регулярно. То есть ситуация не изменилась, только добавилось звено в виде бота.
Так в игру вступил новый бот.
Рождение нового бота
Чтобы бот соответствовал потребностям нашей команды, он должен был, как и первый, отображать ссылки на мердж-реквесты, но при этом пинговать не авторов, а ревьюеров. Кроме того, я хотел, чтобы уведомления не приходили не только в выходные, но и по праздникам.
Также бот должен был быть связан со статусами в 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-команда Купера (ex СберМаркет) ведет соцсети с новостями и анонсами. Если хочешь узнать, что под капотом высоконагруженного e-commerce, следи за нами в Telegram и на YouTube. А также слушай подкаст «Для tech и этих» от наших it-менеджеров.