その他

【GitHub】超マイナー記事。GitHub Projectのお引越し。

その他
この記事は約13分で読めます。

はじめに

みなさんこんにちは。株式会社インプルの岩崎です。

何もなければ、これが本年1発目のimplのrambleになっているはずです。本年もどうぞよろしくお願いいたします!

さて、12月頭からramble記事の更新が途絶えてしまいました。今回の記事は、その原因であり、年末に私を苦しめたGitHubのお引越をしたお話しです。内容は超マイナーです。なるほどなぁ、程度でごらんください。

GitHubとは

GitHubは、ソフトウェア開発者のみなさんにはお馴染みの、ソースコードを管理するツールです。
特に説明することもないと思うので、詳しい説明は割愛します!

GitHub Japan
GitHubはソフトウェア開発のプラットフォームです。GitHubには8000万件以上ものプロジェクトがホスティングされており、2700万人以上のユーザーがプロジェクトを探したり、フォークしたり、コントリビュートしたりしています。

GitHubのお引越し

さて、今回はそんなGitHubのお引越しについてのお話です。
GitHubは所有しているリポジトリーを、他の人に渡すことができます。それは、GitHub標準機能のTransferを使用すれば簡単に実行できます。

しかし、それを実行しただけでは、移譲できない難しい問題があるのです・・・。今回はそこに注目したお話です。

Transfer

GitHubのTransfer機能は、公式がサポートしているリポジトリー類の移行方法です。
issueやPRなどは、基本的にこの機能を使って移行させることができます。詳しくは公式ドキュメントに記載がありますので、ご覧ください。

Transferring a repository - GitHub Docs
You can transfer repositories to other users or organization accounts.

Transferでは移行できない GitHub Project v2

今回の移行作業で一番苦戦したのは、GitHub Project v2 と呼ばれるものです。

About Projects - GitHub Docs
Projects is an adaptable, flexible tool for planning and tracking work on GitHub.

これは、GitHubのプロジェクト管理ができるツールで、UI上のカスタマイズが万能であり、柔軟に表示スタイルを変更させることができます。1つ1つのissueの情報に紐づいており、issue側で設定された情報をもとにして、Project側でUIの表示を柔軟に調整し、プロジェクト管理をしやすいようにできます。

しかし、これはTransferでは移行できず、じゃぁどうやって移行しようか・・・。と悩んだのが今回でした。数十個から数百個程度であれば、頑張って手で移動することはできますが、今回は数千規模だったため、手作業で移行するのはかなり不可能に近かったです。(画像はイメージ)

GitHub Project v2 の移行方法

今回の移行では、公式が用意している移行ツールはないので、コピーできる情報やエクスポートできる情報を使いながら、APIキーとスクリプトを使って移行しました。

コピーできるもの

Project には、viewと呼ばれる表示のさせ方をカスタムした様々なタブを作ることができます。

これは、そのままの表示形式を生かしたままコピーすることができます。
右上の|…|から、Make a copy と書かれた項目をクリックしましょう。これを選択すると、新しいオーナーを選択する画面と共に、コピー先のProject名を設定する画面が出てきます。

これを設定した上で、Copy project をクリックすると、新しいオーナー先にviewのタブ一覧だけそっくりそのまま移ります。

エクスポートできるもの

viewはあくまでUI上の表示のさせ方であり、それはissueそれぞれが持つ情報によってソートされ表示されます。

そのため、viewの表示でなにも制限をかけなければ、そのProjectに関連づけられたissueが全て表示されるようになります。今回は、そのデータを使って、Projectの全てのデータをエクスポートしました。

エクスポート方法は簡単。
+ New view ボタンを押して、新たなviewを作成すればいいだけです。スタイルはTableで問題ありません。その後、viewの横に下三角があるので、ここからExport view dataでデータをエクスポートしてください。

しかし、ここで要注意です。
カスタムフィールドで独自のデータを設定している場合は、全てのカスタムフィールド値を出してから実行してください。チェックが入っていないものが一つでもあると、そのデータはエクスポートデータとして出力されません!


