✏️

[ハンズオン] 世にも恐ろしい開発者評価アプリを作ろう(Ruby, GitHub API)

2021/04/11に公開

前書き

概要

この記事は、以下の記事で紹介した web アプリケーションを開発する手順を紹介した体験学習記事です。今回作成するアプリケーションを開発した動機等に興味がある場合等を除き、体験学習を行う上では前記事を読み直す必要はありません。

https://zenn.dev/ryo_ryukalice/articles/e6229616c234a4431aff

開発するアプリケーション

目的

GitHub の organization 内の各開発者のプロダクトへの貢献度を可視化するための web アプリケーションです。ストーリーとしては、上司に「開発部署全員の仕事量を定量的に見える化しろ!」と無理難題を振られたことを想像してください。今回は GitHub API から各開発者の活動を取得し、以下を算出します。つまり、Pull Request を出したりレビューしたり、Issue や Wiki でドキュメントを残したり議論をしたりするような開発者が貢献度が高い開発者として可視化されます[1]。これらは GitHub API 上で「アクティビティイベント」と記述されているため、本記事でもこれらをまとめて「アクティビティイベント」と記述しています。

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

スクリーンショット

以下のように、月毎の各開発者のアクティビティイベントの発生数を表示します。

DEMO

サンプルコード

本記事で作成するアプリケーションのソースコードは以下で公開しています。
https://github.com/ogihara-ryo/contribution-analyzer

所要時間

実際に書くコードは全部で100行程度のとても小さなアプリケーションなので、本ハンズオンの所要時間は1〜4時間程度を想定しています。

この体験学習から学べること

タスク管理システムや Twitter クローン等といった基礎的なサンプルアプリケーション開発ではあまり取り上げられないような開発経験を積むことができます。

外部サービスの API を使った開発経験

API のドキュメントを読み解いたり実際に呼び出して挙動を確認しながら開発する様子を体験できます。また、Ruby on Rails から Net::HTTP を使って外部の API にリクエストを送るようなプログラムを作成することができます。

Ruby on Rails の少し高度な使い方

ActiveRecord や ActiveSupport の力を借りながら Ruby の柔軟性を実感できるようなコードや、初学者向け教材ではあまり取り上げられませんが、ほとんどの開発現場で使われている rake タスクの作成等について学ぶことができます。

必要な予備知識

