GitHub Enterprise から GitHub への移行ツールをGoで作りました!

弊社ではGitHub Enterprise (以下GHE) からGitHubへの移行が進んでいます。今年頭のプラン改変GitHub ConnectActionsAppsの充実などGitHubの機能強化が後押しとなりました。GHEのメンテナンスコストも徐々に重荷になってきていました。

リポジトリを移行するにあたって問題となるのが、これまでの歴史をどこまで新リポジトリに移行するかということです。もちろんgitのログはそのまま移行できますが、以下のようなものも移行したいと言われると色々と考えることが出てきます。

  • issueやpull requestのコメントやレビュー、ラベル
    • コードコメントからの参照もあるし、リポジトリ間も相互にリンクしている
      • 番号を維持したい
  • projectやmilestone
    • スプリントのフローが依存している
      • 今のカンバンをそのまま移行したい

これらをすべて移行するツールをGoで作りました。

f:id:itchyny:20191224220759j:plain

  • issueを番号を維持したまま移行します
    • 番号を維持することで、コメントのリンクはリポジトリのURLを変更するだけでOKです
    • ラベルもそのまま移行します
  • pull requestはissueに変換して移行します
    • レビューコメントもissueへのコメントに変換します
    • 番号が維持されるので、merged commitからのリンクも維持されます
  • プロジェクトやマイルストーンも、ほぼそのまま移行します
    • こちらも番号が維持されます
    • プロジェクトのautomationsはAPIで指定できないので移行しない
  • webhookの設定も移行します
    • webhookの設定は、手でやるとイベントを選ぶ必要があり面倒です

類似ツールとの比較

もともとfastlane社のissue移行ツールがありました。 issueのコメントを、それを書いた人が分かる形でリポジトリを移行する素晴らしいツールです。 issueやpull requestの移行にはIssue import APIを使っています。 tableタグでアイコンを出すなど、見た目の上でもかなり参考にしています。

