Classi開発者ブログ

教育プラットフォーム「Classi」を開発・運営するClassi株式会社の開発者ブログです。

リードタイムを測るシェルスクリプトを作ってチームの振り返り会を活発にした話

こんにちは。エンジニアのすずまさです。

去年の夏頃にリードタイムの計測を始めてから、振り返りで良い気づきを得られるようになったりリードタイムを減らすアクションが生まれたりと良いことがたくさんあったので、今回はその紹介をしようと思います。

リードタイムの定義

LeanとDevOpsの科学』では、リードタイムを「コードのコミットから本番稼働までの所要時間」として定義しています。

私たちのチームのリポジトリではブランチ戦略としてGitHub Flowを採用しており、mainへのマージと本番稼働のタイミングが近しいため「PRをopenしてからマージするまでの期間」をリードタイムとして定めて計測しました。

リードタイム計測を始めた動機

私たちのチームでは「チームのスピードがあまり出ていない気がする」という漠然とした課題感がありました。しかし、課題感はありつつも、ではどうするかと言われると具体的なアクションが出にくい状態が続いていました。

そこで、リードタイムを測ることでボトルネックになっている箇所を明確にし、デリバリのスピードを上げたいと思い計測を始めることにしました。

計測方法

計測方法については、後半に説明する通り自動化できていないなどの改善点がいくつかありますが、一例として誰かの参考になればと思い紹介します。

私たちのチームではGitHub Projectsを使ってPRやissueの管理をしており、クローズしたアイテムは完了レーンに置くようにしていました。
単純に管轄リポジトリのリードタイムを計測するだけだと、複数チームが使うリポジトリの場合に他チームの結果も混ざってしまいます。そこで、「GitHub Project上の完了レーンにあるアイテム」をリードタイム計測の対象とし、計測したアイテムは削除するような運用にしました。

私たちのチームで使っているGitHub Project

実装はシェルスクリプトとRubyで行いました。
シェルスクリプト上でGitHub APIを叩いて完了レーンのアイテムを取得し、Rubyを使ってリードタイムを計算して出力しています。

当初は「完了レーンにあるアイテムを取得するだけならGitHub APIに専用のクエリがありそう」と考えていましたが、そのようなクエリは見当たらなかったため、下記のように実装しました。

  1. GitHub CLIを使ってGitHub APIを叩き、projectのidを取得する
  2. リポジトリを列挙してfor文で回し、下記を繰り返す
    • GitHub APIのsearch queryを使って直近でクローズしたPR/issueを100件取得する
    • 取得したアイテムのうち、対象のproject idの完了レーンに存在するものをJSONファイルの末尾に追加
  3. JSONファイル内の各アイテムのリードタイムを計算して出力

実装は下記の通りです。

シェルスクリプトを使った実装 (1, 2)

#!/bin/bash

PROJECT_NUMBER="1" # GitHub ProjectsのURLに記載されている数値

gh api graphql -f query='
  query ($org: String!, $project_number: Int!) {
    organization(login: $org) {
      projectV2(number: $project_number) {
        id
      }
    }
  }' -f org="classi" -F project_number=$PROJECT_NUMBER >project.json

PROJECT_ID=$(jq -r '.data.organization.projectV2.id' project.json)

repositories=(
  # 計測対象のリポジトリ名を格納する
)

echo -n "[]" >completed_items.json

