6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

はじめに

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

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

rill_top.png
ブラウザ上でぐりぐり動かすことも可能です。

rill.gif

ツール開発の動機 

  1. 最近追っている「Polars」を使って何か作りたかった
  2. 開発者生産性指標のひとつとして「PRを集計すること」に興味があった

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

本記事について

本記事ではツールで使用した技術や実装方法の紹介をメインに行います。
ツールの具体的な使い方やソースコードの詳細についてはリポジトリをご覧ください :pray:

ツールで使用した各ライブラリーのバージョンは以下になります。

polars==0.16.7
duckdb==0.7.1
rill version 0.21.0

構成

ツールはざっくり以下のような構成とフローになっています。
fig_aggregate.png

  • 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.graphqlAPIリファレンスを見ながら、各フィールドの定義は調べました。特筆すべき事項を抜粋します。

  • pullRequestsstates: MERGEDを指定することでマージ済みのPRだけが取得対象になります。
  • PRが作成されてからマージされるまでのリードタイムを測るため、createdAtmergedAtを指定しています。
  • PRのサイズを測るため、additionsdeletionsを指定しています。
    なお、REST APIの場合はadditionsdeletionsを取得するためにPRのnumberが常に必要です。これだとPRの数だけqueryを発行しないといけなくなるため、今回はREST APIではなくGraphQL APIを採用しています。
    ただしQraphQLの場合はRESTとは異なり、パブリックリポジトリであってもaccess tokenによる認証が必要になります。参考
  • 一度に取得できるPRの上限数は100になります。
    100件以上取得したい場合、PRのcursor値を指定して再度queryを発行する必要があります。これはpageInfoを取得すれば実現可能です。簡単なイメージ図が以下になります。

pr_paging.png

実際のデータ取得には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の辞書要素にドット(.)アクセスするための便利なライブラリーです。
リードタイムとサイズ(additionsdeletionsの和)は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本体にすごく小さなコミットをすることができたので良かったです:laughing:
  • コーディングの感覚を少し取り戻すことができた。(普段がマネジメント業務なので)
  • Publicリポジトリで開発を行うことで、Codecovなど使ってみたかった連携サービスを試すことができた。

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

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

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

  1. 正確にはApache Arrowの仕様に基づくArrow2というRustのCrateをベースにしています。

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

6
1
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
6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?