『入る学科間違えた高専生』の日記

プログラミングのコードを書いたりする予定です。あとは日記等。あといつまで高専生やねん

任意のリポジトリのGitカレンダーを表示するやつ作った

はじめに

久しぶりにそれっぽいやつを作りたいなと思って作りました。

nnsnodnb / git_calendar_graph — Bitbucket

例のごとくまた、BitBucketでサンプルを置いておきます。

環境

フレームワーク

BottleというPythonの軽量Webフレームワークを使いました。
多分、自分が軽量Webフレームワークを使う中で1番使用回数が多いかなぁと。

ライブラリ

Python

  • bottle==0.12.13
  • GitPython==2.1.8
  • python-dateutil==2.6.1

JavaScript

僕はJavaScriptなんて書けないし書く気もないので以下を使用させていただきました。

github.com

簡単な説明

リポジトリ自体がGitで管理されているのでその場に新たにGitリポジトリはcloneできないし mv 等の操作で追加してもゴミになるので git submodule を使いました。
./submodules/ 以下に新しく追加してBottleアプリを起動。 http://127.0.0.1:8080 にアクセスするとディレクトリのものだけがリストで表示されます。
Gitリポジトリではないものも表示されてしまうのでカレンダーを表示する画面でリポジトリかどうかの確認をしています。
Gitリポジトリであった場合は、カレンダーをD3.jsで表示します。

使い方

多分、使う人なんて滅多にいないと思いますが、

$ git clone --recursive https://bitbucket.org/nnsnodnb/git_calendar_graph.git  # サンプルで入ってる django-ios-notifications も一緒に下ろしてきます
$ cd git_calendar_graph
$ pip install -r requirements.txt  # 必要であればVirtualenv環境下で
$ git submodule add https://github.com/hoge/repository.git submodules/repository  # 新たにリポジトリをサブモジュールで追加します
$ python app.py

これで起動しました!

サンプル表示

f:id:nanashinodonbee:20180210215839p:plain

詳細について

app.py からです。

/ へのアクセス

from bottle import route, template, HTTPResponse
import constants
import os
import os.path


@route('/')
def repository():
    files = os.listdir(constants.REPOSITORY_DIR)
    data = list(
        filter(
            lambda item: os.path.isdir(os.path.join(constants.REPOSITORY_DIR, item)), files
        )
    )

    return template('repository', data=data)

サブモジュールが入ってくるであろう、ディレクトリ内のファイル、ディレクトリを全部取得します。
そのあとに、ここでは、filterを使ってますが、 ディレクトリである ものだけをリストとして作成しました。

data = [item for item in files if os.path.isdir(os.path.join(constants.REPOSITORY, item))]

で普通に良かった。仕事柄、Pythonの内包表記を使う機会が全く無いので忘れていた。

あとは bottle.template に流し込みます。今回は、Bottleが初めから持ってるテンプレートエンジンを使っています。これなんていうエンジンなんだ?

/<repository_name> へのアクセス

from bottle import route, template, HTTPResponse
from models import Calendar
import os.path


@route('/<repository_name>')
def contribute(repository_name):
    if not os.path.exists(f'submodules/{repository_name}') or not os.path.exists(f'submodules/{repository_name}/.git'):
        body = '<html><body>Git repository not found.</body></html>'
        r = HTTPResponse(body=body, status=404)
        r.set_header('Content-Type', 'text/html')
        return r

    calendar = Calendar(repository=repository_name)
    data = calendar.create_days_list()
    commits = calendar.create_calendar_data(data)

    return template('contributes', repository_name=repository_name, commits=commits)

先述の通り、Gitリポジトリであるかどうかなど問答無用で入って来られた場合のこともあるので、ここでそのディレクトリがGitリポジトリであるかどうかの判断をします。
Gitリポジトリでなかったら404です。GitリポジトリであればそのままGitカレンダーを作成していきます。
ここでは基本的に Calendar クラスを作って実装しています。
あとはデータをテンプレートに流し込んで終わりです。