for ((i = 0; i < ${#repositories[@]}; i++)); do
  search_query="repo:classi/${repositories[i]} is:closed sort:updated-desc"

  gh api graphql -f query='
    query ($search_query: String!) {
      search(type: ISSUE, first: 100, query: $search_query) {
        nodes {
          ... on PullRequest {
            title
            url
            repository {
              name
            }
            assignees(first: 10) {
              nodes {
                login
              }
            }
            labels(first: 10) {
              nodes {
                name
              }
            }
            createdAt
            closedAt
            projectItems(first: 10) {
              nodes {
                id
                project {
                  id
                }
                fieldValues(first: 10) {
                  nodes {
                    ... on ProjectV2ItemFieldSingleSelectValue {
                      field {
                        ... on ProjectV2SingleSelectField {
                          name
                        }
                      }
                      name
                    }
                  }
                }
              }
            }
          }
        }
      }
    }' -f search_query="$search_query" >search_result.json

  # GitHub Project上の"完了"レーンにあるアイテムをtempに格納
  temp=$(
    jq --arg project_id "$PROJECT_ID" '
      .data.search.nodes[] |
      select(. != {}) | select(.projectItems.nodes[] |
      .project.id == $project_id) |
      select(.projectItems.nodes[] |
      .fieldValues.nodes[] |
      select(.field.name == "Status").name |
      contains("✅ 完了"))
    ' search_result.json |
      jq -cs .
  )
  # temp配列の値をcompleted_items.jsonの配列の末尾に追加する
  completed_items=$(jq -s '.[0] + .[1]' completed_items.json <(echo "$temp"))
  echo "$completed_items" >completed_items.json
done

bundle exec ruby format_pr_infos.rb

Rubyを使った実装 (3)

  • format_pr_infos.rb
require "json"
require "./completed_item"

rows = ''

File.open("completed_items.json") do |f|
  completed_items = JSON.load(f)

  completed_items.each do |completed_item|
    next if completed_item.empty?
    item = CompletedItem.new(completed_item)

    rows+="#{item.title}\t"
    rows+="#{item.repository}\t"
    rows+="#{item.assignees}\t"
    rows+="#{item.labels}\t"
    rows+="#{item.lead_time}\t"
    rows+="#{item.formatted_lead_time}\t"
    rows+="#{item.created_at}\t"
    rows+="#{item.closed_at}\t"
    rows+="#{item.url}\n"
  end
end

puts rows
  • completed_item.rb
require 'time'

class CompletedItem
  def initialize(item)
    @item = item
  end

  def title
    @item['title']
  end

  def repository
    @item['repository']['name']
  end

  def created_at
    Time.parse(@item['createdAt']).getlocal("+09:00").strftime("%Y-%m-%d %H:%M:%S")
  end

  def closed_at
    Time.parse(@item['closedAt']).getlocal("+09:00").strftime("%Y-%m-%d %H:%M:%S")
  end

  def lead_time
    "#{format("%02d", hour)}:#{format("%02d", minute_per_hour)}"
  end

  def formatted_lead_time
    "#{format("%03d", date)}#{format("%02d", hour_per_day)}時間 #{format("%02d", minute_per_hour)}"
  end

  def assignees
    assignees_nodes = @item['assignees']['nodes']
    assignees_nodes.map{ |node| node['login'] }.join(',')
  end

  def labels
    labels_nodes = @item['labels']['nodes']
    labels_nodes.map { |node| node['name'] }.join(',')
  end

  def url
    @item['url']
  end

  private

  def time
    @time ||= (Time.parse(closed_at) - Time.parse(created_at)).to_i
  end

  def date
    @date ||= (time / (60 * 60 * 24)).floor
  end

  def hour
    @hour ||= (time / (60 * 60)).floor
  end

  def minute
    @minute ||= (time / 60).floor
  end

  def hour_per_day
    @hour_per_day ||= hour - date * 24
  end

  def minute_per_hour
    @minute_per_hour ||= minute - hour * 60
  end
end

出力した内容は、振り返りやすくするためにGoogleスプレッドシート上に貼り付けて記録するようにしました。 スプレッドシートのフィルタ機能を使えば、任意の期間や担当者などで条件を絞り込み、その条件のリードタイムの平均値や中央値を算出できます。

毎週、リードタイムを記録する際にその週で絞り込んだフィルタを作成することで、後から簡単に見返せるようにしました。

チームの変化

リードタイムを計測してから下記のような良い変化が起きました。

PRの質を意識するようになった

リードタイムを計測したことで、PRの粒度が大きい場合や複雑な場合にリードタイムがかなり伸びるということがわかりました。

粒度の大きいPRは見る箇所が多いので、当然その分レビューにも時間がかかります。
ただ、それだけではなくレビュー自体が後回しにされていたことも原因の一つでした。

レビュアーが「レビューしづらそう」と感じたPRのレビューを後回しにしており、それによりさらにリードタイムが伸びていました。

この気づきにより、レビューしづらいなと感じても後回しにせず、まずはPRの質に関する指摘を積極的にするようになったと感じます。

また、PRを作る側もレビューしやすいPRを意識するようになっており、チームメンバーに対してリードタイム計測前後でどんなアクションを取るようになったかアンケートをとったところ「PRを小さく作るようになった」「descriptionを細かく記述するようになった」という声が寄せられました。

リードタイム計測後に行ったアンケートの回答

ペア/モブ作業が増えた

上記はPR自体の問題によるものでしたが、PR自体に問題はなくても前提の理解が大変だったり、チームメンバーの苦手分野が集まっている変更だったりするとリードタイムが伸びてしまいます。

そういったPRがあった場合は、レビュアー側からPR作成者にペアレビューを呼びかけてリードタイムの短縮に努めました。

ペアレビューを呼びかけると「私もこのPRよくわかってないので参加したいです」というような声が上がって3~4人のモブレビューになることもあり、PR作成者とレビュアー間の知識差を素早く埋めることができるのでかなり体感が良かったです。

開いた時に難しそうだと感じるPRは、レビューを後回しにするのではなく、ペアレビューチャンスだと思うようになりました。

振り返り会で良い気づきを得られるようになった

上述した変化はいずれもチームの振り返り会の中で生まれたアクションです。

ボトルネックが明確になったことで、今まで曖昧だった問題点を捉えやすくなり、振り返り会が活発になったように感じます。

具体的には、振り返り会の中で下記のような問題点に気づけました。

  • チームメンバーの苦手領域が集まっているPRは明らかにリードタイムが伸びている
  • 問い合わせ対応が多い週はリードタイムが伸びやすい
  • タスクを抱えすぎてレビューに手が回らなくなっている

特に計測を始めて一ヶ月くらいは、振り返り会のたびにチームが改善していっている実感があり、チームで働くモチベーションになっていました。

現状の問題点

リードタイムを計測すること自体は良いことづくめなのですが、今の計測方法には下記のような問題点があります。

  • 手動実行が面倒臭い
  • 他チームで流用しづらい

振り返り会の前に手動で実行して、Googleスプレッドシート上に貼り付けて、フィルタ作成して…ということを毎週やっており、かなり面倒です。 手動なのでオペミスが起きることもあり、一度過去に作成したフィルタを全部削除してしまったこともありました。

また、「GitHub Projectの完了レーンを参照する」という自分たちのチームの運用方法に依存した方法にしてしまったため、似たような運用をしているチームではないと真似するのが難しくなっています。

ではどうするのか

「各々のチームがリードタイムを計測しても他チームへの横展開が難しい」という問題点は以前から存在したため、最近データプラットフォームチームが横断的にFour Keysの可視化をするための基盤を作ってくれました! この基盤を利用すれば上述した問題点は全て解決できそうなので、徐々に移行を進めようと考えています。

今後うまく移行できればまた紹介しますので、ご期待ください!

結び

以上、私のチームで行なっているリードタイムの計測方法と、計測したことで起きたチームの変化についての紹介でした。

計測の仕組みを整えるまでにかかるコストに対して得られる気づきは大きかったので、費用対効果が高くやって良かったなと感じました。

少しでもリードタイムを計測しようと思っている方の助けになれば幸いです。 ここまで読んでいただきありがとうございました。

© 2020 Classi Corp.