開発者にスポットライトを当てる GitHub 貢献ダッシュボードを作った話

に公開

はじめに

GitHub を眺めていると、「この人、実はめちゃくちゃ活動してるな」という瞬間があります。

  • 仕事以外の OSS にコミットしていたり
  • いろいろなリポジトリに PR を飛ばしていたり
  • 他の人の PR を丹念にレビューしていたり

でも、その “すごさ” は、日々の業務の中ではなかなか共有されません。

社内のイベントで自己紹介をしても、

  • 「普段は ◯◯ 案件でバックエンドをやっています」
  • 「××チームでインフラ側を見ています」

くらいで終わってしまうことが多く、
パブリックな GitHub でどれだけコミットや PR、レビューをしているか は、
「わざわざプロフィールを見に行かないとわからない」世界です。

そこで今回は、特定の GitHub Organization に所属しているメンバーの

  • パブリックなコミット数
  • パブリックな PR 数
  • パブリックな PR レビュー数

を集計し、ランキングやグラフで“見える化”するダッシュボード を作ってみました。

本記事の Azure Bicep や C#で実装したソースコードは、GitHub に登録しています。

https://github.com/yutaka-art/github-contribution-rankings

ここでポイントなのは、

Organization 配下のリポジトリだけでなく、
そのメンバーのパブリックな GitHub 活動全体 を対象にしていること

です。

「この Org に所属している人たちは、外の世界でもこんな活動をしている」というのを、
社内のコミュニケーションイベントなどでわいわい眺めるイメージです。

一方で、こういった可視化は簡単に 「監視ツール」 にもなり得ます。

個人の成果を数値で並べてしまうことの危うさも感じていて、

  • なぜ自分はこれを作りたいと思ったのか
  • どう使えば人を追い詰めず、むしろスポットライトを当てられるのか

を考えながら実装を進めました。

この記事では、

  • ダッシュボードの概要と実装
  • その裏側で考えた「個人メトリクスとの付き合い方」

を書き残しておきたいと思います。

作ったものの概要

まずは完成イメージから。

  • 対象:特定の GitHub Organization に所属するメンバー

  • 集計対象:そのメンバーの パブリックな GitHub 活動(コミット / PR / レビュー)

  • 表示:

    • コミット数 / PR 数 / レビュー数 / 合計スコアのランキング
    • テーブル表示とチャート表示を切替
    • Top 5 / 10 / 20 ... と表示件数を切り替え
    • ローディング中は進捗バーを表示
    • 手動で「Refresh」して最新の情報を取得可能

ざっくり UI 構成はこんな感じです

  • 上部:Organization 名、メンバー数、最終更新時刻、リフレッシュボタン
  • 中央:全体の集計カード(総コミット数、総 PR 数、総レビュー数、アクティブメンバー数 etc.)
  • 下部:
    • ランキングの種類(Total / Commits / PRs / Reviews)
    • Table / Chart の切替
    • 表示件数のドロップダウン + 実際のランキング(表 or チャート)

技術スタックとアーキテクチャ

技術スタックはこんな構成にしました。

  • 言語 / フレームワーク
    • .NET 8
    • ASP.NET Core Razor Components(旧 Blazor Server 的な構成)
  • GitHub API
    • GitHub GraphQL API v4
    • GraphQL.Client ライブラリ
  • ホスティング
    • コンテナ前提(Docker)
    • Azure App Service / Azure Container Apps / AKS などにデプロイ可能
  • ログ・メトリクス
    • Application Insights Telemetry

アーキテクチャのイメージ

ポイントは

  • GitHub とのやり取りは GitHubOrganizationService に集約

  • Organization 全体の情報取得にはそれなりに API コールが必要なので、

    • アプリケーション内キャッシュ + SemaphoreSlim で同時取得を制御
  • Application Insights で、ダッシュボード自身の挙動も「観測対象」にしている

あたりです。

Program.cs:最低限の構成とログ設定

Program.cs はかなりシンプルです。

var builder = WebApplication.CreateBuilder(args);

// ロギング設定
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();
builder.Logging.AddApplicationInsights();

builder.Services.AddApplicationInsightsTelemetry();

// Razor Components
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();
builder.Services.AddHttpClient();

// GitHub API 設定
builder.Services.Configure<GitHubApiOptions>(
    builder.Configuration.GetSection(GitHubApiOptions.SectionName));

builder.Services.AddSingleton<IGitHubOrganizationService, GitHubOrganizationService>();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

app.Run();

開発の段階から

  • Console / Debug ログ
  • Application Insights へのログ

を有効にしておくことで、

  • 「GitHub API への問い合わせにどのくらい時間がかかるか」
  • 「リトライがどのくらい発生しているか」

といった情報も後から追いやすくしています。

Rankings ページ:UI と状態管理

UI 側のメインが Rankings.razor / Rankings.razor.cs です。

表示パターンの切り替え

ページ裏側では、こんな状態を持っています。

private List<OrgResponse.MemberNodeObject> allMembers = new();
private List<MemberRanking> commitRanking = new();
private List<MemberRanking> prRanking = new();
private List<MemberRanking> reviewRanking = new();
private List<MemberRanking> totalRanking = new();

