Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

GitHub Actions で Pull Request 作成時の手間を削減した話

こんにちは。技術本部 Mobile Application グループの多鹿です。

本記事は Sansan Advent Calendar 2021 15日目の記事です。 adventar.org

この記事では、 GitHub Actions を用いて Sansan iOS チームでの Pull Request (以降 PR と記載) 作成時の決まり事を自動化した話を紹介します。

前提

まず本題に入る前に Sansan iOS チームにおける開発体制に関する説明をします。 Sansan iOS チームが携わる開発では、機能追加や一定の工数がかかる改修は 1つのプロジェクトとして扱われます。そして、それぞれのプロジェクトの仕様は Confluence を使用してドキュメントとしてまとめることになっています。
また、これらのプロジェクトは同期間に並行して複数のプロジェクトが走っており、 Sansan iOS メンバーは 1~2人の単位で 1つのプロジェクトを担当していることが多いです。
現在の Sansan iOS チームは 7名体制なので、同時期に3~4個のプロジェクトの実装を進めていることになります。

PR 作成について

上記のように、 iOS メンバーそれぞれが必ずしも同時期に同じプロジェクトをやっているわけではないため、 PR を作成しコードレビューをしてもらう際に、レビュワーにはそのプロジェクトを担当していないメンバーもアサインされることがあります。
そこで、 PR の説明文は極力プロジェクト担当ではない人にも分かるように工夫する必要があります。
また、PR 一覧を見たときに、自分が対応した PR を探しやすくするという工夫も必要になります。

上記に対しては、Sansan iOS チームでは既に次のような工夫を行なっていました。

  • プロジェクトごとに GitHub のラベルを生成し、そのプロジェクトに関する PR にはそのプロジェクトのラベルを付与する
  • PR の Assignee にはその PR の対応者(通常は PR 作成者)をアサインする
  • PR の説明にプロジェクトの仕様となる Confluence の URL を記載する
  • PR の説明は PULL_REQUEST_TEMPLATE.md でテンプレート化する

これによって、担当ではないプロジェクトのレビューを行うにあたっても、すぐにドキュメントにアクセスでき、レビュー負荷を下げることができています。
また、レビュイーとしても、自分の担当プロジェクトの PR や自分が作成した PR をフィルターして見つけやすくすることができます。

PR 作成の工夫に対する課題

ただ、このような工夫をする中で、次のような課題が見つかりました。

「PR を頻繁に作成する場合、 Assignee への自身のアサインラベルの付与ドキュメント URL の記載といった細かい作業を GitHub の GUI 上で行うのが面倒」

Assignee への自身のアサインはボタン 1つでできるのでまだ良いですが、ラベルの付与は「ラベルを検索 > 選択」という手作業が発生するので特に面倒でした。

ここで、上記課題を解決するために GitHub Actions を用いて PR 作成時にこれらの手作業を自動で行うようにしました。

上記課題の解決方法

Assignee への自身のアサイン

まず、 PR の Assignee に自身をアサインする点を自動化しました。
こちらを実現するために、下記のような GitHub Actions の workflow を作成しました。

name: Assign self to Pull Request  
  
on:  
  pull_request:  
    types: [opened]  
  
jobs:  
  assign_self_to_pull_request:  
    name: Assign self to Pull Request  
    if: ${{ github.actor != 'dependabot[bot]' }}  
    env:  
      GITHUB_TOKEN: ${{ secrets.PERSONAL_API_TOKEN }}  
      PR_NODE_ID: ${{ github.event.pull_request.node_id }}  
      USER_NODE_ID: ${{ github.event.pull_request.user.node_id }}  
    runs-on: ubuntu-latest  
    steps:  
      - name: Assign self to pull request  
        run: |  
          jq -cn '{  
            "query": "mutation ($input: AddAssigneesToAssignableInput!) {  
              addAssigneesToAssignable(input: $input) {  
                clientMutationId  
              }  
            }",  
            "variables": {  
              "input": {  
                "assigneeIds": ["'"${USER_NODE_ID}"'"],  
                "assignableId": "'"${PR_NODE_ID}"'"  
              }  
            }  
          }' \  
          | curl -s -X POST -H 'Content-Type: application/json' -H "Authorization: bearer ${GITHUB_TOKEN}" -d @- ${GITHUB_GRAPHQL_URL}