また、この時、エクスポートされるデータはviewに表示されているデータだけです。本当にその通りで、たとえば150件あった時、最初に表示されているのは100件以上ですから、101件目以降はスクロールして表示させてからでないと、100件目までしかエクスポートされません。スクロールすると、どんどんロードされて表示されていきますから、注意してください。
これが今回の移行作業の特大大罠でした・・・。

ここまでできたら、エクスポートします。

ポップアップが出るので、そのままExportしちゃいましょう。

すると、tsvデータがエクスポートできます。これで準備OKです!
ここには、issueのURLとそれがもつカスタムフィールド値のデータが含まれます。これでissueに対して、この値を流し込むだけでOKです。

スクリプトでインポートする準備

さて、ここまでくればあとは簡単です。スクリプトを使ってインポートしていくだけです!

スクリプトでインポートする際には、GitHubのPATと移行先のProject IDが必要になります。
PATは、Personal Access Tokenのことで作業する人のトークンになります。これは、移行先と移行前、どちらにもjoinしていないと、正しく動作しない可能性があるので、移行作業者は、移行元と移行先、どちらにもjoinしておきましょう。
Project IDは、お引越しでいう住所になります。今回はオーガナイゼーションの移行でしたので、移行先のどのProjectなのか、IDで示してあげる必要があります。これはコマンドで簡単に取得できます。

PTAの作成方法は、下記の方が記事を作成していたので、引用させていただきます。
権限は、repo / read:org / project の3つがあればOKです。

GitHubで個人アクセストークンを発行する方法 - Qiita
はじめに GitHubの個人アクセストークン(Personal Access Token、以下PAT)は、APIやCLIツールを使ってGitHubのリポジトリにアクセスする際に必要となる重要な認証手段です。この記事では、GitHubで個人アクセストークンを発行する手順と設...

Project IDは、下記のコマンドで取得できます。
先ほど取得したPTAをexport GITHUB_TOKEN=XXXXXXXXXXでインポートしてから、コマンド操作を進めてください。
Org-nameとなっている場所を、移行したいオーガナイゼーションの名前を入力して実行すると、Project名とIDが取得できます。移行したいProject名を見つけて、IDを記録しておきましょう!

export GITHUB_TOKEN=XXXXXXXXXX

curl -X POST https://api.github.com/graphql \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query": "query { organization(login: \"Org-name\") { projectsV2(first: 10) { nodes { id title } } } }"}'

あとはスクリプトを使って流し込むだけです!

今回使用したスクリプト

さて、今回は大きく3つのスクリプトを使って移行させました。

TSV(Viewごとのエクスポート)
   ↓
field_map.py   ← 人が一度だけ作る「対応表」
   ↓
migrate.py     ← 実行用メインスクリプト
   ↓
新 Project v2 に Issue / フィールド値が投入される

tsvファイルは、先ほどエクスポートしたファイルです。
移行したい Issue / PR を定義する唯一の情報源で、View のフィルタ条件が、そのまま「移行対象」になります。

スクリプトで移行する前に、field_map.py というものも作りました。
tsv の列名 とProject v2 の内部 ID(fieldId / optionId) を対応付けた設定ファイルです。TSV の「Status」という文字列を、GitHub が理解できる 内部 ID に変換するもので、Project v2 は 名前ではなく ID でしか更新できないため必須のファイルになります。

そして、一番大事なのが migrate.py です。
TSV を読み込み、GitHub GraphQL API を叩いて実際に移行するスクリプトです。tsvを読み込み、Issue URL から Issue ID を取得し、Issue を Project に追加します。ここのスクリプトは、エラーが発生しうまく読み込ませることができないデータもあったので、AntigravityなどのAIエディターで実行させて、なにかエラーが起きたときはすぐに修正してもらうよう設計させました。

migrate.pyについて

上記ファイル群は、AIに頼めば簡単に出してくれると思いますが、今回の移行の要とも言える、migrate.pyについては、トライアンドエラーが多くあったので、ナレッジとして共有します!

