0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

最近の自分の GitHub コミット数を可視化する Python スクリプト (標準ライブラリのみ)

0
Posted at

GitHub では自分のプロフィールページにここ一年の毎日のコミット状況が緑色の濃さで表示されますが、「もっと最近だけでいいから色の濃さだけでなくコミット数を一覧表示したい」「最近の自分のコミット一覧を表示したい」ということもあると思います。

GitHub API を叩けばコミット情報は取れますが、特にいくつものリポジトリで開発している場合は、それぞれのリポジトリのコミット情報を得てまとめる必要があります。そこで、各リポジトリのコミットを集めて日ごとに集計して可視化する Python スクリプトを書きました。標準ライブラリのみで動きます。

スクリプトを実行すると以下のように棒グラフ的に自分のここ 1 週間のコミット数を可視化します (集計期間は変更可能です)。

image.png

また、詳細オプション -d を付けると各日付に具体的にどのようなコミットをしたかも表示します。このとき、特定のリポジトリ名に文字色を付けることも可能です。

image.png

スクリプト

  • 自分がオーナーであるリポジトリへの自分のコミットを対象とします (そうでない場合は GitHubApi.get_reposGitHubApi.get_commits の修正が必要です)。
  • Personal access tokens (PAT) を発行して利用する想定です。
    • 未発行の場合 Settings -> Developer Settings > Personal access tokens -> Fine-grained tokens から発行して Contents に Read 権限を付けてください。
    • 発行した PAT は if __name__ == '__main__': で pat に代入してください (下記の例では ~/.himitsugithub_pat = "github_pat_XXXXXX..." を書いておいて読み込みますが別の方法でも構いません)。
    • 対象リポジトリがパブリックリポジトリのみの場合は PAT がなくても情報取得できますが、GitHubApi.get_repos の修正が必要です (自分のユーザ名を指定した URL に)。また、PAT なしの場合はリクエスト数が制約されます。
  • GitHubApi.get_reposGitHubApi.get_commitsn_max には「集計期間にコミットするリポジトリ数はこれを超えないだろう」「集計期間で1リポジトリあたりにコミットする数はこれを超えないだろう」という数を入れてください。大きくすれば情報取得漏れを防げますが、アクセスが重くなるかもしれません。
  • Plotter.__init__ で以下を制御できます。
    • 集計期間 (本日までの何日間を集計するか) (本日を含む)。
    • 集計対象 (全てのリポジトリ / パブリックのみ / プライベートのみ)。
    • タイムゾーン (各日付にグルーピングするのに必要なため)。
    • リポジトリ名の文字色 (詳細表示時のみ使用)。リポジトリ名をキーとし色名を値とした辞書を渡すとリポジトリ名表示時に色を付けられます。辞書にないリポジトリ名には色は付きません。
  • 以下ではその日の「1, 2 コミット目」「3, 4 コミット目」「5 コミット目以降」で緑色の濃さを分けますが、カスタマイズしたい場合は Plotter.create_barplot (と Plotter.color) の変更が必要です。
github.py
from datetime import datetime, timedelta, timezone
from urllib.request import Request, urlopen
from urllib.parse import urlencode
from pathlib import Path
import json
import tomllib
from collections import defaultdict
import argparse


class GitHubApi:
    def __init__(self, pat):  # pat = personal access token
        self.pat = pat  # github_pat_XXXXXX...

    def _get(self, url, params):
        req = Request(f'{url}?{urlencode(params)}')
        req.add_header('Authorization', f'Bearer {self.pat}')
        with urlopen(req) as res:
            data = json.load(res)
        return data

    def get_repos(self, visibility, n_max=20):
        url = 'https://api.github.com/user/repos'
        params = {'visibility': visibility, 'sort': 'updated', 'per_page': n_max}
        return self._get(url, params)

    def get_commits(self, owner, repo_name, since, n_max=50):
        url = f'https://api.github.com/repos/{owner}/{repo_name}/commits'
        params = {'author': owner, 'since': since, 'per_page': n_max}
        return self._get(url, params)


class Plotter:
    def __init__(self, pat, n_days=7, repo_colors=None):  # pat = personal access token
        self.gh = GitHubApi(pat)
        self.n_days = n_days
        self.visibility = 'all'  # all, public, private
        self.tz = timezone(timedelta(hours=9))  # JST
        self.set_date()
        self.repo_colors = {} if repo_colors is None else repo_colors

    def set_date(self):
        self.now = datetime.now(self.tz)
        day_since = (self.now - timedelta(days=(self.n_days - 1))).date()
        since_jst = datetime.combine(day_since, datetime.min.time(), self.tz)
        self.since = since_jst.astimezone(timezone.utc).isoformat()

    def to_dt(self, ts):
        return datetime.fromisoformat(ts.replace('Z', '+00:00')).astimezone(self.tz)

    def collect_commits(self):
        repos = self.gh.get_repos(self.visibility)
        commits_all = defaultdict(list)
        for repo in repos:
            owner = repo['owner']['login']
            repo_name = repo['name']
            commits = self.gh.get_commits(owner, repo_name, self.since)
            for c in commits:
                dt = self.to_dt(c['commit']['committer']['date'])
                commits_all[dt.date()].append({
                    'repo': repo_name, 'time': dt.strftime('%H:%M'),
                    'msg': c['commit']['message'].splitlines()[0],
                })
        return commits_all

    @staticmethod
    def dt_to_str(dt):
        dow = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][dt.weekday()]
        return dow + ', ' + dt.strftime('%b') + ' ' + str(dt.day).rjust(2)

    @staticmethod
    def color(s, color_name):
        aes = lambda x: f'\033[{x}m'
        d = {
            'gray': 90, 'green': 92, 'yellow': 93, 'blue': 94,
            'magenta': 95, 'cyan': 96, 'yellowgreen': '38;5;76',
            'green 1': '38;2;172;238;187',
            'green 3': '38;2;74;194;107',
            'green 5': '38;2;45;146;78',
        }
        return aes(d.get(color_name, 0)) + s + aes(0)

    @staticmethod
    def create_barplot(n):
        return ' '.join([
            Plotter.color('\u25A0', 'green ' + str(1 if i <= 1 else (3 if i <= 3 else 5)))
            for i in range(n)
        ])

    def _format_repo(self, repo_name):
        return Plotter.color(repo_name.ljust(18), self.repo_colors.get(repo_name, '-'))

    def plot(self, detail):
        commits_all = self.collect_commits()
        day = self.now.date()
        for i in range(self.n_days):
            commits = sorted(commits_all[day], key=lambda c: c['time'])
            n = len(commits)
            msg = f'- achieved {n:>2} commit' + ('s!' if n != 1 else '! ') if n else ''
            print(f'{Plotter.dt_to_str(day)} {msg} {Plotter.create_barplot(n)}')
            if detail:
                for c in commits:
                    print(' '.join([c['time'], self._format_repo(c['repo']), c['msg']]))
                print()
            day -= timedelta(days=1)


if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('-d', '--detail', action='store_true')
    args = parser.parse_args()

    # Get personal access token
    # github_pat = "github_pat_XXXXXX..."
    pat = tomllib.loads(
        Path('~/.himitsu').expanduser().read_text(encoding='utf8'),
    )['github_pat']

    plotter = Plotter(pat, n_days=7, repo_colors={'cookipedia': 'blue'})
    plotter.plot(args.detail)
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?