これは、単純に pull_requestopened をトリガーとして、 PR 作成者を PR に Assign するということを行なっています。
これによって、 PR 作成者は PR を作成しただけで、自動的に自身がその PR の Assignee として設定されるようになりました!

もちろん、稀に PR 作成者が PR の Assignee にならないこともありますが、それは例外的な扱いとして手動で設定するような運用となっています。

PR へのラベルの自動付与とドキュメント URL の自動コメントについて

次に、「PR に対するプロジェクトのラベル付与」と「その PR に対応するプロジェクトのドキュメント URL の記載」についても自動化したので、その説明をしていきます。

ラベルの自動付与

PR に対するラベル付与の自動化を考えるにあたって、 Sansan iOS で採用しているブランチの命名規則に着目しました。
Sansan iOS チームでは、ブランチ戦略として GitLab-flow を採用しており、各プロジェクトに関しては main ブランチから feature ブランチを切って開発を進めます。
大抵の場合は feature ブランチ1つで完結しないので、 main からは feature/some-project/develop というそのプロジェクトのベースとなるブランチを切って、そこから派生してそのプロジェクトの機能を開発していきます。

f:id:taji-taji:20211207151804p:plain
プロジェクトを進める上でのブランチ戦略

このように、 プロジェクトに紐づくブランチの命名には一定のルールを設けている ので、そのルールを利用して次のような方針を考えました。

  • プロジェクト開始時にそのプロジェクト用のラベルを作成(これまで通り)
  • ラベルの Description にプロジェクトのブランチの prefix を書いておく
    • 例)feature/some-project/develop を起点とするプロジェクトであれば feature/some-project
  • 上記を設定した上で、 PR 作成をトリガーとして GitHub Actions を起動させ、 PR の head ブランチ名をもとに対応するラベルを特定し、 PR にラベルを付与する
    • このラベル特定に上記のラベルの Description に書いたブランチの prefix を用いる

f:id:taji-taji:20211206173858p:plain
Description にブランチの prefix を記載してラベルを作成

こうすることによって、上の例であれば、 feature/some-project/develop をベースとした feature/some-project/add-some-function ブランチの PR を作成すると、「あるプロジェクト」のラベルが自動で付与されるようになります。

具体的な GitHub Actions のコードとその解説は後述するので、続けて ドキュメント URL の自動コメントの方針についても見ていきます。

ドキュメント URL の自動コメント

こちらは上記のラベルの Description を活用した方法を拡張して実現することにしました。
具体的には、ラベルの Description の記載にドキュメント URL に含まれている一意な ID を追加します。

  • 例)ドキュメントの ID が 0123456789 であるプロジェクトのラベル Description: feature/some-project, 0123456789

f:id:taji-taji:20211206174538p:plain
ラベルの Description にドキュメントの ID も追加

「ブランチ名の prefix の後ろにカンマ区切りで記載する」というルールベースでの運用になります。

ここで、ドキュメント URL を直接 Description に書かずに ID だけを記載している理由は、ラベルの Description には文字数制限があるため、ドキュメント URL 文字列が構築できる最低限の情報のみ記述したかったからです。

こちらも PR 作成をトリガーとして GitHub Actions を起動させ PR のブランチ名からラベルを特定することで、そのラベルの Description から抽出した ドキュメントの ID を用いて URL 文字列を構築することが可能です。
構築した URL 文字列を PR に対してコメントすれば、 PR の作成者は PR を作成しただけで自動でドキュメントへのリンクがコメントされるようになると考えました。

GitHub Actions のコード

それでは、実際に作成した GitHub Actions の workflow を見ていきましょう。
「ラベルの自動付与」と「ドキュメント URL の自動コメント」は 1つの workflow として動かしているため、合わせてコードを見ていきます。

