はじめに
GitHubの指定リポジトリからマージ済みのPull-Request(PR)データを取得し、.parquetや.csv形式で出力するツールをPythonで作りました。
出力したparquetファイルをRillで可視化すると以下のような結果が得られます。(Rillについては記事の後半で改めて紹介します)
ツール開発の動機
- 最近追っている「Polars」を使って何か作りたかった
- 開発者生産性指標のひとつとして「PRを集計すること」に興味があった
背景を語り始めると長くなるため、これ以上は最後に感想として記載します。
本記事について
本記事ではツールで使用した技術や実装方法の紹介をメインに行います。
ツールの具体的な使い方やソースコードの詳細についてはリポジトリをご覧ください
ツールで使用した各ライブラリーのバージョンは以下になります。
polars==0.16.7
duckdb==0.7.1
rill version 0.21.0
構成
- I. GitHubのGraphQL APIを使用して、指定リポジトリからデータを取得
- II. 取得したJSON形式のデータをパースし、Polarsのデータフレームを作成
- III. データフレームをDuckDBへ書き込み、DBから.parquetと.csvファイルを出力
- IV. 出力した.parquetをRillで可視化と解析
とりあえず内部ツールとして動かす想定で開発を始めたので、コアな機能以外では足りていない部分が現状色々とあります…
(「access tokenのセキュアな取り扱い」や「DBの厳密な排他制御」などなど)
I. GitHubリポジトリからマージ済みPRデータの取得
マージ済みPRを取得するために使用したqueryが以下になります。
query(長いので折りたたんでいます)
PR_QUERY = Template(
"""
query {
repository(owner: "$owner", name: "$reponame") {
pullRequests(last: 100, states: MERGED, $before) {
nodes {
number
title
author {
login
}
labels(first: 10) {
nodes {
name
}
}
milestone {
title
}
createdAt
mergedAt
additions
deletions
changedFiles
url
}
pageInfo {
hasPreviousPage
startCursor
}
}
}
}
"""
)
パブリックスキーマページにあるschema.docs.graphql
とAPIリファレンスを見ながら、各フィールドの定義は調べました。特筆すべき事項を抜粋します。
-
pullRequests
にstates: MERGED
を指定することでマージ済みのPRだけが取得対象になります。 - PRが作成されてからマージされるまでのリードタイムを測るため、
createdAt
とmergedAt
を指定しています。 - PRのサイズを測るため、
additions
とdeletions
を指定しています。
なお、REST APIの場合はadditions
とdeletions
を取得するためにPRのnumberが常に必要です。これだとPRの数だけqueryを発行しないといけなくなるため、今回はREST APIではなくGraphQL APIを採用しています。
ただしQraphQLの場合はRESTとは異なり、パブリックリポジトリであってもaccess tokenによる認証が必要になります。参考 - 一度に取得できるPRの上限数は100になります。
100件以上取得したい場合、PRのcursor
値を指定して再度queryを発行する必要があります。これはpageInfo
を取得すれば実現可能です。簡単なイメージ図が以下になります。
実際のデータ取得にはrequests
ライブラリーを使用しています。
res = requests.post("https://api.github.com/graphql", json={"query": query}, headers=headers)
II. 取得データのパース
I.で取得したデータをPolarsのデータフレームに変換するため各種パース作業を行っています。
正直に言うと、データ解析に明るい人であればもっとスマートなパース方法を思いつきそうなため、ここは改善の余地が大きくあります。
dataclass定義
PolarsはApache Arrowをベース1にした列指向データモデルを採用しています。以下のような列単位のフィールドを持つdataclassを用意すれば、簡単にデータフレームへの変換ができます。
@dataclass
class PullReqData:
number: list[int] = field(default_factory=list)
title: list[str] = field(default_factory=list)
user: list[str | None] = field(default_factory=list)
labels: list[str | None] = field(default_factory=list)
milestone: list[str | None] = field(default_factory=list)
created_at: list[datetime] = field(default_factory=list)
merged_at: list[datetime] = field(default_factory=list)
read_time_hr: list[float] = field(default_factory=list)
additions: list[int] = field(default_factory=list)
deletions: list[int] = field(default_factory=list)
difference: list[int] = field(default_factory=list)
changed_files: list[int] = field(default_factory=list)
url: list[str] = field(default_factory=list)
def to_lazyframe(self) -> pl.LazyFrame:
return pl.DataFrame(asdict(self)).lazy()
パース処理
一方でGitHubからはPR単位(行単位)のデータが返ってくるため、これを上のdataclassに合わせる必要があり、がちゃがちゃと処理を書いています。
prd = PullReqData()
for pull_request in json:
pr = AttrDict(pull_request)
prd.number.append(pr.number)
prd.title.append(pr.title)
prd.user.append(pr.author.login if pr.author else None)
# TODO: Check if the .parquet data type accepts list[str]
prd.labels.append(",".join([lable["name"] for lable in pr.labels.nodes]) if pr.labels.nodes else None)
prd.milestone.append(pr.milestone.title if pr.milestone else None)
prd.created_at.append(created_at := datetime.strptime(pr.createdAt, "%Y-%m-%dT%H:%M:%S%z"))
prd.merged_at.append(merged_at := datetime.strptime(pr.mergedAt, "%Y-%m-%dT%H:%M:%S%z"))
prd.read_time_hr.append(round((merged_at - created_at).total_seconds() / (60 * 60), 2)) # sec. to hour
prd.additions.append(pr.additions)
prd.deletions.append(pr.deletions)
prd.difference.append(pr.additions + pr.deletions)
prd.changed_files.append(pr.changedFiles)
prd.url.append(pr.url)
AttrDict
はPythonの辞書要素にドット(.
)アクセスするための便利なライブラリーです。
リードタイムとサイズ(additions
とdeletions
の和)はAPIで取得できないため、ここで合わせて算出しています。
III. DBへの保存とファイル出力
DuckDBはつい最近のv0.7.0からPolarsと連携できるようになりました。また、ローカルファイルとして手軽に扱えることもあり今回採用しました。
Polars Integration. This release adds support for tight integration with the Polars DataFrame library, similar to our integration with Pandas DataFrames. Results can be converted to Polars DataFrames using the .pl() function.
以下の操作はconn = duckdb.connect("hoge.db")
とDBファイルへのコネクションを張っている前提です。
新規テーブル作成
conn.sql("CREATE TABLE merged_pr AS SELECT * FROM ldf")
ldf
はPolarsのデータフレームになります。変数ではなく文字列で渡していることに違和感を覚えるかもしれませんが、DuckDB側で文字列をパースするためこの形式で大丈夫です。
テーブル更新
def get_only_additional(new: pl.LazyFrame) -> pl.LazyFrame:
original = self.to_ldf()
return new.join(original, on="number", how="anti")
only_add = get_only_additional(ldf) # noqa
conn.sql("INSERT INTO merged_pr SELECT * FROM only_add")
現状のツールではPRのnumberを比較して、オリジナルのDBに存在しないもの(つまり新規に取得したPR)だけを書き込むようにしています。Polarsのjoin
メソッドにhow="anti"
を指定して実現しています。
なお、マージ済みPRのラベルやマイルストーンをデータ取得後に編集した場合、上記仕様では更新対象になりません。こういったケースは割り切って、現状は新規に全PRを取得してもらう方針です。
ParquetとCSV出力
TOP_TABLE = "merged_pr"
ALL_SELECT = f"SELECT * FROM {TOP_TABLE}"
conn.sql(f"{ALL_SELECT}").write_parquet("pr.parquet")
conn.sql(f"{ALL_SELECT}").write_csv("pr.csv", header=True)
DuckDBではsql
で返ってきた結果をそのまま外部ファイルへ出力することが可能です。
その他に使えるメソッドや引数の詳細はAPIリファレンスをご覧ください。
IV. Rillによる可視化
Rillは手持ちのデータからダッシュボードや解析モデルをWebブラウザ上で簡単に作成・操作できるS/Wです。Developer版はOSSとして公開されています。
Quick decisions need quick analytics. By combining a SQL-based data modeler, high-speed database, and interactive dashboard into a single product, Rill makes metrics exploration easy — a simple alternative to complex BI stacks.
Developer版のインストールや起動方法については以下をご覧ください。
基本的には以下コマンドを叩くだけでインストールと実行が可能な認識です。
curl -s https://cdn.rilldata.com/install.sh | bash
rill start
Webブラウザ上でインタラクティブに操作できるだけでなく、各画面要素の設定がファイルで記述されているため、管理や運用が便利だと個人的に感じています。
例えば、記事冒頭のダッシュボードは以下yamlファイルで構成されています。
# Visit https://docs.rilldata.com/references/project-files to learn more about Rill project files.
display_name: "pr_dashboard"
model: "pr_model"
timeseries: "merged_at"
measures:
- label: PR count
expression: count(*)
description: Total merged PR count.
format_preset: humanize
- label: "PR size[line]"
expression: "sum(difference)/count(difference)"
description: "Avg. merged PR size[line]"
format_preset: humanize
- label: "PR read time[hr]"
expression: "sum(read_time_hr)/count(read_time_hr)"
description: "Avg. merged PR read time [ht]"
format_preset: humanize
dimensions:
- label: Title
property: title
description: ""
- label: User
property: user
description: ""
- label: Labels
property: labels
description: ""
- label: Milestone
property: milestone
description: ""
感想
個人的に良かったことをまとめます。
- 採用した技術要素に少し詳しくなれた。PolarsもDuckDBもRill(Developer版)もOSSなので、今後コミットできれば良いな〜と思っています。
- ちなみに開発中docstringの間違いに気づき、Polars本体にすごく小さなコミットをすることができたので良かったです
- コーディングの感覚を少し取り戻すことができた。(普段がマネジメント業務なので)
- Publicリポジトリで開発を行うことで、Codecovなど使ってみたかった連携サービスを試すことができた。
残りは開発の動機をもう少し掘り下げようと思います。Polarsを使って何か作りたいなというところから始まり、モチベーションを維持するため、普段のEM業務でも役立てられそうなデータ解析と結びつけることにしました。比較的簡単に取得できそうだと思い、マージ済みのPRを取得対象に選定しました。
実は市販のツール2を使えば、GitHubだけでなくJiraやSlackといったサービスとも簡単に連携ができ、PRだけでなく様々な指標が取得可能です。ただし、無料のものは期間や機能に制限があったりするので、自分でも試しに簡単なものが作れないかなと思い立った背景も動機のひとつだったりします。
もちろん、私が作ったツールは市販のものには遠く及ばないです。一方で市販のツールにある機能をどうやったら自分のツールに取り込めるだろうか、想像を膨らますきっかけがもらえたのは良かったです。
ちなみに以前書いたPolarsの紹介記事がこちらになります。(最後に宣伝です)