private bool isLoading = true;
private string? errorMessage;
private string organizationName = "";
private string selectedRankingType = "total"; // total / commits / prs / reviews
private string selectedViewType = "table";    // table / chart
private int displayCount = 10;

private readonly int[] displayCountOptions = { 5, 10, 15, 20, 25, 50 };

現在表示するランキングを選ぶロジックはシンプルです。

private List<MemberRanking> GetCurrentRanking()
{
    return selectedRankingType switch
    {
        "commits" => commitRanking.Take(displayCount).ToList(),
        "prs"     => prRanking.Take(displayCount).ToList(),
        "reviews" => reviewRanking.Take(displayCount).ToList(),
        _         => totalRanking.Take(displayCount).ToList()
    };
}

この GetCurrentRanking()

  • テーブルコンポーネント
  • チャートコンポーネント

に渡すことで、
UI 側はほぼ「表示に専念」できるようにしています。

GitHubOrganizationService:キャッシュとレートリミット対策

このアプリで一番ややこしいのが GitHubOrganizationService です。

キャッシュ戦略と SemaphoreSlim

Organization 全体のメンバー + コントリビューション情報を取りにいくと、
メンバー数によっては API コールがかなりの量になります。

  • ページングしてメンバー一覧を取得
  • 各メンバーの ContributionsCollection を取得(コミット / PR / レビュー)

これを毎回フルでやるとつらいので、

  • OrganizationData を静的フィールドにキャッシュ
  • 初回取得 or 強制リフレッシュ時だけ GitHub へ問い合わせ
  • 取得処理は SemaphoreSlim同時に 1 つだけ実行

という形にしました。

private static OrganizationData? _cachedData;
private static bool _isDataLoaded = false;
private readonly SemaphoreSlim _dataLoadSemaphore = new(1, 1);

public async Task<OrganizationData> GetOrganizationDataAsync(string organizationLogin, IProgress<LoadingProgress>? progress = null)
{
    await _dataLoadSemaphore.WaitAsync();

    try
    {
        if (_isDataLoaded && _cachedData != null)
        {
            return _cachedData;
        }

        var data = await LoadOrganizationDataAsync(organizationLogin, progress);

        if (data.AllMembers.Count > 0)
        {
            data.RefreshTime = DateTime.Now;
            _cachedData = data;
            _isDataLoaded = true;
        }

        return data;
    }
    finally
    {
        _dataLoadSemaphore.Release();
    }
}

UI の「Refresh Data」ボタンからは RefreshOrganizationDataAsync を呼ぶことで、
キャッシュをクリアして強制的に GitHub から再取得します。

メンバー一覧の取得(ページング + リトライ)

メンバー一覧は GraphQL で 100件ずつページングして取得します。

query ($org: String!, $cursor: String) {
  organization(login: $org) {
    name
    membersWithRole(first: 100, after: $cursor) {
      pageInfo { hasNextPage endCursor }
      nodes {
        login
        name
        email
        avatarUrl
      }
    }
  }
}

C# 側では、レスポンスの hasNextPage / endCursor を使いながら

  • 例外が起きたら _options.MaxRetries 回までリトライ
  • ページ数が増えすぎる場合の上限 (MaxPages) も設定

といった素朴なガードを入れています。

コントリビューション情報の取得(バッチ + フォールバック)

各メンバーの

  • totalCommitContributions
  • totalPullRequestContributions
  • totalPullRequestReviewContributions

は、GraphQL の「ユーザーごとの contributionsCollection」から取得します。

1 ユーザーずつ叩くとレートリミットに引っかかりやすいので、
5ユーザーずつまとめて問い合わせるバッチ処理 にしました。

const int batchSize = 5;
for (int i = 0; i < totalMembers; i += batchSize)
{
    var batch = members.Skip(i).Take(batchSize).ToList();
    await FetchContributionsBatchAsync(client, batch, batchNumber, totalBatches);

    // リクエスト間隔を空ける(Rate Limit対策)
    await Task.Delay(2000);
}

バッチクエリの中身は、こんな風に動的に組み立てています。

query ($login1: String!, $login2: String!, ...) {
  user1: user(login: $login1) {
    contributionsCollection {
      totalCommitContributions
      totalPullRequestContributions
      totalPullRequestReviewContributions
    }
  }
  user2: user(login: $login2) { ... }
  ...
}

バッチ取得がエラーになる場合は、最終手段として

  • 1ユーザーずつ個別に問い合わせるフォールバック

も用意しています。
(大量メンバーの Org だと時間はかかりますが、落ちっぱなしにはしたくなかったので…)

ランキングの作成

メンバー一覧とコントリビューション情報が揃ったら、
あとはスコアでソートしてランキングを作るだけです。

private static List<MemberRanking> CreateRanking(
    List<OrgResponse.MemberNodeObject> members,
    Func<OrgResponse.MemberNodeObject, int> scoreSelector)
{
    return members
        .Where(m => m != null && !string.IsNullOrEmpty(m.Login))
        .OrderByDescending(scoreSelector)
        .Select((m, index) => new MemberRanking
        {
            Rank = index + 1,
            Member = m,
            Score = scoreSelector(m)
        })
        .ToList();
}

