【Python】GitHubリポジトリからマージ済みPull-Requestを取得、リードタイムやサイズを可視化

# はじめに
GitHubの指定リポジトリからマージ済みのPull-Request(PR)データを取得し、.parquetや.csv形式で出力するツールを**Python**で作りました。

https://github.com/Trippy3/aggregate_pr

出力したparquetファイルを[Rill](https://www.rilldata.com/)で可視化すると以下のような結果が得られます。(Rillについては記事の後半で改めて紹介します)


![rill_top.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2691924/1edbd464-728a-f289-271d-c622dfd64209.png)
ブラウザ上でぐりぐり動かすことも可能です。

![rill.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2691924/af8a13f2-0956-0ffc-0648-7cced9e29191.gif)


### ツール開発の動機　
1. 最近追っている「[Polars](https://www.pola.rs/)」を使って何か作りたかった
1. 開発者生産性指標のひとつとして「PRを集計すること」に興味があった

:::note warn
背景を語り始めると長くなるため、これ以上は最後に感想として記載します。
:::

### 本記事について
:::note info
本記事ではツールで使用した技術や実装方法の紹介をメインに行います。
ツールの具体的な使い方やソースコードの詳細については[リポジトリ](https://github.com/Trippy3/aggregate_pr)をご覧ください :pray: 
:::

ツールで使用した各ライブラリーのバージョンは以下になります。
~~~
polars==0.16.7
duckdb==0.7.1
rill version 0.21.0
~~~

# 構成
ツールはざっくり以下のような構成とフローになっています。
![fig_aggregate.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2691924/430eccee-8519-bf84-a292-6ac8046110ab.png)

- I. GitHubの[GraphQL API](https://docs.github.com/ja/graphql)を使用して、指定リポジトリからデータを取得
- II. 取得したJSON形式のデータをパースし、[Polars](https://www.pola.rs/)のデータフレームを作成
- III. データフレームを[DuckDB](https://duckdb.org/)へ書き込み、DBから.parquetと.csvファイルを出力
- IV. 出力した.parquetを[Rill](https://www.rilldata.com/)で可視化と解析

:::note warn
とりあえず内部ツールとして動かす想定で開発を始めたので、コアな機能以外では足りていない部分が現状色々とあります…
(「access tokenのセキュアな取り扱い」や「DBの厳密な排他制御」などなど)
:::

# I. GitHubリポジトリからマージ済みPRデータの取得
マージ済みPRを取得するために使用したqueryが以下になります。
<details><summary>query(長いので折りたたんでいます)</summary>

~~~py
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
                }
            }
        }
    }
    """
)
~~~
</details>

[パブリックスキーマ](https://docs.github.com/ja/graphql/overview/public-schema)ページにある`schema.docs.graphql`と[APIリファレンス](https://docs.github.com/ja/graphql/reference/objects#pullrequest)を見ながら、各フィールドの定義は調べました。特筆すべき事項を抜粋します。

- `pullRequests`に`states: MERGED`を指定することでマージ済みのPRだけが取得対象になります。
- PRが**作成されてからマージされるまで**のリードタイムを測るため、`createdAt`と`mergedAt`を指定しています。
- PRのサイズを測るため、`additions`と`deletions`を指定しています。
なお、[REST API](https://docs.github.com/ja/rest/pulls/pulls?apiVersion=2022-11-28#get-a-pull-request)の場合は`additions`と`deletions`を取得するためにPRのnumberが常に必要です。これだとPRの数だけqueryを発行しないといけなくなるため、今回はREST APIではなくGraphQL APIを採用しています。
ただしQraphQLの場合はRESTとは異なり、パブリックリポジトリであってもaccess tokenによる**認証が必要**になります。[参考](https://docs.github.com/ja/graphql/guides/forming-calls-with-graphql#graphql%E3%81%A7%E3%81%AE%E8%AA%8D%E8%A8%BC)
- 一度に取得できるPRの上限数は100になります。
100件以上取得したい場合、PRの`cursor`値を指定して再度queryを発行する必要があります。これは`pageInfo`を取得すれば実現可能です。簡単なイメージ図が以下になります。

![pr_paging.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2691924/e7343c16-e366-d65b-0f60-93ea886a1008.png)

実際のデータ取得には`requests`ライブラリーを使用しています。
~~~py
res = requests.post("https://api.github.com/graphql", json={"query": query}, headers=headers)
~~~


# II. 取得データのパース
I.で取得したデータをPolarsのデータフレームに変換するため各種パース作業を行っています。
正直に言うと、データ解析に明るい人であればもっとスマートなパース方法を思いつきそうなため、ここは改善の余地が大きくあります。

### dataclass定義
PolarsはApache Arrowをベース[^1]にした列指向データモデルを採用しています。以下のような列単位のフィールドを持つdataclassを用意すれば、簡単にデータフレームへの変換ができます。
[^1]: 正確にはApache Arrowの仕様に基づく[Arrow2](https://github.com/jorgecarleitao/arrow2)というRustのCrateをベースにしています。

~~~py
@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に合わせる必要があり、がちゃがちゃと処理を書いています。
~~~py
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の辞書要素にドット(`.`)アクセスするための便利な[ライブラリー](https://github.com/fierte-product-development/Standard-Library-Extension#dict_wrapper)です。
リードタイムとサイズ(`additions`と`deletions`の和)はAPIで取得できないため、ここで合わせて算出しています。



# III. DBへの保存とファイル出力

DuckDBはつい最近のv0.7.0からPolarsと連携できるようになりました。また、ローカルファイルとして手軽に扱えることもあり今回採用しました。

https://duckdb.org/2023/02/13/announcing-duckdb-070.html
> 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ファイルへのコネクションを張っている前提です。

### 新規テーブル作成
~~~py
conn.sql("CREATE TABLE merged_pr AS SELECT * FROM ldf")
~~~
`ldf`はPolarsのデータフレームになります。変数ではなく文字列で渡していることに違和感を覚えるかもしれませんが、DuckDB側で文字列をパースするためこの形式で大丈夫です。

### テーブル更新
~~~py
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`](https://pola-rs.github.io/polars/py-polars/html/reference/lazyframe/api/polars.LazyFrame.join.html#polars.LazyFrame.join)メソッドに`how="anti"`を指定して実現しています。
なお、マージ済みPRのラベルやマイルストーンをデータ取得後に編集した場合、上記仕様では更新対象になりません。こういったケースは割り切って、現状は新規に全PRを取得してもらう方針です。


### ParquetとCSV出力
~~~py
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リファレンスをご覧ください。

https://duckdb.org/docs/api/python/reference/

# IV. Rillによる可視化
Rillは手持ちのデータからダッシュボードや解析モデルをWebブラウザ上で簡単に作成・操作できるS/Wです。Developer版はOSSとして公開されています。

https://www.rilldata.com/
> 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版のインストールや起動方法については以下をご覧ください。

https://docs.rilldata.com/

基本的には以下コマンドを叩くだけでインストールと実行が可能な認識です。
~~~bash
curl -s https://cdn.rilldata.com/install.sh | bash
rill start
~~~

Webブラウザ上でインタラクティブに操作できるだけでなく、各画面要素の設定がファイルで記述されているため、管理や運用が便利だと個人的に感じています。
例えば、記事冒頭のダッシュボードは以下yamlファイルで構成されています。

~~~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: ""
~~~



# 感想
https://github.com/Trippy3/aggregate_pr

個人的に良かったことをまとめます。
- 採用した技術要素に少し詳しくなれた。PolarsもDuckDBもRill(Developer版)もOSSなので、今後コミットできれば良いな〜と思っています。
    - ちなみに開発中docstringの間違いに気づき、Polars本体にすごく小さなコミットをすることができたので良かったです:laughing:
- コーディングの感覚を少し取り戻すことができた。(普段がマネジメント業務なので)
- Publicリポジトリで開発を行うことで、[Codecov](https://about.codecov.io/)など使ってみたかった連携サービスを試すことができた。

残りは開発の動機をもう少し掘り下げようと思います。Polarsを使って何か作りたいなというところから始まり、モチベーションを維持するため、普段のEM業務でも役立てられそうなデータ解析と結びつけることにしました。比較的簡単に取得できそうだと思い、マージ済みのPRを取得対象に選定しました。

実は市販のツール[^2]を使えば、GitHubだけでなくJiraやSlackといったサービスとも簡単に連携ができ、PRだけでなく様々な指標が取得可能です。ただし、無料のものは期間や機能に制限があったりするので、自分でも試しに簡単なものが作れないかなと思い立った背景も動機のひとつだったりします。
もちろん、私が作ったツールは市販のものには遠く及ばないです。一方で市販のツールにある機能をどうやったら自分のツールに取り込めるだろうか、想像を膨らますきっかけがもらえたのは良かったです。

[^2]: 私のおすすめは[Swarmia](https://www.swarmia.com/)です。14名まで無料であり使いやすいです。最近は国内発のサービスもいくつか登場してきているので、そちらも注目しています。


ちなみに以前書いたPolarsの紹介記事がこちらになります。(最後に宣伝です:bow_tone2:)

https://zenn.dev/hiro_torii/articles/06d7e845e146ee