世にも恐ろしい開発者評価アプリを作った話(Ruby, GitHub API)

9 min read読了の目安(約8100字

前書き

背景

自社のサービスが大きくなってくると社員が増え、組織が大きくなり、「開発部署の評価制度ってどうなってんの?」みたいな話になってくることがあります。開発者にはマーケティングチームが持つようなサービスのコンバージョンに関する成果目標や、営業チームが持つような売上目標といった、具体的な数値目標がない場合が多く、「開発者の評価を定量的に行え」と言われると少し難しかったりしますよね。そこで、GitHub API で開発者の動きをレポートにして評価軸にするという世にも恐ろしい取り組みを行ったので、その概要を本記事で紹介します。本記事の話は、社内における部署の体裁を保つために遊びでやってみただけなので、話半分で捉えてください。

評価軸をどこに置くか

安直にやるなら commit 数です。リポジトリの Insights タブから、開発者毎の commit 数を見ることができます。ただ、この機能は1ヶ月までしか遡れない他、数あるリポジトリの全 Insights ページにアクセスして集計して回るのは少し面倒です。

また、どうせ評価指標的なものを作らなければならないのであれば、プロダクトコードに反映されたものしか数値として表れない commit 数を評価軸にするのは個人的にはやりたくありませんでした。commit は簡単に細かくできるのでハックするのも簡単です。私が開発者の仕事として評価したいと思ったのは commit 数ではなく、Pull Request を発行した数、Issue を発行した数、Issue にコメントした数、Pull Request にレビュー/コメントした数、Wiki を書いた数といった、開発プロセス全体で積極的に議論やレビューを行い、Issue や Wiki でドキュメントを残し、細かい Pull Request でフィードバックサイクルを短く努めるような行いでした。

特に、Issue や Pull Request へのコメントによる開発ログの記録や議論はチームにとって重要で、積極的な人ほど評価したい思いもありました。ただ、Issue や Pull Request の発行数はともかく、コメントした数まで開発者毎に集計するのは、既存の Insights ページや Organization ページでは恐らく不可能でした。そこで、自分で集計できないかと思い、GitHub API のドキュメントを眺めてみることにしました。

GitHub API

やりたかったこと

最初に「こんなのないかなー」と探していたのは自社の Organization の全リポジトリに対しての以下の情報をまとめて引っ張ることでした。

  • Issue の発行
  • Pull Request の発行
  • Issue へのコメント
  • Pull Request へのコメント
  • Pull Request のレビュー
  • Wiki の作成/編集

/orgs/{org}/pulls でその Organization の Pull Request が全部返ってきたりしないかなー、なんて思っていましたが、軽く探してみたところでは、そこまで都合の良い API はなく、/org/{org}/{repo}/pulls のようにリポジトリを指定する必要があり、とりあえず適当に叩いてみた感じはコメント数を引っ張るのも難しそうな雰囲気でした。

アクティビティイベント

上記の線では対して調査していないのですが、私の目を引いたのはアクティビティイベント API でした。

イベント API は、GitHub でのアクティビティによってトリガーされるさまざまなタイプのイベントを返すことができます。

と書かれており、この API であれば Issue に対するコメントといったイベントも取得できそうな雰囲気を感じ取りました。ドキュメントからは詳しい戻り値を読み取ることはできませんでしたが、恐らく GitHub のダッシュボードと同じような情報が拾えるのではないかと想像しました。適当に叩いてみると、各種イベントに対して type という属性が割り当てられており、以下のような値がセットされていました。

JSON.parse(res.body).map { |e| e['type'] }.uniq
=> ["PushEvent",
 "DeleteEvent",
 "PullRequestEvent",
 "PullRequestReviewEvent",
 "CreateEvent",
 "IssuesEvent",
 "IssueCommentEvent",
 "GollumEvent",
 "PullRequestReviewCommentEvent"]

まさに私が欲しかったような PullRequestReviewCommentEventIssueCommentEventGollumEvent (Wiki の作成/編集) といった値がセットされていたので、この API を使って集計アプリケーションを作ることにしました。

問題点

この API は大変理想的なものでしたが、ドキュメント によると過去90日以内の300件までのイベントしか取得できないようでした。

イベントはページネーションをサポートしていますが、per_page オプションはサポートされていません。 固定ページサイズは 30 項目です。 最大 10 ページ、合計 300 イベントのフェッチがサポートされています。

過去 90 日以内に作成されたイベントのみがタイムラインに含まれます。 90 日以上経過しているイベントは含まれません(タイムラインのイベントの総数が300 未満の場合でも)。

つまり、継続的に集計を続けるためには、こちらでイベントの情報を永続的に保存する必要があります。以上を踏まえて開発者の仕事量を集計する世にも恐ろしい Rails アプリケーションを作っていきます。

実装

マイグレーションファイル

API からは各 type 毎に異なる構造で膨大なデータが返ってくるので、今回作る簡易的なアプリケーションでは保存するデータを全 type で共通して存在する(であろう)属性のみの保存に絞ることにしました。以下があれば集計するには充分だと考えました。

属性 json 内の位置 内容 値のサンプル
ID ['id'] GitHub が一意に振る ID 00000000000(現状11桁)
種別 ['type'] イベントの種類 IssueCommentEvent
開発者 ['actor']['login'] イベントを発生させた人の GitHub ID ogihara-ryo
リポジトリ名 ['repo']['name'] イベントが発生したリポジトリの名前 org/repo_name
日時 ['created_at'] イベントの発生日時 2020-11-06 12:49:23

ということで、そのままマイグレーションファイルを書きました。id を使い回しているのは良くないことです。

class CreateEvents < ActiveRecord::Migration[6.0]
  def change
    create_table :events do |t|
      t.string :type
      t.string :actor
      t.string :repo_name
      t.datetime :timestamp

      t.timestamps
    end
  end
end

同期処理

とりあえずアクセスしやすいように API コール用の口となるメソッドを作っておきます。2段階認証を設定している GitHub アカウントから Organization のプライベートリポジトリにアクセスするためには、Personal Access Token が必要です。執筆現在は Developer Setting から発行することができます。これを環境変数からリクエストヘッダーにセットしています。

  def call_github_api(path)
    uri = URI.parse("https://api.github.com#{path}")
    request = Net::HTTP::Get.new(uri)
    request.basic_auth('', ENV['PERSONAL_ACCESS_TOKEN'])
    req_options = { use_ssl: uri.scheme == 'https' }

    response = Net::HTTP.start(uri.hostname, uri.port, req_options) do |http|
      http.request(request)
    end

    JSON.parse(response.body)
  end

続いてアクティビティイベント API をコールしてイベントを取得したいところですが、アクティビティイベント API で Organization の全リポジトリのイベントを取得することはできませんでした。/orgs/{org}/events という API がそれっぽく見えますが、ドキュメントに "List public organization events" と書いてあり、叩いてみると public なリポジトリに対するイベントしか返ってきませんでした。プライベートなリポジトリのイベントを取得するためには、/repos/{owner}/{repo}/events のようにリポジトリ個別で取得する他なさそうでした。

そのため、一旦 /orgs/{org}/repos でリポジトリ名の一覧を取得することにしました。

  def repo_names
    call_github_api('/orgs/[Organization名]/repos').map { |repo| repo['name'] }
  end

このリポジトリ名の配列を使って、イベントを取得します。ドキュメントによると

イベントはページネーションをサポートしていますが、per_page オプションはサポートされていません。 固定ページサイズは 30 項目です。 最大 10 ページ、合計 300 イベントのフェッチがサポートされています。

とのことですが、per_page=100 を指定してみたら普通に動いてくれたので、100件を3ページ分コールしています。ドキュメントに従うならデフォルトの30件を10ページ分コールすべきではあります。

  def call_github_events_api
    repo_names.flat_map do |repo_name|
      3.times.flat_map do |page|
        call_github_api("/repos/[Organization名]/#{repo_name}/events?per_page=100&page=#{page}")
      end
    end
  end

最後に、上記の情報をデータベースに突っ込んでいきます。最後に同期した日時等から差分を取るのが面倒だったので、ActiveRecord::Base.exists? を毎回コールして、その ID のイベントが保存済みがチェックする大変乱暴な実装です。また、評価軸として PushEvent (push する度に発行) と DeleteEvent (merge したブランチを消す度に発行) の2つは不要だと思ったので省いています。

  def create_events
    call_github_events_api.each do |e|
      next if %w[PushEvent DeleteEvent].include? e['type']

      next if Event.exists?(id: e['id'])

      Event.create(
        id: e['id'],
        type: e['type'],
        actor: e['actor']['login'],
        repo_name: e['repo']['name'],
        timestamp: e['created_at']
      )
    end
  end

これを cron で回すなり、定期的に同期ボタンを押しに行くなりすれば、イベントを集計し続けることができます。

表示

1時間ぐらいで作ったアプリなので超雑ですが、とりあえず今クォーターのレポートを表示する画面は用意しておきました。まあ、10月中旬以前のデータは保存されてないんですが。

<h1>10月〜12月</h1>

<%= button_to '同期', sync_path %>

<table>
  <thead>
    <tr>
      <th></th>
      <th>荻原 涼</th>
      <th>開発者a</th>
      <th>開発者b</th>
      ...
    <tr>
  </thead>

  <tbody>
    <tr>
      <th>10月</th>
      <td><%= Event.where(timestamp: '2020-10-01'..'2020-10-31', actor: 'ogihara-ryo').count %></th>
      <td><%= Event.where(timestamp: '2020-10-01'..'2020-10-31', actor: 'developer-a').count %></th>
      <td><%= Event.where(timestamp: '2020-10-01'..'2020-10-31', actor: 'developer-b').count %></th>
      ...
    </tr>
    <tr>
      <th>11月</th>
      <td><%= Event.where(timestamp: '2020-11-01'..'2020-11-30', actor: 'ogihara-ryo').count %></th>
      <td><%= Event.where(timestamp: '2020-11-01'..'2020-11-30', actor: 'developer-a').count %></th>
      <td><%= Event.where(timestamp: '2020-11-01'..'2020-11-30', actor: 'developer-b').count %></th>
      ...
    </tr>
    <tr>
      <th>12月</th>
      <td><%= Event.where(timestamp: '2020-12-01'..'2020-12-31', actor: 'ogihara-ryo').count %></th>
      <td><%= Event.where(timestamp: '2020-12-01'..'2020-12-31', actor: 'developer-a').count %></th>
      <td><%= Event.where(timestamp: '2020-12-01'..'2020-12-31', actor: 'developer-b').count %></th>
      ...
    </tr>
  </tbody>
</table>

これで、開発者がどれぐらいの回数 Issue や Pull Request を発行/コメントしているか、Wiki のドキュメントを整備しているかを可視化してしまうアプリケーションが生まれてしまいました。

終わりに

冒頭の繰り返しになりますが、本記事の話は、社内における部署の体裁を保つために遊びでやってみただけなので、評価云々の話は忘れてください。

この記事に贈られたバッジ