エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

レビュー依頼の優先度について考えていたら、レビュー依頼をスコアリングしてソートするツールができた

こちらはAI・機械学習チームブログリレー8日目の記事です。前回のブログは高田さんの「AI・機械学習チームで学んだ開発技法で趣味の通知系ツールを量産した」でした!

www.m3tech.blog

エムスリーエンジニアリンググループ AI・機械学習チームでソフトウェアエンジニアをしている中村(po3rin) です。

「レビュー依頼の優先度」といえば自分の作業とレビューのどちらを優先するかという意味での「優先度」の印象ですが、今回は複数あるレビュー依頼の中で、どのレビューから見ていくかという意味での「優先度」の話をします。

レビューの優先度を考えていく中で、「これは自動化したら面白いのではないか」と思い立ち、レビューの優先度をスコアリングするツールを作ったので、その経緯を簡単に紹介していきます。

レビューの優先度の再考

エムスリーでは日々沢山のレビュー依頼が飛んできます。みんなの開発速度が早いのは素晴らしいことです。

エムスリーではGitLabを使っており、ユーザー名がマージリクエスト(GitHubでいうプルリクエスト)中でメンションされるとGitLabの機能であるTodosというリストに追加されます。私のチームではTodosが追加されるとbotから個人にSlack DMが飛ぶ仕組みになっています。

開発やミーティングで目を離すと、Todosが何件か溜まっていることがあります。

そういうとき、皆さんはどのレビュー依頼から見ていきますか?

私はリストの順番、つまり依頼された早さ順を基本にして見ていたのですが、それだと、メンションされた時間に限らず自分が見るべき優先度の高いレビューが遅れてしまうことがあります。そこで自分が見るべきレビュー依頼の観点を改めて考えてみました。

先に見るべきレビュー依頼の観点

先に見るべきレビュー依頼の観点を考えると、次の観点が挙げられます。もちろんチーム内でのレビュー文化やルールはチームごとに違うので、次で述べる観点が全てではないことはご了承ください。

急ぎ系のマージリクエスト

マージリクエストのタイトルに「緊急」「急ぎ」などの単語があったら、要望通り優先します。

メンションされてからの経過時間

メンションされてからの経過時間は当然重要です。

Google's Engineering Practices documentationでもコードレビューのスピードについて言及があります。

あるタスクに集中的に取り組んでいる最中でなければ、コードレビューの依頼が来たらすぐに着手してください。コードレビューのリクエストに返信するまでの最長の時間は一営業日です(つまり遅くとも翌朝一番に返信すべきです)。

そこで時間経過に合わせて優先度を大きく増幅させる必要があります。

メンションされている人の数

メンションされている人の数が少ない時は、もう1度見てほしい人への再メンションや議論の為のメンションなど、より重要度が高いものが多いです。そこでメンションされている人が少なければ少ないほど重要なレビュー依頼である可能性があるので優先度を上げます。

メンションされている人たちの順番

弊社ではレビューしてほしい人を何人か並べてメンションします。そのメンションされた順番が最初の方であればあるほど、プルリクエスト作成者がレビューを依頼するときに名前が浮かんだ人ということになります。そのため、自分の名前が何番目に挙げられたかで自分がレビュアーとしてどのくらい重要なのかを測れます。

自分が過去にどれくらいコミットしたことのあるリポジトリか

自分が編集したことのあるリポジトリは、自分しか気付けないレビュー観点がある可能性があります。過去のコミットのうち自分のコミットか多数を占める場合、優先度を若干上げる必要があります。

得意言語のファイルの変更など、ファイルの種類による優先度

Goが得意なら.goファイルをTerraformに詳しいなら.tfファイルがdiffに入っているレビュー依頼の優先度上げたいところです。ただやりすぎると他の言語や技術の習得のチャンスを逃す可能性もあるので、小さな重みづけで優先したいところです。

レビュー優先度を決めるのを自動化したい!

これまでに紹介した観点だと、1個ずつレビュー依頼を見ていく必要があり、優先度をつけるだけで時間がかかります。そこで自動化を試しました。

gitlab-todos-sort

gitlab-todos-sortというツールを即席で作りました。

github.com

GitLabのAPIを叩いて、自分がメンションされてるToDoと各リポジトリのコミット過去1年分と、プルリクエストのdiffの情報を取得したのちに、こちらで定義したスコア計算式でレビュー依頼をソートします。ツールの使い方は次のようになります。

$ export GITLAB_USER_NAME=<GitLabのユーザー名>
$ export GITLAB_HOST=<GitLabのホスト>
$ export GITLAB_TOKEN=<GitLabのアクセストークン>

$ gitlab-todos-sort
# url                                score
# https://XXXX/-/merge_requests/1    336.3636363636364
# https://XXXX/-/merge_requests/2    60.97207636814954

gitlab-todos-sortは優先度でソート済みのマージリクエストURLを標準出力に書き込みます。

例えばこれをブラウザで開く場合は次のようにpipeで繋いで実行します。

gitlab-todos-sort | awk 'FNR>1' | awk '{print $1}' | xargs open

ここからはスコア計算のポイントだけに絞って実装を紹介します。ちなみにスコア計算方法は全て私の感覚による1週間の調整の結果です!こういうスコアの付け方もあるよねという紹介をしていきます。スコアの付け方は各々のチームの方針ごとに調整しましょう。

急ぎ系のプルリクエスト

弊チームではほとんどありませんが、プルリクエストのタイトルや説明文に急ぎの旨が記載されている場合はスコアを大きく上げます(今回の実装ではスコアを+300)。単純に部分文字列一致のチェックをするだけです。より厳密にやりたい場合は形態素解析などを挟むのもいいかもしれません。

var urgentKeywords = []string{"URGENT", "EMERGENCY", "緊急", "急ぎ"}

