GitHub リポジトリ B からリポジトリ A に Pull Request を積んでいく GitHub Actions

:::note
本記事は [ZOZO Advent Calendar 2024](https://qiita.com/advent-calendar/2024/zozo) シリーズ 2 の 7 日目の記事です。
:::

# やりたいこと
以下のような状況を考えます。

- 共通ライブラリが管理されている GitHub リポジトリ A がある
- 共通ライブラリを利用する GitHub リポジトリ B, C がある
- リポジトリ B, C ではリポジトリ A のコードを `git subtree` で取り込んでいる


ここではリポジトリ B で共通ライブラリ（リポジトリ A のコード）の変更を行った際に、その変更内容をリポジトリ A に対して Pull-Request を作成する GitHub Actions を作ります。


# 結論
まずは完成形を示します。リポジトリ B で作成した PR がマージされたタイミングで起動する Action の想定です。

```yaml
name: Create PR to other repository

on:
  pull_request:
    branches:
      - "main"
    types: [closed]

env:
  # BOT として使用する GitHub Apps の App ID
  APP_ID: 1234567

jobs:
  # git subtree で取り込んでいる repository-a のファイル変更を検知する
  get-changed-files:
    if: ${{ github.event.pull_request.merged == true }}
    runs-on: ubuntu-22.04
    outputs:
      changed_files: ${{ steps.changed-files.outputs.all_changed_files }}

    steps:
    - name: Check out repository code
      uses: actions/checkout@v4
      with:
        fetch-depth: 0
        persist-credentials: false
        ref: refs/heads/${{ github.event.pull_request.base.ref }}

    - name: Get changed files
      id: changed-files
      uses: tj-actions/changed-files@v45
      with:
        json: true
        quotepath: false
        files: |
          repository-a/**

    - name: Show changed files
      run: |
        echo "${{ steps.changed-files.outputs.all_changed_files }}"

  # repository-a に PR を作成する
  create-pr-to-repository-A:
    if: ${{ !failure() && needs.get-changed-files.outputs.changed_files != '[]' && github.event.pull_request.merged == true }}
    needs: get-changed-files
    runs-on: ubuntu-22.04
    permissions:
      contents: write
      id-token: write
    env:
      LIBRARY_UPDATE_PR_BRANCH: feature/update-library-via-ci

    steps:
    - name: Check out to this repo
      uses: actions/checkout@v4
      with:
        fetch-depth: 0
        path: repository-b

    - uses: actions/create-github-app-token@v1
      id: app-token
      with:
        app-id: ${{ env.APP_ID }}
        private-key: ${{ secrets.GITHUB_APPS_PRIVATE_KEY }}
        owner: ${{ github.repository_owner }}

    - name: Check out to repository-a
      uses: actions/checkout@v4
      with:
        token: ${{ steps.app-token.outputs.token }}
        repository: repository-a
        path: repository-a

    - name: Get GitHub App User ID
      id: get-user-id
      env:
        GH_TOKEN: ${{ steps.app-token.outputs.token }}
      run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"

    - name: Configure git environment and switch branch
      working-directory: repository-a
      run: |
        set -eux
        git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]'
        git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com>'
        git fetch origin
        git switch -c $LIBRARY_UPDATE_PR_BRANCH origin/$LIBRARY_UPDATE_PR_BRANCH || git switch -c $LIBRARY_UPDATE_PR_BRANCH

    - name: Sync files to repository-a
      run: |
        rsync -r --delete repository-b/repository-a/ repository-a/ --exclude '.git' --exclude '.github/'

    - name: Show git diff
      working-directory: repository-a
      run: |
        set -eux
        git status
        git branch
        git add .
        git diff --cached --name-status

    - name: Create pr to repository-a
      env:
        TOKEN: ${{ steps.app-token.outputs.token }}
        REVIEWER: Reviewer-name-as-you-like
      working-directory: repository-a
      run: |
        set -eux
        git commit -m "Update repository-a from repository-b's PR ${{github.event.pull_request.html_url}}"
        git push origin HEAD
        echo ${TOKEN} > token.txt
        gh auth login --with-token < token.txt

        pr_list=$(gh pr list --head $LIBRARY_UPDATE_PR_BRANCH --json=number,body)
        if [[ $(echo "$pr_list" | jq '.[] | length') -eq 0 ]]; then
          gh pr create \
            --title "Update `repository-a` via repository-b repo" \
            --body "- [ ] ${{github.event.pull_request.html_url}}" \
            --reviewer ${REVIEWER}
        else
          echo $pr_list | jq -r '.[0].body' > pr_body.txt
          echo "- [ ] ${{github.event.pull_request.html_url}}" >> pr_body.txt
          gh pr edit --body "$(cat pr_body.txt)"
        fi
```

上記の Action についてポイントをいくつかピックアップして解説します。

# subtree で取り込んでいるコードの変更を検知する
[tj-actions/changed-files](https://github.com/tj-actions/changed-files) という Action を使ってファイルの変更を検出しています。

今回作成した Action では「`repository-b` のリポジトリルートで `repository-a` というディレクトリ名でリポジトリ A のコードを管理している」という状況を想定しているため、`repository-a/**` で変更されたファイルを選択しています。

```bash
$ tree .
.
├── README-b.md
├── repository-a
│   ├── README-a.md
│   └── src
│       └── file
└── src
    └── file
```

# リポジトリ A に checkout して変更内容を sync させる
以下の手順でリポジトリ B での変更内容を反映したブランチを作成しています。

1. `repository-a` というディレクトリ名でリポジトリ A に checkout
1. リポジトリ A で特定のリモートブランチに switch（リモートブランチが存在しなければ新規に作成）
1. `rsync` コマンドでリポジトリ B での変更内容をリポジトリ A のブランチに反映

上記の流れを踏まえつつ Action を読むと理解が捗ります。

```yaml: 結論に記載した Action から抜粋
    - name: Check out to repository-a
      uses: actions/checkout@v4
      with:
        token: ${{ steps.app-token.outputs.token }}
        repository: repository-a
        path: repository-a

    - name: Get GitHub App User ID
      id: get-user-id
      env:
        GH_TOKEN: ${{ steps.app-token.outputs.token }}
      run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"

    - name: Configure git environment and switch branch
      working-directory: repository-a
      run: |
        set -eux
        git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]'
        git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com>'
        git fetch origin
        git switch -c $LIBRARY_UPDATE_PR_BRANCH origin/$LIBRARY_UPDATE_PR_BRANCH || git switch -c $LIBRARY_UPDATE_PR_BRANCH

    - name: Sync files to repository-a
      run: |
        rsync -r --delete repository-b/repository-a/ repository-a/ --exclude '.git' --exclude '.github/'
```

# リポジトリ B での変更内容をリポジトリ A の特定の PR に積んでいく
リポジトリ B で PR をマージするたびにリポジトリ A で新しい PR が生まれて PR が乱立することを避けます。

これは「既に PR が存在していれば PR 編集・PRが存在していなければ新規作成」を実現するシェルスクリプトを書いているだけです。

PR の body にはリポジトリ B の PR へのリンクをチェックボックス形式で記載しています。

```yaml: 結論に記載した Action から抜粋
    - name: Create pr to repository-a
      env:
        TOKEN: ${{ steps.app-token.outputs.token }}
        REVIEWER: Reviewer-name-as-you-like
      working-directory: repository-a
      run: |
        set -eux
        git commit -m "Update repository-a from repository-b's PR ${{github.event.pull_request.html_url}}"
        git push origin HEAD
        echo ${TOKEN} > token.txt
        gh auth login --with-token < token.txt

        pr_list=$(gh pr list --head $LIBRARY_UPDATE_PR_BRANCH --json=number,body)
        if [[ $(echo "$pr_list" | jq '.[] | length') -eq 0 ]]; then
          gh pr create \
            --title "Update `repository-a` via repository-b repo" \
            --body "- [ ] ${{github.event.pull_request.html_url}}" \
            --reviewer ${REVIEWER}
        else
          echo $pr_list | jq -r '.[0].body' > pr_body.txt
          echo "- [ ] ${{github.event.pull_request.html_url}}" >> pr_body.txt
          gh pr edit --body "$(cat pr_body.txt)"
        fi
```


# さいごに
「共通ライブラリに対して PR を投げる」という GitHub Actions でした。
別なリポジトリに変更内容を反映するようなユースケースは稀によくあるので便利な action かと思います。