name: Auto label and comment Confluence URL to PR  
  
on:  
  pull_request:  
    types: [opened]  
  
jobs:  
  auto_label_and_comment:  
    name: Label and comment Confluence URL  
    if: ${{ github.actor != 'dependabot[bot]' && contains(github.head_ref, '/') }}  
    env:  
      GITHUB_TOKEN: ${{ secrets.PERSONAL_API_TOKEN }}  
      REPOSITORY_OWNER: ${{ github.repository_owner }}  
      REPOSITORY_NAME: <ここには Sansan-iOS のリポジトリ名をハードコードしています。>  
      BRANCH_NAME: ${{ github.head_ref }}  
      PR_ID: ${{ github.event.pull_request.node_id }}  
      CONFLUENCE_URL_BASE: https://example.com/  
    runs-on: ubuntu-latest  
    defaults:  
      run:  
        shell: bash  
    steps:  
      - name: Find a label whose description contains the branch base name  
        run: |  
          branchNameComponents=(${BRANCH_NAME//\// })  
          query="${branchNameComponents[0]}/${branchNameComponents[1]}"  
          json=$(jq -cn '{  
            "query": "query {  
              repository(owner: \"'${REPOSITORY_OWNER}'\", name: \"'${REPOSITORY_NAME}'\") {  
                labels(first: 15, query: \"'${query}'\", orderBy: {direction: DESC, field: CREATED_AT}) {  
                  nodes {  
                    ... on Label {  
                      id  
                      description  
                    }  
                  }  
                }  
              }  
            }"  
          }' \  
          | curl -s -X POST -H 'Content-Type: application/json' -H "Authorization: bearer ${GITHUB_TOKEN}" -d @- ${GITHUB_GRAPHQL_URL} \  
          | jq '.data.repository.labels.nodes | select(length > 0) | map(select(contains({description: "'${query}',"}))) | .[0] | values')  
          if [ -n "$json" ]; then  
            echo "labelId=$(echo $json | jq -r .id)" >> $GITHUB_ENV  
            echo "labelDescription=$(echo $json | jq -r .description)" >> $GITHUB_ENV  
          fi  
      - name: Label to PR  
        if: ${{ env.labelId != '' && env.labelDescription != '' }}  
        run: |  
          jq -cn '{  
            "query": "mutation ($input: AddLabelsToLabelableInput!) {  
              addLabelsToLabelable(input: $input) {  
                clientMutationId  
              }  
            }",  
            "variables": {  
              "input": {  
                "labelIds": ["'"${{ env.labelId }}"'"],  
                "labelableId": "'"${PR_ID}"'"  
              }  
            }  
          }' \  
          | curl -s -X POST -H 'Content-Type: application/json' -H "Authorization: bearer ${GITHUB_TOKEN}" -d @- ${GITHUB_GRAPHQL_URL}  
      - name: Comment to PR  
        if: ${{ env.labelId != '' && env.labelDescription != '' }}  
        run: |  
          description="${{ env.labelDescription }}"  
          descriptionComponents=(${description//,/ })  
          confluenceId=${descriptionComponents[1]}  
          confluenceURL="$CONFLUENCE_URL_BASE$confluenceId"  
          body=":memo: コンフルは[こちら]($confluenceURL)"  
          jq -cn '{  
           "query": "mutation ($input: AddCommentInput!) {  
              addComment(input: $input) {  
                clientMutationId  
              }  
            }",  
            "variables": {  
              "input": {  
                "subjectId": "'"${PR_ID}"'",  
                "body": "'"${body}"'"  
              }  
            }  
          }' \  
          | curl -s -X POST -H 'Content-Type: application/json' -H "Authorization: bearer ${GITHUB_TOKEN}" -d @- ${GITHUB_GRAPHQL_URL}
コード解説
Step: Find a label whose description contains the branch base name について

このステップでは、下記のようなことを行ない、その PR に付与するべきラベルを特定しています。

  • PR の head_ref をもとにブランチの prefix を抽出し、ラベルの検索クエリとして利用する準備を行う