issue番号を維持するというのはaereal/migrate-gh-repoにアイディアをもらいました。 issue間の相互リンク (#128のようなもの) やmerged commitのメッセージなど、issue (pull request) へのリンクが壊れると大変な箇所は意外とたくさんあります。 projectやmilestoneを移行するのにもissue番号が維持されているのは必要なことなのです (このことを気がつかせてくれました)。

issue・pull request番号を維持しつつコメントもレビューコメントもすべて移行する、それがgithub-migratorです。

リポジトリの歴史をどこまで捨ててよいかというのは様々な意見があると思います。 gitのログさえ残っていればよいという人もいれば、すべてのコメントやレビューに価値があり残すべきと考える人もいるでしょう。 GHEは当分運用されると分かっていても、いつか来る撤退の日に向けて、移行時にできるだけ情報を吸っておきたいというのが私の気持ちです。

実装

言語選択は、自分が書けて好きな (書いていて苦にならない) 言語と、社内で書ける人が多く手元で動かせる言語で共通集合を取ってGo言語一択でした。

GitHubAPIを叩く部分は自前でクライアントの実装を行っています。 golang/go-githubはIssue import APIに対応していない (undocumentedなAPIなので仕方ない) のと、構造体のメンバーがポインターだらけで使いにくいです。 APIクライアントは自前で作ったほうがAPIへの理解が深まるし、クライアントのドキュメントとにらめっこしなくてもよいし、リトライとかキャッシュとか変なところではまらなくてよいと思っています。

github-migratorは、issue一覧やコメント一覧、プロジェクトカード一覧など、様々なものの一覧APIを叩いています。 GitHubAPIは基本的にどんなリソースも100件ずつしか取れません。 ページングはレスポンスのLinkヘッダーを見て行います (参考)。

APIクライアントはページングのAPIをどのように扱ったらいいのでしょうか。 ユーザーとしては、ページングの切れ目を意識したくはありません (少なくとも今回のユースケースでは)。 そこで、イテレータを返してページングがユーザーに見えないようにしてみました。

// Issue represents an issue.
type Issue struct {
    ID int `json:"id"`
    // ...
}

// Issues represents a collection of issues.
type Issues <-chan interface{}

// Next emits the next Issue.
func (is Issues) Next() (*Issue, error) {
    for x := range is {
        switch x := x.(type) {
        case error:
            return nil, x
        case *Issue:
            return x, nil
        }
        break
    }
    return nil, io.EOF
}

// ListIssues lists the issues.
func (c *client) ListIssues(repo string, params *ListIssuesParams) Issues {
    // ...
}

使う側は、次のような感じです。

   issues := cli.ListIssues("sample/repo", &ListIssuesParams{})
    for {
        issue, err := issues.Next()
        if err != nil {
            if err == io.EOF {
                return nil
            }
            return err
        }
        fmt.Printf("%#v\n", issue)
    }

次のページとかページあたりの数とかを気にせず、一重のループで全件辿れるのは素敵です。 このやり方は基本的に全件たどりたいという今回のようなパターンには合うと思います (カーソルベースのページングには合わない)。

github-migratorはissueの番号を維持するように実装されています。 実はこれはそんなに簡単ではありません。 まずimportを順番に、エラーを確認しながら行う必要があります。 並列に作成して片方が失敗してしまうと、別のissueがその番号をとってしまいます。 そして番号は飛ぶことがあります。 issueやprojectが削除されたらその番号は欠番になりますし、issueはすでに別のリポジトリに移転されているかもしれません。 欠番は新しいリポジトリでも欠番でなくてはなりません (実はAPIでissueを削除することはできないため、github-migratorでは空のissueを作って削除は諦めています・projectは削除しています)。

リポジトリに何千件とissueがあると、そこそこ時間がかかってしまいます。 github-migratorは、いつ中断されても、同じコマンドで再開できるように設計されています。

GitHub間のリポジトリの移行ならば問題はないのですが、GHEからの移行で一番大きな問題になるのはユーザーの対応です。 IDが異なるユーザーがいるかも知れませんし、GHEに存在するユーザーがGitHubに存在しないかもしれません。 同じIDのユーザーが存在するのだけど実は別人の可能性もあります。 コメントを書いた人が同一IDの別人にリンクされたり、あるいはIDが異なる人のアイコンが出なかったりすると少し不便です。 こういう対応は人間にしかわかりませんから、ユーザーIDの対応を設定してもらうことにしました。 受け取った設定を元に、issueのauthorやメンションなどを置換するようにしています。

テスト

github-migratorは、APIを叩いて情報を収集し、APIを叩いて投稿するツールです。 移行元の情報と投稿する情報の対があれば、それが一つのテストケースになります。

Go言語ではテストケースを一覧で定義してループで回すTable driven testsスタイルが推奨されますが、構造体が複雑になるとこれさえ書くのが億劫になってきます。 そういうときはYAMLファイルにテストケースを書いてしまうのがおすすめです。 複雑な構造体を書く必要はありませんし、JSONとの変換のテストにもなっています (実はgojqでも同じようにテストケースを書いています)。

GitHub APIクライアントのモックはFunctional options patternで行っています。 必要なAPIのみをモックしたり、最初はサーバーが落ちていて2回叩いたらOKを返すなど (これは今回はやってませんが)、APIクライアントのモックには適した方法だと思っています。 APIクライアントを使うツールをテストするときは、頑張ってローカルにサーバーを立ててテストするよりも、モックしてしまうほうがよいでしょう。 テストをたくさん書いて通しても、本番サーバーに向けて落ちるものは落ちます (メソッドが間違ってたりヘッダーが足りなかったり)。 本番サーバー相当のバリデーションをテストに書くのはあまりにもナンセンスです (docker imageが提供されていないか探すほうがよい)。 作っているものがAPIクライアントlibraryそのものであり、その品質を高めたいという場合はサーバーを立ててもいいと思いますが、そうでない場合は頑張りすぎないほうが良いと思います。

まとめ

GitHubリポジトリ移行ツールをGoで作りました。 issueのコメントやレビュー、プロジェクトやマイルストーンなどをほぼすべてそのまま移行するツールです。

GitHub APIクライアントは自前で書く選択を取りました。 おかげでGitHubAPI v3にはそこそこ詳しくなったと思います。 自前で書くのはあまりおすすめできませんが、今回はundocumentedなAPIを叩く必要があり、また二週間ほどで作り上げる必要があったので (問題があったときにissueを立てて待ったりするのに律速されたくなかった)、すべて自分で書いてしまいました。 事前に必要なAPIが分かっていて、そんなに複雑なプロトコルでなければ、自前で書くのもアリだなと思いました。

私の所属しているチームのほぼすべてのリポジトリを今回作ったツールでGitHubに移行しました。 issueの移行もそうですが、projectsの移行をきちんと実装していたおかげで、スプリントの進行を妨げることなく (ポチポチとカンバンを作り直すことなく)、GitHubに移行することができました。 GitHub ActionsDependabotRenovateなどのモダンな開発ツールを勢いよく取り入れて、より効率よく開発を回し、コードの健全性を維持していきたいと思います。