func urgentScore(todos []ToDo) map[int]float64 {
    result := make(map[int]float64)
    for _, t := range todos {
        for _, keyword := range urgentKeywords {
            if strings.Contains(t.Body, keyword) || strings.Contains(t.Target.Title, keyword) {
                result[t.Target.IID] = 1000
            }
        }
    }
    return result
}

メンションされてからの経過時間

メンションへの反応が遅れたら遅れただけその人の開発が止まることになるのでなるべく早く反応したいところです。Exponentialで時間が経てば経つほど追加するスコアを増幅させます。

func createdAtScore(todos []ToDo) map[int]float64 {
    result := make(map[int]float64)
    for _, t := range todos {
        score := math.Exp(time.Since(t.CreatedAt).Hours())
        if score > 300 {
            score = 300
        }
        result[t.Target.IID] = score
    }
    return result
}

例えば3時間放置で約20点、5時間放置でスコアが約148.4点が加算されます。

メンションされる人たちの順番と数によるスコアリング

自分の名前の出現位置とメンションされている数でスコアリングします。

func userNamePosScore(todos []ToDo, userName string) map[int]float64 {
    result := make(map[int]float64)
    for _, t := range todos {
        userNamePos := strings.LastIndex(t.Body, fmt.Sprintf("@%s", userName))
        if userNamePos == -1 {
            continue
        }
        userNameOrder := float64(strings.Count(string([]rune(t.Body)[:userNamePos]), "@"))
        userNum := float64(strings.Count(t.Body, "@"))
        result[t.Target.IID] = (30 * (1 - (userNameOrder / userNum))) + (50 / userNum)
    }
    return result
}

これであればメンションされている全体の人数を考慮しつつ、自分の名前が何番目に出ているかでスコアを調整できます。どちらかというとメンションされている人数が少ない方を評価しています。

例えば2人がメンションされて、一番最初に名前が挙げられている場合は、 30 * (1 - 0/2) + 50/2 = 55となり55点の加算です。

ここはもっと良いモデリングがありそうですが一旦この形で落ち着いています。

自分が過去にどれくらいコミットしたことのあるリポジトリか

過去のコミットの中で、自分のコミットが何パーセントあるかでスコアを決めます。

func commitScore(gitlabClient *GitLabClient, userName string, todos []ToDo) (map[int]float64, error) {
    result := make(map[int]float64)
    ProjectScoreMap := make(map[int]float64)
    for _, t := range todos {
        score, ok := ProjectScoreMap[t.Project.ID]
        if !ok {
            commits, err := gitlabClient.commits(t.Project.ID)
            if err != nil {
                return nil, fmt.Errorf("failed to get commits: %w", err)
            }

            var commitiByUserNum float64
            for _, commit := range commits {
                if strings.Contains(commit.CommitterEmail, userName) {
                    commitiByUserNum += 1
                }
            }

            commitsNum := float64(len(commits))

            score = 100 * commitiByUserNum / commitsNum
            ProjectScoreMap[t.Project.ID] = score
        }
        result[t.Target.IID] = score
    }
    return result, nil
}

gitlabClient.commitsでは一年分のコミットを遡ります。commitsはプロジェクトごとに取得するので、複数回同じリクエストをしないように結果をキャッシュしています。1年間のコミットで20%が自分のコミットの場合、100 * 0.2 = 20で20点の加算です。

得意言語のファイルの変更など、ファイルの種類による優先度

自分で指定した拡張子を持つファイルにdiffがあった場合、スコアを+10します。巨大プルリクの場合、無尽蔵にスコアが増える可能性もあるので、diffによるスコアの最大値を50としています。

func diffScore(gitlabClient *GitLabClient, todos []ToDo, priorityFileExt []string) (map[int]float64, error) {
    result := make(map[int]float64)
    for _, t := range todos {
        diffs, err := gitlabClient.diffs(t.Project.ID, fmt.Sprintf("%d", t.Target.IID))
        if err != nil {
            return nil, fmt.Errorf("failed to get diffs: %w", err)
        }

        var diffScore int
        for _, diff := range diffs {
            for _, ext := range priorityFileExt {
                if strings.HasSuffix(diff.NewPath, ext) {
                    diffScore += 10
                }
            }
        }

        if diffScore > 50 {
            diffScore = 50
        }
        result[t.Target.IID] = float64(diffScore)
    }
    return result, nil
}

gitlabClient.diffsではマージリクエストに含まれるファイルのdiffを返してくれます。その情報を作ってスコアを加算しています。

使用感と改良点

1週間使いながら、自分の認識と合うようにスコア計算式を修正して、一旦は使える形になりました。基本的にはメンションされてからの時間が支配的ですが、数十分差のレビュー依頼なら、メンションされている順番や、過去のコミットの情報が重要になります。これらの計算式はその人の信念と感覚はもちろん、チーム内でのレビュー文化やルールも考慮して調整していく必要があります。

また、openコマンドと一緒に使うことで、全てのTodoをブラウザで一気に開けるので、リンクを踏んで一個ずつ開いていくというステップを取り除けて、これだけでも便利です。

そして、使っていく中で次の改良点が見つかっています。

  • 拡張子によるスコア加算しかできないので「k8sのyaml」などを認識できない
  • 自分がコミットしたことのあるファイルの修正を重み付けしたい(まだ実装できていない)
  • レビューする必要がない自動生成コードの拡張子をスコアに含んでしまう
  • 特定のレポジトリでだけSquashマージしてると優先度が下がる

この辺は使っていきながら改良していきたいと思います。

We are hiring !!

エムスリーでは自動化が大好きなエンジニアを募集しています!少しでも興味がある方は、次のURLからカジュアル面談をご応募ください!

jobs.m3.com