静的ファイルの配信について

from bottle import route, static_files


@route('/static/<file_path>')
def static(file_path):
    return static_file(file_path, root='./static')

デフォルトで使える bottle.static_files を使っています。 app.py と同ディレクトリに static フォルダを作ってそれにCSSやらJavaScriptやらをぶち込みました。


次、 models.py についてです。
一応、 Calendar クラスはモデル的なロジックを持っているのでモジュール名は models にしてます。なんで複数形なのかは知りません。

from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
from git.repo import Repo
import os.path
import constants
import multiprocessing


class Calendar(object):

    def __init__(self, repository: str, now: datetime=datetime.now()):
        self.repository = repository
        self.now = self._convert_day_first(now)
        self.year_ago = self.now - relativedelta(years=1)

    # その日の 00:00:00 を取得
    @staticmethod
    def _convert_day_first(now) -> datetime:
        return now - timedelta(hours=now.hour, minutes=now.minute, seconds=now.second)

    # 1年前から今日までの日付をstr型でlistで作成
    def create_days_list(self) -> list:
        data = []
        for day in range((self.now - self.year_ago).days + 1):
            data.append((self.year_ago + timedelta(day)).strftime('%Y-%m-%d'))

        return data

    # 指定日の総Contribute数を取得
    def _get_data_dictionary(self, date: str) -> dict:
        repo = Repo(os.path.join(constants.REPOSITORY_DIR, self.repository))
        commits = repo.iter_commits(rev='master', since=f'{date} 00:00+09:00', until=f'{date} 23:59:59+09:00')
        return {
            'date': date,
            'count': len(list(commits))
        }

    # 1年間のデータ配列を作成
    def create_calendar_data(self, data: list) -> list:
        pool = multiprocessing.Pool(multiprocessing.cpu_count())
        commits = pool.map(self._get_data_dictionary, data)
        pool.close()
        return commits

インスタンス生成時に、リポジトリ名と今日の日付と1年前の日付データを取得しておきます。
create_days_list メソッドでは、1年前から今日の日付までのデータをリストで生成です。 (例: 2018-02-10)
_get_data_dictionary メソッドは一応、プロテクト的なあれで、外部から呼ばれたくないので _ を前置してます。
内容自体は、 GitPython で日にちを指定して git log を取得して repo.iter_commits で取得した generator をlistにして長さを取得してます。そうするとその日のコミット数を取得できました。
create_calendar_data メソッドは、 app.py から全部の日付データを受け取って multiprocessing を使って _get_data_dictionary に流しています。
ここのメソッドを呼ばないと中々に時間を吸収されます。今回は、コア数分の8つのプロセスで計算させています。それ以上しても D3.jsレンダリング速度とかもあるので変わんなかったです。
8以下は試してないですが...

追記:
_get_data_dictionary で作ってる repo 毎回作られてるしこれをイニシャライザで作ることによってもうちょっと計算速度上げられるかと思った。多分誤差


最後に、JavaScriptです。基本コピペなのでテンプレートエンジンのシンタックスの部分しか書いてないです。

var chartData = [];
% for _commit in commits:
    chartData.push({
        date: moment("{{ _commit['date'] }}").toDate(),
        count: {{ _commit['count'] }}
    });
% end

var heatmap = calendarHeatmap()
    .data(chartData)
    .selector('#calendar')
    .tooltipEnabled(true)
    .colorRange(['#f4f7f7', '#af5916'])
    .onClick(function (data) {
        console.log('data', data);
    });
heatmap();

最後に

前々から、GitHubのカレンダーみたいなのを作ってみたいと思ってたので、やれてよかったです。
基本的にフロントエンドは書きたくないので、また何かしらの機会があれば、これを使った何かしらのそれを作っていきたいです。
あれ?作文?

Aqoursのライブに関係者席で入らせてください!