まずは、API制限を考慮した設計にしないと、途中でスクリプトが止まってしまいます。
今回の移行作業では、issueが数千件以上ありましたので、実行途中にAPI制限がかかって何度も止まってしまうことがありました。そのため、下記のスクリプトの用に、リミットに引っかかったときは、待機し再度実行するスクリプトにすることで、問題なく移行させることができます。

from dotenv import load_dotenv
load_dotenv()

GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN")
if not GITHUB_TOKEN:
    raise ValueError("GITHUB_TOKEN is not set")

def gql(query, variables=None):
    max_retries = 10
    for attempt in range(max_retries):
        r = requests.post(API_URL, json={"query": query, "variables": variables}, headers=HEADERS)
        resp = r.json()

        if "errors" in resp:
            is_rate_limit = any(err.get("type") == "RATE_LIMIT" for err in resp["errors"])
            if is_rate_limit:
                wait = 60 * (attempt + 1)
                print(f"RATE_LIMIT 到達。{wait}秒待機")
                time.sleep(wait)
                continue
            raise ValueError(resp["errors"])
        return resp["data"]

Issue / PR を URL から安全に解決する処理する設計にもしました。
TSV には URL しかないので、Project v2 API は Node ID 必須です。Issue / PR 混在を考慮しない設計にしてしまうと、間違いなくエラーが発生してしまうので、これも注意しました。

def get_issue_node_id(issue_url):
    m = re.search(r"github.com/([^/]+)/([^/]+)/(issues|pull)/(\d+)", issue_url)
    owner, repo, type_str, number = m.groups()

    field_name = "pullRequest" if type_str == "pull" else "issue"

    q = f"""
    query($owner:String!, $repo:String!, $number:Int!) {{
      repository(owner:$owner, name:$repo) {{
        {field_name}(number:$number) {{ id }}
      }}
    }}
    """

ここのスクリプトでは、Projectにitemを追加する最初のポイントになります。ここでitemIDを初めて発行しています。

def add_issue_to_project(issue_id):
    mutation = """
    mutation($project:ID!, $content:ID!) {
      addProjectV2ItemById(input:{projectId:$project, contentId:$content}) {
        item { id }
      }
    }
    """

フィールド値の更新には、型ごとに対応する形で実装させることも重要でした。
DATE / NUMBER は、このような形で読み込ませないと、エラーで弾かれてしまうため、しっかりとした方を作ってあげることが重要です。

elif field["type"] == "DATE":
    for fmt in ["%b %d, %Y", "%Y-%m-%d", "%Y/%m/%d"]:
        try:
            dt = datetime.strptime(value, fmt)
            base["value"] = {"date": dt.strftime("%Y-%m-%d")}
            break

親子関係(Sub-issues)の紐付けも重要です。今回はこれがうまくいかず、いくつかissueが抜け落ちてしまうことがありました。

def link_sub_issues(parent_id, child_id):
    mutation = """
    mutation($parent:ID!, $child:ID!) {
      addSubIssue(input:{issueId:$parent, subIssueId:$child}) {
        issue { id }
      }
    }
    """

これらのポイントに気をつけてスクリプトを作ると、今回の移行では問題なく移行させることができました。みなさんもスクリプトを作る際の参考にしてみてください。

移行完了 + 確認作業

このスクリプト実行が完了すると、概ね、移行完了になります。
そう、概ね、です。今回は数千件規模ということもあり、やはり移行でいくつか抜け落ちているものや、反映がされなかったものもありました。これは、もう手作業でやるしかありません。

といっても、新旧のtsvデータをLLMに突き合わせ、過不足がないかを調査するだけで、結果としては数十件程度の変更をすれば、完璧な移行が実現できました。

これはスクリプトを過信しすぎず、一度目で確認してみることが大事です。

おわりに

いかがだったでしょうか?
今回は、スクリプトを回して確認作業まで、トータル6時間程度かかった気がします。規模が大きいということもありましたが、なかなかハードな作業でした。

改めて、ポイントとしては、

  • tsvデータのエクスポート方法
  • migrate.pyのスクリプトの構造

この2点さえ気をつければ、ほぼ問題なく移行できると思います。

みなさんも、もしこのような機会があれば、参考にしてみてください。(ない)
ご覧いただきありがとうございました。