branchNameComponents=(${BRANCH_NAME//\// })  
query="${branchNameComponents[0]}/${branchNameComponents[1]}"  
  • GitHub の GraphQL API でブランチの prefix が Description に記載されたラベルを検索
    • API で検索されるのがクエリの完全一致ではないため、ある程度の件数(ここでは 15件)を検索した上で、取得結果を jq でフィルターしてブランチの prefix と完全に一致する Description を持つラベルを特定するようにしている
json=$(jq -cn '{  
  "query": "query {  
    repository(owner: \"'${REPOSITORY_OWNER}'\", name: \"'${REPOSITORY_NAME}'\") {  
      labels(first: 15, query: \"'${query}'\", orderBy: {direction: DESC, field: CREATED_AT}) {  
        nodes {  
          ... on Label {  
            id  
            description  
          }  
        }  
      }  
    }  
  }"  
}' \  
| curl -s -X POST -H 'Content-Type: application/json' -H "Authorization: bearer ${GITHUB_TOKEN}" -d @- ${GITHUB_GRAPHQL_URL} \  
| jq '.data.repository.labels.nodes | select(length > 0) | map(select(contains({description: "'${query}',"}))) | .[0] | values')  
  • 取得したラベルの ID と Description の内容を次のステップで使用するために環境変数に設定する
if [ -n "$json" ]; then
  echo "labelId=$(echo $json | jq -r .id)" >> $GITHUB_ENV
  echo "labelDescription=$(echo $json | jq -r .description)" >> $GITHUB_ENV
fi 
Step: Label to PR

次に、上記ステップで特定したラベルを PR に対して付与します。
ここではこれまで取得したデータをもとに GitHub の GraphQL API を叩いてラベルを付与する処理を行なっています。

Step: Comment to PR

最後のステップでは、 PR に対するドキュメント URL のコメントを行なっています。

  • 1つ目のステップでラベルが取得できているので、そのラベルの Description に記載されているドキュメントの ID を用いてドキュメント URL 文字列を構築する
description="${{ env.labelDescription }}"  
descriptionComponents=(${description//,/ })  # カンマ区切りの文字列を分割
confluenceId=${descriptionComponents[1]}  # 2つめの要素が ID
confluenceURL="$CONFLUENCE_URL_BASE$confluenceId" # URL 文字列を組み立てる
  • 構築した URL 文字列を含め、 GitHub GraphQL API で PR に対してコメントを送信する
    • ※ Confluence のことを社内では コンフル と略して呼んでいる
body=":memo: コンフルは[こちら]($confluenceURL)"  
jq -cn '{  
 "query": "mutation ($input: AddCommentInput!) {  
    addComment(input: $input) {  
      clientMutationId  
    }  
  }",  
  "variables": {  
    "input": {  
      "subjectId": "'"${PR_ID}"'",  
      "body": "'"${body}"'"  
    }  
  }  
}' \  
| curl -s -X POST -H 'Content-Type: application/json' -H "Authorization: bearer ${GITHUB_TOKEN}" -d @- ${GITHUB_GRAPHQL_URL}

以上の設定で、 PR 作成時にその PR に対応するプロジェクトの「ラベルの付与」と「ドキュメントの記載」が自動化されました!

f:id:taji-taji:20211206175811p:plain
PR 作成時の様子

おわりに

今回の改善によって、 PR を作成する際に地味にストレスを感じていた手作業が減り、精神的な安定を得られたとともに、作業効率の向上が実現できたと思います。

今回ご紹介したのはこれまで行なってきた改善活動のほんの一部であり、また、変化としては小さなものだったかもしれません。
ただ、このような小さな変化でも、日々の業務におけるストレスが解消され、作業効率を上げつつ成果を上げることに繋がっていくと感じています。

また、私自身が Sansan にジョインするまではチーム開発をあまり経験してこなかったこともあり、このようにチームに貢献することはとても楽しく感じていると同時に、改善提案にしっかり耳を傾けてくれるチームメンバーには日々感謝をしています。
これからも改善を重ねていければと思います。

© Sansan, Inc.