開発するアプリケーションの特性上、GitHub の Organization や Issue, Pull Request 等といった基本的な機能についての知識が必要です。また、基礎的な web アプリケーションの開発経験あるいは写経経験がある読者を想定しています。自分の Organization を持っていなくても、(rails)[https://github.com/rails] の Organization を指定して、rails のコントリビューターの皆さんの動向をチェックするアプリケーションとして開発をすれば良いのでハンズオン上は問題ありません。記事中の API コールのテストに関しても rails の Organization を指定してリクエストするような構成になっています。

開発環境

  • Ruby 3.0.1
  • Rails 6.1.3

本記事では開発環境のセットアップについてはサポートしませんが、macOS を使っていて過去に Ruby on Rails の開発環境をセットアップしたことがある場合は、以下のような感じで始められると思います。

% brew upgrade ruby-build
% rbenv install 3.0.1
% rbenv global 3.0.1
% gem install rails

アクティビティイベントの寿命と設計の方向性

前書きで述べたようなアクティビティイベントを GitHub API から取得するわけですが、任意の Organization のアクティビティイベントを任意の期間で取得するような API は提供されていません。もし、そんなものが提供されていれば、ただ API を叩いた結果を整形して表示するだけのアプリケーションを作るだけで済みましたが現実は甘くありません。今回利用する、アクティビティイベントを取得する API のドキュメントがこちらです[2]

https://docs.github.com/ja/rest/reference/activity

ドキュメントを読み込んでいくと、任意の Organization のアクティビティイベントを取得するためには、List repository events という API を利用すると良さそうです。しかし、ドキュメントには以下のように書かれており、アクティビティイベントは過去90日以内の300件までのイベントしか取得できない仕様になっているようです。

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

つまり、継続的に集計を続けるためには、こまめにアクティビティイベントのログを永続的に保存する必要があります。設計の方向性としては、API から取得したアクティビティイベントを保存できるデータベースを用意し、API をコールして取得したアクティビティイベントをデータベースに詰め込む rake タスクを用意し、こまめに実行するような運用となります。今回開発するアプリケーションの役割は、アクティビティイベントの収集と保存と表示の3つを行うことです。以上を踏まえて早速アプリケーションを作っていきます。

rails new

それでは早速 Ruby on Rails アプリケーションを作っていきましょう。オプション欲張りセットの rails new コマンドで新しい Ruby on Rails アプリケーションを作成します。

% rails new contribution-analyzer -d postgresql -M --skip-action-text --skip-active-storage -C -T

データベースの指定について

上記の rails new コマンドでは -d postgresql のように、各環境での RDBMS に PostgreSQL を指定しています。とりあえずローカルで動かしたい場合は何も指定せずに SQLite3 を使っても問題ありませんし、MySQL 等を使っても問題ありません。Heroku にデプロイしたい場合等は PostgreSQL にしておくと良いかもしれません。本ハンズオンにおいては RDBMS の種類によって記述するコードが変わることはありません。ちなみに -d オプションで変わるのは Gemfile に書かれる Ruby とのインターフェースを持つ Gem の種類と、config/database.yml の中身ぐらいなので、後から変更することは容易いです。

与えているオプション

-d 以外にも大量のオプションを付与していますが、これらについては全てアプリケーションを最小規模で作成するために不要なものを削ぎ落とすためのオプションです。以下に概要を記述しますが、他のオプション等について知りたい方は rails new -h 等をご覧ください。

オプション 内容
-M (--skip-action-mailer) Action Mailer に関するメール周りの機構をインストールしません。メールは利用しません。
--skip-action-text Action Text(WYSIWYG)をインストールしません。リッチテキストは利用しません。
--skip-active-storage Active Storage をインストールしません。クラウドストレージは利用しません。
-C (--skip-action-cable) Action Cable をインストールしません。WebSocket は利用しません。
-T (--skip-test) テスト機構をインストールしません。今回はテストは書きません。

データモデリング

どの属性を保存するか

次に、API から取得したアクティビティイベントを保存する Model を作りたいところですが、アクティビティイベント API から返ってくる json がどんなものであるかをドキュメントから読み取ることができないため、どのような属性を持てば良いか悩ましいところです。各種アクティビティイベントは type という属性が割り当てられていて、この typePullRequestCommentEvent であったり IssueCommentEvent であったりといったアクティビティイベントの種類が格納されているのですが、json の構造はこの type 毎に大きく異なります。以下は rails/rails のリポジトリに対して curl でAPI をコールした結果の一部です。

返ってくる json の一部

これは kamipo さんという Active Record のメンテナーの方の PullRequestEvent です。

{
    "id": "15889424193",
    "type": "PullRequestEvent",
    "actor": {
      "id": 12642,
      "login": "kamipo",
      "display_login": "kamipo",
      "gravatar_id": "",
      "url": "https://api.github.com/users/kamipo",
      "avatar_url": "https://avatars.githubusercontent.com/u/12642?"
    },
    "repo": {
      "id": 8514,
      "name": "rails/rails",
      "url": "https://api.github.com/repos/rails/rails"
    },
    "payload": {
      "action": "opened",
      "number": 41898,
      "pull_request": {
        "url": "https://api.github.com/repos/rails/rails/pulls/41898",
        "id": 612882799,
        "node_id": "MDExOlB1bGxSZXF1ZXN0NjEyODgyNzk5",
        "html_url": "https://github.com/rails/rails/pull/41898",
        "diff_url": "https://github.com/rails/rails/pull/41898.diff",
        "patch_url": "https://github.com/rails/rails/pull/41898.patch",
        "issue_url": "https://api.github.com/repos/rails/rails/issues/41898",
        "number": 41898,
        "state": "open",
        "locked": false,
        "title": "Improve the payload name for `delete_all` to more appropriate",
        "user": {
          "login": "kamipo",
          "id": 12642,
          "node_id": "MDQ6VXNlcjEyNjQy",
          "avatar_url": "https://avatars.githubusercontent.com/u/12642?v=4",
          "gravatar_id": "",
          "url": "https://api.github.com/users/kamipo",
          "html_url": "https://github.com/kamipo",
          "followers_url": "https://api.github.com/users/kamipo/followers",
          "following_url": "https://api.github.com/users/kamipo/following{/other_user}",
          "gists_url": "https://api.github.com/users/kamipo/gists{/gist_id}",
          "starred_url": "https://api.github.com/users/kamipo/starred{/owner}{/repo}",
          "subscriptions_url": "https://api.github.com/users/kamipo/subscriptions",
          "organizations_url": "https://api.github.com/users/kamipo/orgs",
          "repos_url": "https://api.github.com/users/kamipo/repos",
          "events_url": "https://api.github.com/users/kamipo/events{/privacy}",
          "received_events_url": "https://api.github.com/users/kamipo/received_events",
          "type": "User",
          "site_admin": false
        },
        "body": "The payload name for `delete_all` was named \"Destroy\" in #30619 since\r\n`delete_all` was used in `record.destroy` at that time.\r\n\r\nSince ea45d56, `record.destroy` no longer relies on `delete_all`, so now\r\nwe can improve the payload name for `delete_all` to more appropriate.\r\n",
        "created_at": "2021-04-10T11:27:41Z",
        "updated_at": "2021-04-10T11:27:41Z",
        "closed_at": null,
        "merged_at": null,
        "merge_commit_sha": null,
        "assignee": null,
        "assignees": [

        ],
        "requested_reviewers": [

        ],
        "requested_teams": [

        ],
        "labels": [

        ],
        "milestone": null,
        "draft": false,
        "commits_url": "https://api.github.com/repos/rails/rails/pulls/41898/commits",
        "review_comments_url": "https://api.github.com/repos/rails/rails/pulls/41898/comments",
        "review_comment_url": "https://api.github.com/repos/rails/rails/pulls/comments{/number}",
        "comments_url": "https://api.github.com/repos/rails/rails/issues/41898/comments",
        "statuses_url": "https://api.github.com/repos/rails/rails/statuses/899ecd418666967fd1dedf6dc421611211723ab9",
        "head": {
          "label": "kamipo:delete_all_payload_name",
          "ref": "delete_all_payload_name",
          "sha": "899ecd418666967fd1dedf6dc421611211723ab9",
          "user": {
            "login": "kamipo",
            "id": 12642,
            "node_id": "MDQ6VXNlcjEyNjQy",
            "avatar_url": "https://avatars.githubusercontent.com/u/12642?v=4",
            "gravatar_id": "",
            "url": "https://api.github.com/users/kamipo",
            "html_url": "https://github.com/kamipo",
            "followers_url": "https://api.github.com/users/kamipo/followers",
            "following_url": "https://api.github.com/users/kamipo/following{/other_user}",
            "gists_url": "https://api.github.com/users/kamipo/gists{/gist_id}",
            "starred_url": "https://api.github.com/users/kamipo/starred{/owner}{/repo}",
            "subscriptions_url": "https://api.github.com/users/kamipo/subscriptions",
            "organizations_url": "https://api.github.com/users/kamipo/orgs",
            "repos_url": "https://api.github.com/users/kamipo/repos",
            "events_url": "https://api.github.com/users/kamipo/events{/privacy}",
            "received_events_url": "https://api.github.com/users/kamipo/received_events",
            "type": "User",
            "site_admin": false
          },
          "repo": {
            "id": 14544189,
            "node_id": "MDEwOlJlcG9zaXRvcnkxNDU0NDE4OQ==",
            "name": "rails",
            "full_name": "kamipo/rails",
            "private": false,
            "owner": {
              "login": "kamipo",
              "id": 12642,
              "node_id": "MDQ6VXNlcjEyNjQy",
              "avatar_url": "https://avatars.githubusercontent.com/u/12642?v=4",
              "gravatar_id": "",
              "url": "https://api.github.com/users/kamipo",
              "html_url": "https://github.com/kamipo",
              "followers_url": "https://api.github.com/users/kamipo/followers",
              "following_url": "https://api.github.com/users/kamipo/following{/other_user}",
              "gists_url": "https://api.github.com/users/kamipo/gists{/gist_id}",
              "starred_url": "https://api.github.com/users/kamipo/starred{/owner}{/repo}",
              "subscriptions_url": "https://api.github.com/users/kamipo/subscriptions",
              "organizations_url": "https://api.github.com/users/kamipo/orgs",
              "repos_url": "https://api.github.com/users/kamipo/repos",
              "events_url": "https://api.github.com/users/kamipo/events{/privacy}",
              "received_events_url": "https://api.github.com/users/kamipo/received_events",
              "type": "User",
              "site_admin": false
            },
            "html_url": "https://github.com/kamipo/rails",
            "description": "Ruby on Rails",
            "fork": true,
            "url": "https://api.github.com/repos/kamipo/rails",
            "forks_url": "https://api.github.com/repos/kamipo/rails/forks",
            "keys_url": "https://api.github.com/repos/kamipo/rails/keys{/key_id}",
            "collaborators_url": "https://api.github.com/repos/kamipo/rails/collaborators{/collaborator}",
            "teams_url": "https://api.github.com/repos/kamipo/rails/teams",
            "hooks_url": "https://api.github.com/repos/kamipo/rails/hooks",
            "issue_events_url": "https://api.github.com/repos/kamipo/rails/issues/events{/number}",
            "events_url": "https://api.github.com/repos/kamipo/rails/events",
            "assignees_url": "https://api.github.com/repos/kamipo/rails/assignees{/user}",
            "branches_url": "https://api.github.com/repos/kamipo/rails/branches{/branch}",
            "tags_url": "https://api.github.com/repos/kamipo/rails/tags",
            "blobs_url": "https://api.github.com/repos/kamipo/rails/git/blobs{/sha}",
            "git_tags_url": "https://api.github.com/repos/kamipo/rails/git/tags{/sha}",
            "git_refs_url": "https://api.github.com/repos/kamipo/rails/git/refs{/sha}",
            "trees_url": "https://api.github.com/repos/kamipo/rails/git/trees{/sha}",
            "statuses_url": "https://api.github.com/repos/kamipo/rails/statuses/{sha}",
            "languages_url": "https://api.github.com/repos/kamipo/rails/languages",
            "stargazers_url": "https://api.github.com/repos/kamipo/rails/stargazers",
            "contributors_url": "https://api.github.com/repos/kamipo/rails/contributors",
            "subscribers_url": "https://api.github.com/repos/kamipo/rails/subscribers",
            "subscription_url": "https://api.github.com/repos/kamipo/rails/subscription",
            "commits_url": "https://api.github.com/repos/kamipo/rails/commits{/sha}",
            "git_commits_url": "https://api.github.com/repos/kamipo/rails/git/commits{/sha}",
            "comments_url": "https://api.github.com/repos/kamipo/rails/comments{/number}",
            "issue_comment_url": "https://api.github.com/repos/kamipo/rails/issues/comments{/number}",
            "contents_url": "https://api.github.com/repos/kamipo/rails/contents/{+path}",
            "compare_url": "https://api.github.com/repos/kamipo/rails/compare/{base}...{head}",
            "merges_url": "https://api.github.com/repos/kamipo/rails/merges",
            "archive_url": "https://api.github.com/repos/kamipo/rails/{archive_format}{/ref}",
            "downloads_url": "https://api.github.com/repos/kamipo/rails/downloads",
            "issues_url": "https://api.github.com/repos/kamipo/rails/issues{/number}",
            "pulls_url": "https://api.github.com/repos/kamipo/rails/pulls{/number}",
            "milestones_url": "https://api.github.com/repos/kamipo/rails/milestones{/number}",
            "notifications_url": "https://api.github.com/repos/kamipo/rails/notifications{?since,all,participating}",
            "labels_url": "https://api.github.com/repos/kamipo/rails/labels{/name}",
            "releases_url": "https://api.github.com/repos/kamipo/rails/releases{/id}",
            "deployments_url": "https://api.github.com/repos/kamipo/rails/deployments",
            "created_at": "2013-11-20T02:14:22Z",
            "updated_at": "2015-02-16T07:18:13Z",
            "pushed_at": "2021-04-10T11:26:19Z",
            "git_url": "git://github.com/kamipo/rails.git",
            "ssh_url": "git@github.com:kamipo/rails.git",
            "clone_url": "https://github.com/kamipo/rails.git",
            "svn_url": "https://github.com/kamipo/rails",
            "homepage": "http://rubyonrails.org",
            "size": 186372,
            "stargazers_count": 0,
            "watchers_count": 0,
            "language": "Ruby",
            "has_issues": false,
            "has_projects": true,
            "has_downloads": true,
            "has_wiki": false,
            "has_pages": false,
            "forks_count": 1,
            "mirror_url": null,
            "archived": false,
            "disabled": false,
            "open_issues_count": 1,
            "license": {
              "key": "mit",
              "name": "MIT License",
              "spdx_id": "MIT",
              "url": "https://api.github.com/licenses/mit",
              "node_id": "MDc6TGljZW5zZTEz"
            },
            "forks": 1,
            "open_issues": 1,
            "watchers": 0,
            "default_branch": "master"
          }
        },
        "base": {
          "label": "rails:main",
          "ref": "main",
          "sha": "f7e19dee026a6cec4d50c28f84da88b0f1397c40",
          "user": {
            "login": "rails",
            "id": 4223,
            "node_id": "MDEyOk9yZ2FuaXphdGlvbjQyMjM=",
            "avatar_url": "https://avatars.githubusercontent.com/u/4223?v=4",
            "gravatar_id": "",
            "url": "https://api.github.com/users/rails",
            "html_url": "https://github.com/rails",
            "followers_url": "https://api.github.com/users/rails/followers",
            "following_url": "https://api.github.com/users/rails/following{/other_user}",
            "gists_url": "https://api.github.com/users/rails/gists{/gist_id}",
            "starred_url": "https://api.github.com/users/rails/starred{/owner}{/repo}",
            "subscriptions_url": "https://api.github.com/users/rails/subscriptions",
            "organizations_url": "https://api.github.com/users/rails/orgs",
            "repos_url": "https://api.github.com/users/rails/repos",
            "events_url": "https://api.github.com/users/rails/events{/privacy}",
            "received_events_url": "https://api.github.com/users/rails/received_events",
            "type": "Organization",
            "site_admin": false
          },
          "repo": {
            "id": 8514,
            "node_id": "MDEwOlJlcG9zaXRvcnk4NTE0",
            "name": "rails",
            "full_name": "rails/rails",
            "private": false,
            "owner": {
              "login": "rails",
              "id": 4223,
              "node_id": "MDEyOk9yZ2FuaXphdGlvbjQyMjM=",
              "avatar_url": "https://avatars.githubusercontent.com/u/4223?v=4",
              "gravatar_id": "",
              "url": "https://api.github.com/users/rails",
              "html_url": "https://github.com/rails",
              "followers_url": "https://api.github.com/users/rails/followers",
              "following_url": "https://api.github.com/users/rails/following{/other_user}",
              "gists_url": "https://api.github.com/users/rails/gists{/gist_id}",
              "starred_url": "https://api.github.com/users/rails/starred{/owner}{/repo}",
              "subscriptions_url": "https://api.github.com/users/rails/subscriptions",
              "organizations_url": "https://api.github.com/users/rails/orgs",
              "repos_url": "https://api.github.com/users/rails/repos",
              "events_url": "https://api.github.com/users/rails/events{/privacy}",
              "received_events_url": "https://api.github.com/users/rails/received_events",
              "type": "Organization",
              "site_admin": false
            },
            "html_url": "https://github.com/rails/rails",
            "description": "Ruby on Rails",
            "fork": false,
            "url": "https://api.github.com/repos/rails/rails",
            "forks_url": "https://api.github.com/repos/rails/rails/forks",
            "keys_url": "https://api.github.com/repos/rails/rails/keys{/key_id}",
            "collaborators_url": "https://api.github.com/repos/rails/rails/collaborators{/collaborator}",
            "teams_url": "https://api.github.com/repos/rails/rails/teams",
            "hooks_url": "https://api.github.com/repos/rails/rails/hooks",
            "issue_events_url": "https://api.github.com/repos/rails/rails/issues/events{/number}",
            "events_url": "https://api.github.com/repos/rails/rails/events",
            "assignees_url": "https://api.github.com/repos/rails/rails/assignees{/user}",
            "branches_url": "https://api.github.com/repos/rails/rails/branches{/branch}",
            "tags_url": "https://api.github.com/repos/rails/rails/tags",
            "blobs_url": "https://api.github.com/repos/rails/rails/git/blobs{/sha}",
            "git_tags_url": "https://api.github.com/repos/rails/rails/git/tags{/sha}",
            "git_refs_url": "https://api.github.com/repos/rails/rails/git/refs{/sha}",
            "trees_url": "https://api.github.com/repos/rails/rails/git/trees{/sha}",
            "statuses_url": "https://api.github.com/repos/rails/rails/statuses/{sha}",
            "languages_url": "https://api.github.com/repos/rails/rails/languages",
            "stargazers_url": "https://api.github.com/repos/rails/rails/stargazers",
            "contributors_url": "https://api.github.com/repos/rails/rails/contributors",
            "subscribers_url": "https://api.github.com/repos/rails/rails/subscribers",
            "subscription_url": "https://api.github.com/repos/rails/rails/subscription",
            "commits_url": "https://api.github.com/repos/rails/rails/commits{/sha}",
            "git_commits_url": "https://api.github.com/repos/rails/rails/git/commits{/sha}",
            "comments_url": "https://api.github.com/repos/rails/rails/comments{/number}",
            "issue_comment_url": "https://api.github.com/repos/rails/rails/issues/comments{/number}",
            "contents_url": "https://api.github.com/repos/rails/rails/contents/{+path}",
            "compare_url": "https://api.github.com/repos/rails/rails/compare/{base}...{head}",
            "merges_url": "https://api.github.com/repos/rails/rails/merges",
            "archive_url": "https://api.github.com/repos/rails/rails/{archive_format}{/ref}",
            "downloads_url": "https://api.github.com/repos/rails/rails/downloads",
            "issues_url": "https://api.github.com/repos/rails/rails/issues{/number}",
            "pulls_url": "https://api.github.com/repos/rails/rails/pulls{/number}",
            "milestones_url": "https://api.github.com/repos/rails/rails/milestones{/number}",
            "notifications_url": "https://api.github.com/repos/rails/rails/notifications{?since,all,participating}",
            "labels_url": "https://api.github.com/repos/rails/rails/labels{/name}",
            "releases_url": "https://api.github.com/repos/rails/rails/releases{/id}",
            "deployments_url": "https://api.github.com/repos/rails/rails/deployments",
            "created_at": "2008-04-11T02:19:47Z",
            "updated_at": "2021-04-10T10:31:47Z",
            "pushed_at": "2021-04-10T11:23:12Z",
            "git_url": "git://github.com/rails/rails.git",
            "ssh_url": "git@github.com:rails/rails.git",
            "clone_url": "https://github.com/rails/rails.git",
            "svn_url": "https://github.com/rails/rails",
            "homepage": "https://rubyonrails.org",
            "size": 233109,
            "stargazers_count": 48141,
            "watchers_count": 48141,
            "language": "Ruby",
            "has_issues": true,
            "has_projects": false,
            "has_downloads": true,
            "has_wiki": false,
            "has_pages": false,
            "forks_count": 19271,
            "mirror_url": null,
            "archived": false,
            "disabled": false,
            "open_issues_count": 700,
            "license": {
              "key": "mit",
              "name": "MIT License",
              "spdx_id": "MIT",
              "url": "https://api.github.com/licenses/mit",
              "node_id": "MDc6TGljZW5zZTEz"
            },
            "forks": 19271,
            "open_issues": 700,
            "watchers": 48141,
            "default_branch": "main"
          }
        },
        "_links": {
          "self": {
            "href": "https://api.github.com/repos/rails/rails/pulls/41898"
          },
          "html": {
            "href": "https://github.com/rails/rails/pull/41898"
          },
          "issue": {
            "href": "https://api.github.com/repos/rails/rails/issues/41898"
          },
          "comments": {
            "href": "https://api.github.com/repos/rails/rails/issues/41898/comments"
          },
          "review_comments": {
            "href": "https://api.github.com/repos/rails/rails/pulls/41898/comments"
          },
          "review_comment": {
            "href": "https://api.github.com/repos/rails/rails/pulls/comments{/number}"
          },
          "commits": {
            "href": "https://api.github.com/repos/rails/rails/pulls/41898/commits"
          },
          "statuses": {
            "href": "https://api.github.com/repos/rails/rails/statuses/899ecd418666967fd1dedf6dc421611211723ab9"
          }
        },
        "author_association": "MEMBER",
        "auto_merge": null,
        "active_lock_reason": null,
        "merged": false,
        "mergeable": null,
        "rebaseable": null,
        "mergeable_state": "unknown",
        "merged_by": null,
        "comments": 0,
        "review_comments": 0,
        "maintainer_can_modify": true,
        "commits": 1,
        "additions": 21,
        "deletions": 10,
        "changed_files": 2
      }
    },
    "public": true,
    "created_at": "2021-04-10T11:27:41Z",
    "org": {
      "id": 4223,
      "login": "rails",
      "gravatar_id": "",
      "url": "https://api.github.com/orgs/rails",
      "avatar_url": "https://avatars.githubusercontent.com/u/4223?"
    }
  }

他の type の json 構造も確認しながら、全 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

rails generate model

それでは、上記のように Migration クラスと Model クラスを作成します。Model の名前は ActivityEvent だと少し長いですし、今回は特に名前の衝突を気にするほどでもない小さなアプリケーションなので Event としておきましょう。属性は先ほどの表に示した json 構造におおよそ倣った識別子にします。先ほどの表に今回作成するアプリケーションが使う識別子の列を追加した表が以下です。

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

Ruby on Rails に少し経験のある方であれば「type はマズい!」と感じたかもしれません。typeシングルテーブル継承(STI)のサブクラスをセットする属性のデフォルトの識別子なので、普通の属性として type という識別子を使うことは避けることが多いですが、今回は GitHub が API で返してくる識別子を尊重したいので、後ほど ActiveRecord::Base.inheritance_column で退避させます[3]

id を使い回すのも本来はあまり良いことではありませんが、Ruby on Rails を採用している GitHub が管理しているアクティビティイベントの id も bigint 型な気がしますし、今回は GitHub が返してくる内容をそのまま詰め込むようなイメージで実装したいので深く考えないことにします[4]

ということで、このまま rails g model コマンドを入力します。

% rails g model Event type:string actor:string repo_name:string timestamp:datetime

生成された Migration ファイルと Model ファイルは以下のようになっているはずです。

db/migrate/yyyymmddhhnnss_create_event.rb
class CreateEvents < ActiveRecord::Migration[6.1]
  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
app/models/event.rb
class Event < ApplicationRecord
end

rails db:migrate

それではデータベースを作成し、

% rails db:create
Created database 'contribution_analyzer_development'
Created database 'contribution_analyzer_test'

migrate を実行します。

% rails db:migrate
== 20210410073222 CreateEvents: migrating =====================================
-- create_table(:events)
   -> 0.0858s
== 20210410073222 CreateEvents: migrated (0.0859s) ============================

migrate を実行すると db/schema.rb は以下のような形で作られているはずです。

db/schema.rb
ActiveRecord::Schema.define(version: 2021_04_10_073222) do

  # These are extensions that must be enabled in order to support this database
  enable_extension "plpgsql"

  create_table "events", force: :cascade do |t|
    t.string "type"
    t.string "actor"
    t.string "repo_name"
    t.datetime "timestamp"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

end

STI 回避

先ほど述べたように、シングルテーブル継承(STI)のサブクラスをセットする属性のデフォルトの識別子として使われている type という識別子を今回は STI とは関係ない目的で使いたいので、ActiveRecord::Base.inheritance_column で別の名前を指定して退避させます。よく分からない方はいわゆる「おまじない」だと思って頂いて大丈夫です[5]

app/models/event.rb
  class Event < ApplicationRecord
+   self.inheritance_column = :_type_disabled
  end

アクティビティイベント同期モジュール

環境変数

データベースの準備はできたので、GitHub から取得したアクティビティイベントを保存する処理を書いていきます。二段階認証を設定している GitHub アカウントから Organization のプライベートリポジトリにアクセスするためには、Personal Access Token が必要です。執筆現在は Developer Setting から発行することができます。これを CONTRIBUTION_ANALYZER_GITHUB_PERSONAL_ACCESS_TOKEN という環境変数にセットします。環境変数名はどんなに長くなっても、アプリケーション名のプレフィックスを付与することで用途の明確化と衝突の回避を優先するのが筆者の好みです。そして、アクティビティイベントの Organization 名も同様に CONTRIBUTION_ANALYZER_ORG_NAME という環境変数にセットします。冒頭に述べた通り、自分の Organization を持っていない場合は rails と Organization 名を指定しましょう。zsh を使っている場合は ~/.zshrc に以下のように追記して source ~/.zshrc をする等、ご利用の環境に応じて環境変数をセットしてください。

export CONTRIBUTION_ANALYZER_GITHUB_PERSONAL_ACCESS_TOKEN="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
export CONTRIBUTION_ANALYZER_ORG_NAME="rails"

実装

設計

それでは、GitHub の API からアクティビティイベントを取得してデータベースに詰め込む同期機構を実装していきましょう。実装場所は app/lib 配下や app/services 配下等といった様々な派閥がありますが、今回は autoload_path を弄る等の複雑なことをしたくないので、app/models/git_hub/activity_event.rb を作成し、GitHub::ActivityEvent という class に同期処理の全てを押し付けようと思います。インターフェースとしては、クラスメソッドで GitHub::ActivityEvent.sync とコールすれば同期処理が全て走るような設計にします。

% touch app/models/git_hub/activity_event.rb
app/models/git_hub/activity_event.rb
class GitHub::ActivityEvent
  class << sync
    def sync
      puts 'sync'
    end
  end
end

とりあえずインターフェースだけ準備したので、rails console で呼び出しのテストをしておきましょう。generator を用いずに作った Model で、名前空間によるネストも行っている class なので、ディレクトリ/ファイル名や class 名の typo 等によって class が見えないことは多いです。今回のハンズオンでは rails console をこまめに使いながら丁寧に動作を確認しながら実装を進めていきます。

% rails c
Running via Spring preloader in process 673
Loading development environment (Rails 6.1.3.1)
irb(main):001:0> GitHub::ActivityEvent.sync
sync
=> nil

GitHub API のコール

今回利用する API は List repository events ですが、このエンドポイントは /repos/{owner}/{repo}/events となっています。owner には先ほど環境変数にセットした Organization 名をセットすれば良いですが、問題は repo の方です。この API は /{owner}/events のように Organization 全体のアクティビティイベントを返すものではなく、あくまでリポジトリごとのアクティビティイベントを返すものです。つまり、Organization 全体のアクティビティイベントを取得するためには、まず全てのリポジトリの名前を取得した上で、リポジトリの数だけ /repos/{owner}/{repo}/events をコールする必要があります。全リポジトリの名前を取得する API と、リポジトリ毎のアクティビティイベントを取得する API の2つをコールしなければならないので、共通用の API コールメソッドをとりあえず準備しておきましょう。先ほど作った sync メソッドの下(class << self の中)に call_github_api メソッドを追加します。

app/models/git_hub/activity_event.rb
  def call_github_api(path)
    uri = URI.parse("https://api.github.com#{path}")
    request = Net::HTTP::Get.new(uri)
    request.basic_auth('', ENV['CONTRIBUTION_ANALYZER_GITHUB_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

少し長めのコードですが、やっていることは https://api.github.com に続いて引数 path で与えられたパスの API にアクセスし、その結果を json でパースして返しているだけです。また、リクエストヘッダーに先ほど取得した GitHub の Personal Access Token を環境変数(ENV['CONTRIBUTION_ANALYZER_GITHUB_PERSONAL_ACCESS_TOKEN'])からセットしています。例えば、Organization の詳細を返す Get an organization API を使って rails という Organization の詳細を照会する場合は call_github_api '/orgs/rails' とコールすれば良いです。call_github_api は private なメソッドにはしていないので、rails console で試してみましょう。GitHub::ActivityEvent.call_github_api '/orgs/rails' を実行すると Organization の詳細が返ってくることを確認してください。

% rails c
Running via Spring preloader in process 1989
Loading development environment (Rails 6.1.3.1)
irb(main):001:0> GitHub::ActivityEvent.call_github_api '/orgs/rails'
=> {"login"=>"rails", "id"=>4223, "node_id"=>"MDEyOk9yZ2FuaXphdGlvbjQyMjM=", "url"=>"https://api.github.com/orgs/rails", "repos_url"=>"https://api.github.com/orgs/rails/repos", "events_url"=>"https://api.github.com/orgs/rails/events", "hooks_url"=>"https://api.github.com/orgs/rails/hooks", "issues_url"=>"https://api.github.com/orgs/rails/issues", "members_url"=>"https://api.github.com/orgs/rails/members{/member}", "public_members_url"=>"https://api.github.com/orgs/rails/public_members{/member}", "avatar_url"=>"https://avatars.githubusercontent.com/u/4223?v=4", "description"=>"", "name"=>"Ruby on Rails", "company"=>nil, "blog"=>"https://rubyonrails.org/", "location"=>nil, "email"=>nil, "twitter_username"=>nil, "is_verified"=>false, "has_organization_projects"=>true, "has_repository_projects"=>true, "public_repos"=>102, "public_gists"=>3, "followers"=>0, "following"=>0, "html_url"=>"https://github.com/rails", "created_at"=>"2008-04-02T01:59:25Z", "updated_at"=>"2021-03-12T15:26:12Z", "type"=>"Organization"}

リポジトリ名の一覧を取得

次に、先ほど述べたようにアクティビティイベントを取得するためには /repos/{owner}/{repo}/events のようにリポジトリ毎のアクセスを行う必要があるので、{repo} の部分に入るリポジトリ名の一覧を取得するため、List organization repositories API を利用します。エンドポイントは /orgs/{org}/repos なので、先ほど作成した call_github_api 経由でコールするのであれば、 call_github_api '/orgs/rails/repos' のように呼び出せば良いことがわかります。実装に入る前に、まずは [List organization repositories] のレスポンスを確認してみましょう。ドキュメントを読めば実装すべきコードは推測できますが、私は実際に API を叩いてドキュメントと見比べながら開発を進めていくのが好みです。

% rails c
Running via Spring preloader in process 1989
Loading development environment (Rails 6.1.3.1)
irb(main):001:0> GitHub::ActivityEvent.call_github_api '/orgs/rails/repos'
[{"id"=>8514, "node_id"=>"MDEwOlJlcG9zaXRvcnk4NTE0", "name"=>"rails", "full_name"=>"rails/rails", "private"=>false, "owner"=>{"login"=>"rails", "id"=>4223, "node_id"=>"MDEyOk9yZ2FuaXphdGlvbjQyMjM=", "avatar_url"=>"https://avatars.githubusercontent.com/u/4223?v=4", "gravatar_id"=>""
...

膨大な量のデータが返ってくるので、とりあえず先頭の1リポジトリのデータだけを取得しましょう。.first をメソッドチェインします。

% rails c
Running via Spring preloader in process 1989
Loading development environment (Rails 6.1.3.1)
irb(main):001:0> GitHub::ActivityEvent.call_github_api('/orgs/rails/repos').first
=> {"id"=>8514, "node_id"=>"MDEwOlJlcG9zaXRvcnk4NTE0", "name"=>"rails", "full_name"=>"rails/rails", "private"=>false, "owner"=>{"login"=>"rails", "id"=>4223, "node_id"=>"MDEyOk9yZ2FuaXphdGlvbjQyMjM=", "avatar_url"=>"https://avatars.githubusercontent.com/u/4223?v=4", "gravatar_id"=>"", "url"=>"https://api.github.com/users/rails", "html_url"=>"https://github.com/rails", "followers_url"=>"https://api.github.com/users/rails/followers", "following_url"=>"https://api.github.com/users/rails/following{/other_user}", "gists_url"=>"https://api.github.com/users/rails/gists{/gist_id}", "starred_url"=>"https://api.github.com/users/rails/starred{/owner}{/repo}", "subscriptions_url"=>"https://api.github.com/users/rails/subscriptions", "organizations_url"=>"https://api.github.com/users/rails/orgs", "repos_url"=>"https://api.github.com/users/rails/repos", "events_url"=>"https://api.github.com/users/rails/events{/privacy}", "received_events_url"=>"https://api.github.com/users/rails/received_events", "type"=>"Organization", "site_admin"=>false}, "html_url"=>"https://github.com/rails/rails", "description"=>"Ruby on Rails", "fork"=>false, "url"=>"https://api.github.com/repos/rails/rails", "forks_url"=>"https://api.github.com/repos/rails/rails/forks", "keys_url"=>"https://api.github.com/repos/rails/rails/keys{/key_id}", "collaborators_url"=>"https://api.github.com/repos/rails/rails/collaborators{/collaborator}", "teams_url"=>"https://api.github.com/repos/rails/rails/teams", "hooks_url"=>"https://api.github.com/repos/rails/rails/hooks", "issue_events_url"=>"https://api.github.com/repos/rails/rails/issues/events{/number}", "events_url"=>"https://api.github.com/repos/rails/rails/events", "assignees_url"=>"https://api.github.com/repos/rails/rails/assignees{/user}", "branches_url"=>"https://api.github.com/repos/rails/rails/branches{/branch}", "tags_url"=>"https://api.github.com/repos/rails/rails/tags", "blobs_url"=>"https://api.github.com/repos/rails/rails/git/blobs{/sha}", "git_tags_url"=>"https://api.github.com/repos/rails/rails/git/tags{/sha}", "git_refs_url"=>"https://api.github.com/repos/rails/rails/git/refs{/sha}", "trees_url"=>"https://api.github.com/repos/rails/rails/git/trees{/sha}", "statuses_url"=>"https://api.github.com/repos/rails/rails/statuses/{sha}", "languages_url"=>"https://api.github.com/repos/rails/rails/languages", "stargazers_url"=>"https://api.github.com/repos/rails/rails/stargazers", "contributors_url"=>"https://api.github.com/repos/rails/rails/contributors", "subscribers_url"=>"https://api.github.com/repos/rails/rails/subscribers", "subscription_url"=>"https://api.github.com/repos/rails/rails/subscription", "commits_url"=>"https://api.github.com/repos/rails/rails/commits{/sha}", "git_commits_url"=>"https://api.github.com/repos/rails/rails/git/commits{/sha}", "comments_url"=>"https://api.github.com/repos/rails/rails/comments{/number}", "issue_comment_url"=>"https://api.github.com/repos/rails/rails/issues/comments{/number}", "contents_url"=>"https://api.github.com/repos/rails/rails/contents/{+path}", "compare_url"=>"https://api.github.com/repos/rails/rails/compare/{base}...{head}", "merges_url"=>"https://api.github.com/repos/rails/rails/merges", "archive_url"=>"https://api.github.com/repos/rails/rails/{archive_format}{/ref}", "downloads_url"=>"https://api.github.com/repos/rails/rails/downloads", "issues_url"=>"https://api.github.com/repos/rails/rails/issues{/number}", "pulls_url"=>"https://api.github.com/repos/rails/rails/pulls{/number}", "milestones_url"=>"https://api.github.com/repos/rails/rails/milestones{/number}", "notifications_url"=>"https://api.github.com/repos/rails/rails/notifications{?since,all,participating}", "labels_url"=>"https://api.github.com/repos/rails/rails/labels{/name}", "releases_url"=>"https://api.github.com/repos/rails/rails/releases{/id}", "deployments_url"=>"https://api.github.com/repos/rails/rails/deployments", "created_at"=>"2008-04-11T02:19:47Z", "updated_at"=>"2021-04-10T22:57:42Z", "pushed_at"=>"2021-04-11T00:30:44Z", "git_url"=>"git://github.com/rails/rails.git", "ssh_url"=>"git@github.com:rails/rails.git", "clone_url"=>"https://github.com/rails/rails.git", "svn_url"=>"https://github.com/rails/rails", "homepage"=>"https://rubyonrails.org", "size"=>233136, "stargazers_count"=>48146, "watchers_count"=>48146, "language"=>"Ruby", "has_issues"=>true, "has_projects"=>false, "has_downloads"=>true, "has_wiki"=>false, "has_pages"=>false, "forks_count"=>19272, "mirror_url"=>nil, "archived"=>false, "disabled"=>false, "open_issues_count"=>701, "license"=>{"key"=>"mit", "name"=>"MIT License", "spdx_id"=>"MIT", "url"=>"https://api.github.com/licenses/mit", "node_id"=>"MDc6TGljZW5zZTEz"}, "forks"=>19272, "open_issues"=>701, "watchers"=>48146, "default_branch"=>"main", "permissions"=>{"admin"=>false, "push"=>false, "pull"=>true}}
整形した json
{
    "id": 8514,
    "node_id": "MDEwOlJlcG9zaXRvcnk4NTE0",
    "name": "rails",
    "full_name": "rails/rails",
    "private": false,
    "owner": {
      "login": "rails",
      "id": 4223,
      "node_id": "MDEyOk9yZ2FuaXphdGlvbjQyMjM=",
      "avatar_url": "https://avatars.githubusercontent.com/u/4223?v=4",
      "gravatar_id": "",
      "url": "https://api.github.com/users/rails",
      "html_url": "https://github.com/rails",
      "followers_url": "https://api.github.com/users/rails/followers",
      "following_url": "https://api.github.com/users/rails/following{/other_user}",
      "gists_url": "https://api.github.com/users/rails/gists{/gist_id}",
      "starred_url": "https://api.github.com/users/rails/starred{/owner}{/repo}",
      "subscriptions_url": "https://api.github.com/users/rails/subscriptions",
      "organizations_url": "https://api.github.com/users/rails/orgs",
      "repos_url": "https://api.github.com/users/rails/repos",
      "events_url": "https://api.github.com/users/rails/events{/privacy}",
      "received_events_url": "https://api.github.com/users/rails/received_events",
      "type": "Organization",
      "site_admin": false
    },
    "html_url": "https://github.com/rails/rails",
    "description": "Ruby on Rails",
    "fork": false,
    "url": "https://api.github.com/repos/rails/rails",
    "forks_url": "https://api.github.com/repos/rails/rails/forks",
    "keys_url": "https://api.github.com/repos/rails/rails/keys{/key_id}",
    "collaborators_url": "https://api.github.com/repos/rails/rails/collaborators{/collaborator}",
    "teams_url": "https://api.github.com/repos/rails/rails/teams",
    "hooks_url": "https://api.github.com/repos/rails/rails/hooks",
    "issue_events_url": "https://api.github.com/repos/rails/rails/issues/events{/number}",
    "events_url": "https://api.github.com/repos/rails/rails/events",
    "assignees_url": "https://api.github.com/repos/rails/rails/assignees{/user}",
    "branches_url": "https://api.github.com/repos/rails/rails/branches{/branch}",
    "tags_url": "https://api.github.com/repos/rails/rails/tags",
    "blobs_url": "https://api.github.com/repos/rails/rails/git/blobs{/sha}",
    "git_tags_url": "https://api.github.com/repos/rails/rails/git/tags{/sha}",
    "git_refs_url": "https://api.github.com/repos/rails/rails/git/refs{/sha}",
    "trees_url": "https://api.github.com/repos/rails/rails/git/trees{/sha}",
    "statuses_url": "https://api.github.com/repos/rails/rails/statuses/{sha}",
    "languages_url": "https://api.github.com/repos/rails/rails/languages",
    "stargazers_url": "https://api.github.com/repos/rails/rails/stargazers",
    "contributors_url": "https://api.github.com/repos/rails/rails/contributors",
    "subscribers_url": "https://api.github.com/repos/rails/rails/subscribers",
    "subscription_url": "https://api.github.com/repos/rails/rails/subscription",
    "commits_url": "https://api.github.com/repos/rails/rails/commits{/sha}",
    "git_commits_url": "https://api.github.com/repos/rails/rails/git/commits{/sha}",
    "comments_url": "https://api.github.com/repos/rails/rails/comments{/number}",
    "issue_comment_url": "https://api.github.com/repos/rails/rails/issues/comments{/number}",
    "contents_url": "https://api.github.com/repos/rails/rails/contents/{+path}",
    "compare_url": "https://api.github.com/repos/rails/rails/compare/{base}...{head}",
    "merges_url": "https://api.github.com/repos/rails/rails/merges",
    "archive_url": "https://api.github.com/repos/rails/rails/{archive_format}{/ref}",
    "downloads_url": "https://api.github.com/repos/rails/rails/downloads",
    "issues_url": "https://api.github.com/repos/rails/rails/issues{/number}",
    "pulls_url": "https://api.github.com/repos/rails/rails/pulls{/number}",
    "milestones_url": "https://api.github.com/repos/rails/rails/milestones{/number}",
    "notifications_url": "https://api.github.com/repos/rails/rails/notifications{?since,all,participating}",
    "labels_url": "https://api.github.com/repos/rails/rails/labels{/name}",
    "releases_url": "https://api.github.com/repos/rails/rails/releases{/id}",
    "deployments_url": "https://api.github.com/repos/rails/rails/deployments",
    "created_at": "2008-04-11T02:19:47Z",
    "updated_at": "2021-04-10T22:57:42Z",
    "pushed_at": "2021-04-11T00:30:44Z",
    "git_url": "git://github.com/rails/rails.git",
    "ssh_url": "git@github.com:rails/rails.git",
    "clone_url": "https://github.com/rails/rails.git",
    "svn_url": "https://github.com/rails/rails",
    "homepage": "https://rubyonrails.org",
    "size": 233136,
    "stargazers_count": 48146,
    "watchers_count": 48146,
    "language": "Ruby",
    "has_issues": true,
    "has_projects": false,
    "has_downloads": true,
    "has_wiki": false,
    "has_pages": false,
    "forks_count": 19272,
    "mirror_url": null,
    "archived": false,
    "disabled": false,
    "open_issues_count": 701,
    "license": {
      "key": "mit",
      "name": "MIT License",
      "spdx_id": "MIT",
      "url": "https://api.github.com/licenses/mit",
      "node_id": "MDc6TGljZW5zZTEz"
    },
    "forks": 19272,
    "open_issues": 701,
    "watchers": 48146,
    "default_branch": "main",
    "permissions": {
      "admin": false,
      "push": false,
      "pull": true
    }
  }

これでも結構な情報量ですが、ドキュメントを見ても上記の結果を見ても、name にアクセスすればリポジトリ名を取得すれば良いことがわかります。

irb(main):03:0> GitHub::ActivityEvent.call_github_api('/orgs/rails/repos').first['name']
=> "rails"

API から返ってくる全リポジトリの name を取得するためには Enumerable#map を使って GitHub::ActivityEvent.call_github_api('/orgs/rails/repos').map { |repo| repo['name'] } のようにすることで、リポジトリ名の配列が得られます。

irb(main):004:0> GitHub::ActivityEvent.call_github_api('/orgs/rails/repos').map { |repo| repo['name'] }
=> ["rails", "account_location", "acts_as_list", "acts_as_nested_set", "acts_as_tree", "atom_feed_helper", "auto_complete", "continuous_builder", "deadlock_retry", "exception_notification", "http_authentication", "in_place_editing", "javascript_test", "localization", "open_id_authentication", "scaffolding", "scriptaculous_slider", "ssl_requirement", "token_generator", "tzinfo_timezone", "tztime", "upload_progress", "render_component", "country_select", "iso-3166-country-select", "irs_process_scripts", "request_profiler", "rails-contributors", "sqlite2_adapter", "fcgi_handler"]

これを踏まえて、リポジトリ名の一覧を取得するメソッドを追加しましょう。Organization は直接 rails を指定するのではなく、環境変数(ENV['CONTRIBUTION_ANALYZER_ORG_NAME'])から指定していることと、?per_page=100 を与えていることに注意してください。per_page というクエリパラメーターについては、ドキュメントからページネーションの仕様について読み取れずに参照先を提示できないため[6]、深く考えなくて ok です。API からはデフォルトで最大30件分しか返却されないため、per_page を与えることで100件までのリポジトリ名を取得することができます[7]。ドキュメントから読み取れない挙動と格闘するのはよくあることです。

app/models/git_hub/activity_event.rb
    def repo_names
      call_github_api("/orgs/#{ENV['CONTRIBUTION_ANALYZER_ORG_NAME']}/repos?per_page=100").map { |repo| repo['name'] }
    end

こちらも rails console で実際に挙動を確認しておきましょう。リポジトリ名の一覧が配列で得られれば期待通りに動いています。動かない場合は ENV['CONTRIBUTION_ANALYZER_GITHUB_PERSONAL_ACCESS_TOKEN']ENV['CONTRIBUTION_ANALYZER_ORG_NAME'] に値がセットされているかを確認してみてください。想定されるのは source ~/.zshrc を忘れている場合や、Personal Access Token のユーザーがその Organization へのアクセス権限がない場合[8]等です。

% rails c        
Running via Spring preloader in process 3341
Loading development environment (Rails 6.1.3.1)
irb(main):001:0> GitHub::ActivityEvent.repo_names
=> ["rails", "account_location", "acts_as_list", "acts_as_nested_set", "acts_as_tree", "atom_feed_helper", "auto_complete", "continuous_builder", "deadlock_retry", "exception_notification", "http_authentication", "in_place_editing", "javascript_test", "localization", "open_id_authentication", "scaffolding", "scriptaculous_slider", "ssl_requirement", "token_generator", "tzinfo_timezone", "tztime", "upload_progress", "render_component", "country_select", "iso-3166-country-select", "irs_process_scripts", "request_profiler", "rails-contributors", "sqlite2_adapter", "fcgi_handler", "arel", "asset_server", "verification", "hide_action", "dynamic_form", "prototype-ujs", "jquery-ujs", "prototype_legacy_helper", "rails_upgrade", "rails_xss", "rails-master-hook", "prototype-rails", "pjax_rails", "sprockets-rails", "sass-rails", "jquery-rails", "coffee-rails", "journey", "jbuilder", "rails.github.com", "weblog", "strong_parameters", "activeresource", "activerecord-deprecated_finders", "routing_concerns", "cache_digests", "rails-dev-box", "etagger", "activerecord-session_store", "protected_attributes", "actionpack-page_caching", "actionpack-action_caching", "rails-observers", "commands", "spring", "rails-perftest", "actionpack-xml_parser", "gsoc2013", "web-console", "rails-html-sanitizer", "rails-dom-testing", "conductor", "rails-docs-server", "activesupport-json_encoder", "gsoc2014", "activejob", "activemodel-globalid", "globalid", "rails-deprecated_sanitizer", "record_tag_helper", "actioncable", "ruby-coffee-script", "execjs", "sprockets", "rails-controller-testing", "activemodel-serializers-xml", "actioncable-examples", "rails-bot", "homepage", "rails-ujs", "webpacker", "marcel", "activestorage", "rails_fast_attributes", "actiontext", "actionmailbox", "rails-probot", "buildkite-config", "discourse-rubyonrails-theme", "tailwindcss-rails"]

各リポジトリのアクティビティイベントを取得

リポジトリ名の配列を得られたので、List repository events API(/repos/{owner}/{repo}/events) を利用することができるようになりました。まずはこの API の挙動を確認しておきましょう。とりあえず rails/rails のリポジトリのアクティビティイベントを取得してみます。

% rails c
Running via Spring preloader in process 3702
Loading development environment (Rails 6.1.3.1)
irb(main):001:0> GitHub::ActivityEvent.call_github_api '/repos/rails/rails/events'
=> [{"id"=>"15892528266", "type"=>"PullRequestReviewCommentEvent", "actor"=>{"id"=>277819, "login"=>"zzak", "display_login"=>"zzak", "gravatar_id"=>"", "url"=>"https://api.github.com/users/zzak", "avatar_url"=>"https://avatars.githubusercontent.com/u/277819?"}, "repo"=>{"id"=>8514, "name"=>"rails/rails", "url"=>"https://api.github.com/repos/rails/rails"},
...

例によって大量のデータが返却されますが、おおよそ取りたいデータが返ってきているっぽいことは何となく想像できると思います。先ほどと同様にメソッドを追加していきましょう。API から返ってきたアクティビティイベントを1次元の配列にして返すメソッドです。

app/models/git_hub/activity_event.rb
    def call_github_events_api
      repo_names.flat_map do |repo_name|
        3.times.flat_map do |page|
          call_github_api("/repos/#{ENV['CONTRIBUTION_ANALYZER_ORG_NAME']}/#{repo_name}/events?per_page=100&page=#{page}")
        end
      end
    end

repo_names のループに加えて 3.times の二重ループになっているのは、先ほどと同様に深くは考えなくて大丈夫です。前記事に以下の記述があるように、前記事執筆時点ではページネーションの仕様についてドキュメントに明記されていたのですが、本記事執筆現在は何故かページネーションの仕様についての記述が削除されてしまっているので、ここでは深く言及しないことにします。とりあえず各リポジトリに対して100件ずつを3ページで合計300件のアクティビティイベントを取得していることだけ把握できれば大丈夫です。

ドキュメントによると

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

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

アクティビティイベントをデータベースに保存する

最後に、最初に仮に作成した sync メソッドの中身を書いていきます。先ほどの call_github_events_api で返ってくるアクティビティイベントの配列をループで回してデータベースの events テーブルに書き込んでいきます。書き込む内容や参照する json 上の位置については先ほどのデータモデリングの時に設計した内容を思い出してください。

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

        next if %w[PushEvent CreateEvent DeleteEvent].include? e['type']

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

ループの中に2つの next があります。next if Event.exists?(id: e['id']) は、既に登録されているアクティビティイベントを二重で登録してしまわないように、ActiveRecord::Base.exists? で同じ ID のアクティビティイベントがデータベース上に存在していないかを確認しています。ただ、これはループの度にデータベースアクセスが起こる大変乱暴な実装なので、これを避けるためには例えば最後に同期した時刻をデータベース上等で保持しておいて、その時刻以後のアクティビティイベントだけを保存するような処理にするか、id に UNIQUE 制約と Model の uniqness バリデーションを設けて create を失敗させるか等の手段がありますが、今回はデータベースのアクセス不可も、同期処理の実行時間もそれほどこだわらないので最も楽に実装できる exists? で対応しています。

もう1つの next は next if %w[PushEvent CreateEvent DeleteEvent].include? e['type'] です。PushEvent は push する度に発行されるイベント、Create/Delete Event はリモートブランチを作成/削除した時に発行されるイベントですが、これは開発者の評価軸として貢献のポイントに加える必要がないと思ったので、ここで弾くようにしています。

さて、動作を確認してみましょう。いろんな意味で重たい処理なので制御が戻ってくるまで数分かかると思います。

% rails c
Running via Spring preloader in process 4569
Loading development environment (Rails 6.1.3.1)
irb(main):001:0> GitHub::ActivityEvent.sync

制御が戻ってきたら、データベースに期待の値が入力されているか件数やレコードの中身を確認してみましょう。以下は rails の Organization のアクティビティイベントを取得した例です。1783件のレコードが作成されています。

irb(main):002:0> Event.count
   (6.8ms)  SELECT COUNT(*) FROM "events"
=> 1783
irb(main):003:0> Event.last
  Event Load (0.2ms)  SELECT "events".* FROM "events" ORDER BY "events"."id" DESC LIMIT $1  [["LIMIT", 1]]
=> #<Event id: 15892739044, type: "IssuesEvent", actor: "siefkenj", repo_name: "rails/rails", timestamp: "2021-04-11 01:44:18.000000000 +0000", created_at: "2021-04-11 01:58:16.552038000 +0000", updated_at: "2021-04-11 01:58:16.552038000 +0000">

これでアクティビティイベント同期モジュールの実装は完了です。ここまでのコードを全て反映した app/models/git_hub/activity_event.rb の中身は以下のようになります。

app/models/git_hub/activity_event.rb
class GitHub::ActivityEvent
  class << self
    def sync
      call_github_events_api.each do |e|
        next if Event.exists?(id: e['id'])

        next if %w[PushEvent CreateEvent DeleteEvent].include? e['type']

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

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

    def repo_names
      call_github_api("/orgs/#{ENV['CONTRIBUTION_ANALYZER_ORG_NAME']}/repos?per_page=100").map { |repo| repo['name'] }
    end

    def call_github_api(path)
      uri = URI.parse("https://api.github.com#{path}")
      request = Net::HTTP::Get.new(uri)
      request.basic_auth('', ENV['CONTRIBUTION_ANALYZER_GITHUB_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
  end
end

rake タスク

実装

同期を行うために rails console を開いて GitHub::ActivityEvent.sync を実行するのは、あまりにプログラマブルで手続きも面倒なので、rake タスクとして登録しておくことにします。タスクの内容は実にシンプルで、ただ GitHub::ActivityEvent.sync を1行実行するだけです。

% touch lib/tasks/github/sync_activity_event.rake
lib/tasks/github/sync_activity_event.rake
namespace :github do
  desc "Sync GitHub's Activity Event"

  task sync_activity_events: :environment do
    GitHub::ActivityEvent.sync
  end
end

実行

rake タスクの一覧を確認するには rake -T (--tasks) を実行します。

% rake -T
rake about                              # List versions of all Rails frameworks and the environment
rake app:template                       # Applies the template supplied by LOCATION=(/path/to/template) or URL
rake app:update                         # Update configs and some other initially generated files (or use just update:configs or update:bin)
rake assets:clean[keep]                 # Remove old compiled assets
rake assets:clobber                     # Remove compiled assets
rake assets:environment                 # Load asset compile environment
rake assets:precompile                  # Compile all the assets named in config.assets.precompile
rake cache_digests:dependencies         # Lookup first-level dependencies for TEMPLATE (like messages/show or comments/_comment.html)
rake cache_digests:nested_dependencies  # Lookup nested dependencies for TEMPLATE (like messages/show or comments/_comment.html)
rake db:create                          # Creates the database from DATABASE_URL or config/database.yml for the current RAILS_ENV (use db:create:all to create all databases in the config). Without RAILS_ENV or when RAILS_ENV ...
rake db:drop                            # Drops the database from DATABASE_URL or config/database.yml for the current RAILS_ENV (use db:drop:all to drop all databases in the config). Without RAILS_ENV or when RAILS_ENV is dev...
rake db:environment:set                 # Set the environment value for the database
rake db:fixtures:load                   # Loads fixtures into the current environment's database
rake db:migrate                         # Migrate the database (options: VERSION=x, VERBOSE=false, SCOPE=blog)
rake db:migrate:down                    # Runs the "down" for a given migration VERSION
rake db:migrate:redo                    # Rolls back the database one migration and re-migrates up (options: STEP=x, VERSION=x)
rake db:migrate:status                  # Display status of migrations
rake db:migrate:up                      # Runs the "up" for a given migration VERSION
rake db:prepare                         # Runs setup if database does not exist, or runs migrations if it does
rake db:reset                           # Drops and recreates the database from db/schema.rb for the current environment and loads the seeds
rake db:rollback                        # Rolls the schema back to the previous version (specify steps w/ STEP=n)
rake db:schema:cache:clear              # Clears a db/schema_cache.yml file
rake db:schema:cache:dump               # Creates a db/schema_cache.yml file
rake db:schema:dump                     # Creates a database schema file (either db/schema.rb or db/structure.sql, depending on `config.active_record.schema_format`)
rake db:schema:load                     # Loads a database schema file (either db/schema.rb or db/structure.sql, depending on `config.active_record.schema_format`) into the database
rake db:seed                            # Loads the seed data from db/seeds.rb
rake db:seed:replant                    # Truncates tables of each database for current environment and loads the seeds
rake db:setup                           # Creates the database, loads the schema, and initializes with the seed data (use db:reset to also drop the database first)
rake db:structure:dump                  # Dumps the database structure to db/structure.sql
rake db:structure:load                  # Recreates the databases from the structure.sql file
rake db:version                         # Retrieves the current schema version number
rake github:sync_activity_events        # Sync GitHub's Activity Event
rake log:clear                          # Truncates all/specified *.log files in log/ to zero bytes (specify which logs with LOGS=test,development)
rake middleware                         # Prints out your Rack middleware stack
rake restart                            # Restart app by touching tmp/restart.txt
rake secret                             # Generate a cryptographically secure secret key (this is typically used to generate a secret for cookie sessions)
rake stats                              # Report code statistics (KLOCs, etc) from the application or engine
rake time:zones[country_or_offset]      # List all time zones, list by two-letter country code (`bin/rails time:zones[US]`), or list by UTC offset (`bin/rails time:zones[-8]`)
rake tmp:clear                          # Clear cache, socket and screenshot files from tmp/ (narrow w/ tmp:cache:clear, tmp:sockets:clear, tmp:screenshots:clear)
rake tmp:create                         # Creates tmp directories for cache, sockets, and pids
rake webpacker                          # Lists all available tasks in Webpacker
rake webpacker:binstubs                 # Installs Webpacker binstubs in this application
rake webpacker:check_binstubs           # Verifies that webpack & webpack-dev-server are present
rake webpacker:check_node               # Verifies if Node.js is installed
rake webpacker:check_yarn               # Verifies if Yarn is installed
rake webpacker:clean[keep,age]          # Remove old compiled webpacks
rake webpacker:clobber                  # Remove the webpack compiled output directory
rake webpacker:compile                  # Compile JavaScript packs using webpack for production with digests
rake webpacker:info                     # Provide information on Webpacker's environment
rake webpacker:install                  # Install Webpacker in this application
rake webpacker:install:angular          # Install everything needed for Angular
rake webpacker:install:coffee           # Install everything needed for Coffee
rake webpacker:install:elm              # Install everything needed for Elm
rake webpacker:install:erb              # Install everything needed for Erb
rake webpacker:install:react            # Install everything needed for React
rake webpacker:install:stimulus         # Install everything needed for Stimulus
rake webpacker:install:svelte           # Install everything needed for Svelte
rake webpacker:install:typescript       # Install everything needed for Typescript
rake webpacker:install:vue              # Install everything needed for Vue
rake webpacker:verify_install           # Verifies if Webpacker is installed
rake webpacker:yarn_install             # Support for older Rails versions
rake yarn:install                       # Install all JavaScript dependencies as specified via Yarn
rake zeitwerk:check                     # Checks project structure for Zeitwerk compatibility

rake db:migrate のようにデフォルトで登録されているタスクが大量に表示されますが、今回作成した rake github:sync_activity_events が登録されていることがわかります。これを実行すれば同期処理を走らせることができます。

% rake github:sync_activity_events

ただ、先ほど同期を行なったばかりで差分を確認することができないので、一度 rails db:migrate:reset をしてデータベースの中身を空っぽにしてから、改めて今回作った rake タスクを実行して、rails console でアクティビティイベントがデータベースに新しく保存されたかを確認してみることにします。

% rails db:migrate:reset
Dropped database 'contribution_analyzer_development'
Dropped database 'contribution_analyzer_test'
Created database 'contribution_analyzer_development'
Created database 'contribution_analyzer_test'
== 20210410073222 CreateEvents: migrating =====================================
-- create_table(:events)
   -> 0.0478s
== 20210410073222 CreateEvents: migrated (0.0479s) ============================

% rake github:sync_activity_events
% rails c
Running via Spring preloader in process 5227
Loading development environment (Rails 6.1.3.1)
irb(main):001:0> Event.count
   (1.1ms)  SELECT COUNT(*) FROM "events"
=> 1783

cron

余談ですが、アクティビティイベントを継続的に収集するためには、こまめに rake github:sync_activity_events を実行する必要があります。API からはリポジトリ毎に最新300件までのアクティビティしか保存されないので、300件の間が空かない感覚で rake タスクを実行することを忘れないようにしなければなりません。定期的な rake タスクの実行を自動化する方法の1つとして cron を用いる方法があります。これはあくまで余談なので詳しくは取り上げませんが、デプロイ先のサーバーが UNIX 系 OS なのであれば例えば crontab に以下のように書き込むことで、毎日0時に自動でアクティビティイベントを収集し続けることができます。こういったことを自動化することも簡単にできる、程度で良いので頭の片隅に入れておくと良いかもしれません。

0 0 * * * /bin/bash -l -c 'cd /var/www/apps/contribution-analyzer && RAILS_ENV=production bundle exec rake github:sync_activity_events --silent'

View

ルーティング

データの準備はできたので、そのデータを表示する View を実装していきましょう。今回はトップページに表示するので EventsControllerindex アクションをトップページにルーティングします。特に特別なテクニックはないのでサクサクといきましょう。

config/routes.rb
Rails.application.routes.draw do
  root 'events#index'
end
% touch app/controllers/events_controller.rb
app/controllers/events_controller.rb
class EventsController < ApplicationController
  def index; end
end

集計結果描画

完成形は以下の通りです。ポイントは月毎に table 要素を分割していることと、アクティビティイベントの typeactor の軸でカウントしていることです。

DEMO

少し長いコードなので詳しい解説は後にして、とりあえず完成形の View を以下に示します。

app/views/events/
<h1>Contribution Analyzer</h1>

<% oldest = Event.first.timestamp.to_datetime %>
<% newest = Event.last.timestamp.to_datetime %>
<% months = (oldest..newest).map(&:beginning_of_month).uniq.reverse %>

<% months.each do |month| %>
  <%= link_to month.strftime('%Y.%m'), '#' + month.strftime('%Y%m') %>
<% end %>

<% months.each do |month| %>
  <% events = Event.where(timestamp: month.beginning_of_month..month.end_of_month) %>
  <% actors = events.map(&:actor).uniq.sort %>
  <% types = events.map(&:type).uniq.sort %>

  <h2 id="<%= month.strftime('%Y%m') %>"><%= month.strftime('%Y.%m') %> (<%= events.count %>)</h2>
  
  <table>
    <thead>
      <tr>
        <th></th>
        <% actors.each do |actor| %>
          <th><%= actor %></th>
        <% end %>
      </tr>
    <thead>

    <tbody>
      <% types.each do |type| %>
        <tr>
          <th><%= type %></th>
          <% actors.each do |actor| %>
            <td><%= events.where(type: type, actor: actor).count %></td>
          <% end %>
        </tr>
      <% end %>
      <tr>
        <th>Total</th>
        <% actors.each do |actor| %>
          <td><strong><%= events.where(actor: actor).count %></strong></td>
        <% end %>
      </tr>
    </tbody>
  </table>
<% end %>

CSS は適用していないため、macOS 版 Chrome のデフォルト CSS による現状での実行結果は以下のようになります。

DEMO

Active Support による日付の操作

View に書いたコードの詳細を確認していきましょう。以下のコードは、アクティビティイベントの表示月を割り出すための処理です。最も古いアクティビティイベントと、最も新しいアクティビティイベントの範囲で、年月を取得しています。(oldest..newest).map(&:beginning_of_month).uniq.reverse は一見複雑なコードですが、例えば最も古いアクティビティイベントが2021年1月10日のもので、最も新しいアクティビティイベントが2021年4月5日のものであったとすれば、[2021年4月1日, 2021年3月1日, 2021年2月1日, 2021年1月1日] という配列を取得しています。begenning_of_month はその月の1日を返してくれる Active Support のメソッドで、日付を使った荒技を行使したい場合に活躍してくれます。表示上は新しい月順に並べたいので、古い順に並べて reverse するか、最初から新しい順に並べるかは好みの問題です。筆者は直感的なコードを書いてから後から辻褄を合わせる方が好みなので計算効率が悪くても reverse を使っています。もう1つ好みの話をすると、View にローカル変数を作ったりビジネスロジックをゴリゴリ書いたりすることは通常あまり好まれませんが、私は View だけの都合で扱うデータのために Controller のインスタンス変数を利用したり、View Helper を用いたりするのはあまり好みではなく、このように View にゴリゴリとロジックを書く方が場合にもよりますが結果的に可読性が高まることが多いと思っています。

<% oldest = Event.first.timestamp.to_datetime %>
<% newest = Event.last.timestamp.to_datetime %>
<% months = (oldest..newest).map(&:beginning_of_month).uniq.reverse %>

見出しとアンカーリンク

これは UI 上の親切ですが、ページの最上部に各月へのページ内リンクを設置しています。

<% months.each do |month| %>
  <%= link_to month.strftime('%Y.%m'), '#' + month.strftime('%Y%m') %>
<% end %>

生成されるリンクは例えば localhost:3000#202104 のようになり、各月のデータの見出しに #202104 等といった id 属性を付与することでアンカーリンクによるジャンプを実現しています。

<h2 id="<%= month.strftime('%Y%m') %>"><%= month.strftime('%Y.%m') %> (<%= events.count %>)</h2>

テーブルの描画

テーブルの描画については特に難しいことはしていませんが、データの描画順と table 要素の行と列を意識したアルゴリズムを構築しなければならないので、少し癖のあるコードに見えると思いますが、理解するためには一生懸命アルゴリズムについて考える他ありません。

<% months.each do |month| %>
  <% events = Event.where(timestamp: month.beginning_of_month..month.end_of_month) %>
  <% actors = events.map(&:actor).uniq.sort %>
  <% types = events.map(&:type).uniq.sort %>

  <h2 id="<%= month.strftime('%Y%m') %>"><%= month.strftime('%Y.%m') %> (<%= events.count %>)</h2>
  
  <table>
    <thead>
      <tr>
        <th></th>
        <% actors.each do |actor| %>
          <th><%= actor %></th>
        <% end %>
      </tr>
    <thead>

    <tbody>
      <% types.each do |type| %>
        <tr>
          <th><%= type %></th>
          <% actors.each do |actor| %>
            <td><%= events.where(type: type, actor: actor).count %></td>
          <% end %>
        </tr>
      <% end %>
      <tr>
        <th>Total</th>
        <% actors.each do |actor| %>
          <td><strong><%= events.where(actor: actor).count %></strong></td>
        <% end %>
      </tr>
    </tbody>
  </table>
<% end %>

ループ内で Active Record の countwhere 実行しているため、rails のような規模の大きな Organization のアクティビティイベントを描画するのは結構な時間がかかります。ここの問題を解決するには、あらかじめ Ruby で Event の配列をキャッシュして Ruby の計算によって count 等を算出したり、同期処理の時点でこの画面に表示するレポートに必要なデータを完成させてしまうことで同期処理の負荷を高める代わりに View ではただ表示するだけの仕組みにしたり等の方法があります。筆者が想定しているユースケースとしては、規模の小さな Organization のアクティビティイベントを四半期毎に少し確認したい程度なので、この乱暴な実装で充分だと考えています。

スタイリング

仕上げに、CSS スタイリングによるデザインを適用してみましょう。今回のハンズオンでは CSS について取り上げたくないので、外部の CSS フレームワークを利用することにします。ただ、先ほど書いた View に CSS フレームワークに依存した class 付与等をしたくないので、classless な CSS フレームワークをいろいろ試したところ、Writ から提供されている CSS が最もそれっぽい見た目になったので、こちらを採用しました。CSS は CDN で公開されているので、レイアウトファイルに一行書くだけでスタイルが適用されます。

app/views/layouts/application.html.erb
  <!DOCTYPE html>
  <html>
    <head>
      <title>ContributionAnalyzer</title>
      <meta name="viewport" content="width=device-width,initial-scale=1">
      <%= csrf_meta_tags %>
      <%= csp_meta_tag %>

      <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
      <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
+
+     <link rel="stylesheet" href="//writ.cmcenroe.me/1.0.4/writ.min.css">
    </head>

    <body>
      <%= yield %>
    </body>
  </html>

これでデザインが適用されたので、アプリケーションは完成です。お疲れ様でした。

DEMO

後書き

少し特殊な要件の Ruby on Rails アプリケーションの体験学習記事でしたが、とっても頑張って書き上げたので、実際にハンズオンを試したあなたも、読み流してきたあなたも、ここまで一気にスクロールしてきたあなたも、もしよろしければ "いいね" だけでも押して帰ってください。本記事の内容に関わらず筆者へのお問い合わせは Twitter でもメールでもお好きなルートでお願いいたします。特に PSN と Switch のフレンド申請を待っています。記事にアクセスして頂き、ありがとうございました。

脚注
  1. そんなことを可視化しなければならない理由については前記事をご覧ください ↩︎

  2. ドキュメントは頻繁に更新されているため、執筆現在と内容が異なっている可能性があります ↩︎

  3. ここは何を言っているか分からなくても大丈夫です ↩︎

  4. 仕事のプロダクションコードだったら多分別カラムにしますが ↩︎

  5. この言い方はあまり好みではありませんが... ↩︎

  6. つい最近まで書いてあったのですが、最新版のドキュメントからは何故か削除されています ↩︎

  7. rails の Organization は執筆現在102件のリポジトリがありますが... ↩︎

  8. public なrails でテストする場合は Personal Access Token のことは気にしなくて良いです ↩︎

Discussion