これを使って、

  • コミット数ランキング
  • PR 数ランキング
  • レビュー数ランキング
  • これらを全部足した「合計スコア」ランキング

の 4 種類のリストを作り、
UI から selectedRankingType に応じて切り替えています。

このダッシュボードをどう使うか:楽しさと怖さ

ここからは、コードというより「どう使うか」の話です。

もともとのモチベーション

このダッシュボードを作ろうと思ったきっかけは、かなりシンプルでした。

「同じ Organization に所属しているメンバーが、
GitHub 上でどんな活動をしているのか、もっと知りたかった」

  • あの人、実は OSS でもかなり活動している
  • 別部署のあの人、PR のレビューをめちゃくちゃやっている

といったことを、
社内のミートアップや LT 会の場などで共有して、開発者に光を当てたい
というのが最初のイメージです。

でも、「監視ツール」にもなり得る

ただ、「ランキング」という形を取ると一気に空気が変わります。

  • 「コミット数が少ない人」が下位に並ぶ
  • 「レビューに時間を使っていてコミットは少ない人」が誤解される
  • 「数で測られている」と感じる人も出てくる

やり方を間違えると、

「個人を評価するための監視ダッシュボード」

になってしまうリスクがあります。

自分の中でもこの違和感はずっとあって、
「これは本当に作って良いのか?」と考えました。

自分なりに決めた “線の引き方”

いろいろ悩んだ結果、少なくとも以下のようなルールを置くことにしました。

  • 正式な評価には使わない

    • 人事評価・査定・ランキング表彰などには使わない
  • 「会話のきっかけ」に限定して使う

    • 社内の技術コミュニティイベントで、話のネタとして紹介する
    • 1on1 で「最近レビューへの貢献が増えていますね」とポジティブに使う
  • 「見えない貢献がある」ことを前提に話す

    • ドキュメント作成 / プロジェクト調整 / 設計レビューなど、
      コミット数には出ない貢献がたくさんあることを常に明示する

また、ランキングの種類も

  • コミット数
  • PR 数
  • レビュー数
  • それらの合計

と複数で見られるようにしたのも、

「アウトプットだけでなく、レビューなどのチーム貢献もちゃんと見たい」

という思いからです。

DevOps 的な距離感

DevOps の文脈でよく語られるのは、
「チームメトリクスを重視し、個人メトリクスを評価に使わない」 という考え方です。

今回のダッシュボードは、かなり個人寄りのメトリクスです。
なので DevOps 的に言うと、

  • これは「チームのパフォーマンスを測るツール」ではなく
  • 「チームの中の個々人にスポットライトを当てるための“観察ツール”」

くらいの距離感が良いのかな、と今は考えています。

将来的には、

  • チーム単位 / リポジトリ単位の集計(個人 → チームメトリクスへ)
  • DORA 指標とのゆるい関連(PR 数やレビュー数と Lead Time の関係など)

にも広げていくと、より DevOps 寄りの話にしていけそうです。

今後の拡張アイデア

やってみる中で、「ここまでやると面白そうだな」と感じた拡張案もいくつかあります。

  • チーム / 部署単位の集計

    • 個人ではなく「チームの GitHub 活動」を可視化する
  • 時系列の可視化

    • ここ 3 ヶ月 / 半年の活動量の推移をチャートで出す
  • PR レビューの“質”に近づく指標

    • コメント数、レビューにかかった時間なども含めて集計する
  • 他のメトリクスとの組み合わせ

    • Issue やプロジェクトボードと組み合わせて、
      「どのくらいの頻度で PR が Issue をクローズしているか」などを見る

このあたりは、別記事や登壇ネタとして掘り下げても面白そうだなと思っています。

まとめ

この記事では、

  • GitHub GraphQL API を使って、Organization メンバーのパブリックな活動を集計するダッシュボードを作った
  • コミット / PR / レビュー / 合計スコアをランキングやチャートで可視化する実装のポイント
  • その裏側で感じた、「個人メトリクスの楽しさと怖さ」「どういう使い方なら健全か」

について書きました。

技術的には、

  • GraphQL のページング
  • バッチ取得 + レートリミット対策
  • アプリケーション内キャッシュ
  • Razor Components でのランキング UI

など、割と実務にも転用しやすい要素が揃っています。

一方で、「個人の活動を数字で並べる」ことには、
最後まで悩み続ける種類の難しさもあるなと実感しました。

このダッシュボードは、誰かを査定するためではなく

「この人、こんなにいろんなところで活動しているんだね」

と互いにリスペクトを深めるきっかけとして使えたらいいなと思っています。

今後は、このコードを GitHub 上で公開しつつ、

  • 実装詳細の記事(もっとコードを貼る回)
  • Azure PaaS へのデプロイ手順
  • カンファレンス向けのセッション(個人メトリクスとどう付き合うか)

などにもつなげていければ、と考えています。

リファレンス

https://docs.github.com/ja/graphql

GitHubで編集を提案

Discussion