spacelyのブログ

Spacely Engineer's Blog

GitHub ActionsでPRを自動生成するチュートリアル

株式会社スペースリーのRailsエンジニアの海老原です。

スペースリーが提供するサービスの反映は、CSチームでテストを実施してから本番環境にリリースする業務フローになっております。各featureブランチごとに、テストを実施できるように、プルリクエストに動作確認の手順を記載し、動作確認をCSに依頼するという流れです。今回は、ラベルごとにPRの記載内容を書き分けて、自動でプルリクエストを作成できるようにする設定を、GitHub Actionsを使って行いましたので、コードを交えて紹介したいと思います。

以下のようなプルリクエストをGitHub Actionsから自動で作成できます。赤枠が自動で埋められる部分です。

1. ローカルデバッグ環境構築

まずは、作業の準備として必要なツール類のインストールを行ってローカルの開発環境を整えて行きます。 4章のインテグレーションテストで利用することになります。

1. Dockerのインストールと起動

  1. Dockerのサイトに行き、ご自身のPCに合ったDockerをインストールして起動しておきます。
  2. デスクトップで起動している事が確認できます

2. actのインストール

  1. actをインストールします。 ご自分の環境にあった方法でインストールしてください。Macの場合には、brew install actでインストールできます。 このツールを使うことにより、GitHub Actionsがローカルで起動できるようになり、デバッグが格段に楽になります。

3. GitHub の Tokenを取得

  1. Settings > Developper Settings > Personal access tokens に移動
  2. Generate new tokenでtokenを発行します。

※ 今回、Fine-grained tokensで試したところ、GitHub ActionsのAPI呼び出しでエラーが起きたため、従来どおり、classic tokenを利用して開発を進める事にしました。

  1. 権限は下記の権限を設定しましょう。
  2. repo (ALL)
    • repo: status
    • repo_deployment
    • public_repo
    • repo:invite
    • security_events
  3. workflow
  4. admin: public_key
    • read:public_key
  5. admin:repo_hook
    • read:repo_hook
  6. project

    • read:project
  7. 有効期限を設定してください。 開発用のトークンは正しく有効期限を設定して、開発が終了したら削除しましょう。

  8. トークンはローカルデバッグで利用するので、コピーして保管しておいて下さい。

2. ymlファイルの設定

1. jobを定義する

Create a release pull-requestというタイトルで、jobを定義します。 onの部分でイベントトリガーの設定をする事が可能です。 今回は、workflow_dispatchというGitHub Actionsの画面から手動でトリガーするイベントを使ってみます。 こちらのイベント利用すると、下記のように画面の操作をトリガーにして、実行することができます。

.github/workflows/create-release-pull-request-staging.yml

name: Create a release pull-request (staging)
on:
  workflow_dispatch:
jobs:
  release_pr_staging:
    runs-on: ubuntu-latest
    name: release_pull_request_stg
    steps:
      - name: Check out Repository
        id: check_out
        uses: actions/checkout@v3
        with:
          fetch-depth: 0
      - name: Setup Node JS
        id: setup_node_js
        uses: actions/setup-node@v3
        with:
          node-version: 16.x
          cache: "yarn"

2. 利用するライブラリをインストールする

今回利用するライブラリは、@actions/core@actions/githubmustache の3種類だけです。

Market Placeのものをいくつか試したが実現できないロジックが存在した、ミニマムな依存関係になるようにしたいという希望などから決定しました。

先程作成したファイルに下記の記述を追加しましょう。

.github/workflows/create-release-pull-request-staging.yml

      - name: Install JS dependencies
        run: |
          yarn add @actions/core @actions/github mustache

3. jobに独自スクリプトを設定する

touch src/create_staging_release_pr.jsとでもして空ファイルを作成しておきましょう。後ほどプルリクを自動生成するコードを実装します。 下記のような、呼び出し元のyml設定を追加します。 デバッグの容易さから、envを利用して情報を渡す方法を利用したいと思います。

.github/workflows/create-release-pull-request-staging.yml

      - name: "Create Pull Request"
        run: |
          node .github/src/create_staging_release_pr.js
        env:
          GITHUB_TOKEN:  ${{ secrets.GITHUB_TOKEN }}
          BASE_BRANCH: staging
          HEAD_BRANCH: main
          OWNER: ${{ github.repository_owner }}
          REPO: ${{ github.event.repository.name }}
          LABELS: "staging-release"
          TEMPLATE: ".github/git-pr-release.template"

3. スクリプトの実装

1. 環境変数のexport

まず、デバッグ用に現在のターミナルで環境変数をexportしておきましょう。 正しくexportされたかどうかは、printenvで確認できます。

export GITHUB_TOKEN=※先程取得したトークン
export BASE_BRANCH=staging
export HEAD_BRANCH=main
export OWNER=3dstylee
export REPO=dollhouse-creation-frontend
export TEMPLATE=.github/git-pr-release.template
export LABELS=staging-release

2. ライブラリの読み込みと環境変数の確認

必要なライブラリを、requireします。 .github/src/create_staging_release_pr.js

const core = require('@actions/core');
const github = require('@actions/github')
const Mustache = require('mustache');
const fs = require('fs');

環境変数の定義と、きちんと連携されたか確認するコードを記載します。

.github/src/create_staging_release_pr.js

const GITHUB_TOKEN = process.env['GITHUB_TOKEN'];
const BASE_BRANCH = process.env['BASE_BRANCH'];
const HEAD_BRANCH = process.env['HEAD_BRANCH'];
const OWNER = process.env['OWNER'];
const REPO = process.env['REPO'];
const LABELS = process.env['LABELS'];
const TEMPLATE_FILE_NAME= process.env['TEMPLATE'];
const PAGER_LIMIT = 2; // 2ページ目まで確認
const PAGER_COUNT = 100; // 100件づつ
const TITLE = "Staging Release " + formatDate(new Date());

// Debug outputs
console.log("BASE_BRANCH: ", BASE_BRANCH);
console.log("HEAD_BRANCH: ", HEAD_BRANCH);
console.log("OWNER: ", OWNER);
console.log("REPO: ", REPO);
console.log("TEMPLATE_FILE_NAME: ", TEMPLATE_FILE_NAME);

node .github/src/create_staging_release_pr.jsを実行してコンソール出力を確認しましょう。 確認後、console.logの部分は削除してください。

3. プルリクエストの差分を確認する

HEADブランチとBASEブランチのコミットの差分から今回マージされるプルリクエスト一覧を取得します。 GitHubの画面上では、このようなURLで比較できるものです。 APIの仕様は、Compare two commitsを確認してください。

https://github.com/$owner/$repository/compare/production...staging

getCommitsという関数を作成してコミットの一覧を取得します。 マージコミットの場合には、Merge pull request #676 from 3dstylee/DHCR-2021/highlight-gizmosというようなメッセージ入ってるので、そこからPRの番号を抜き出します。

.github/src/create_staging_release_pr.js

async function getCommits() {
  const octokit = github.getOctokit(GITHUB_TOKEN);
  const promises = generatePromises(octokit, retriveCommits);
  let resultCommits = [];
  const regex = /#(\d+)/;
  await Promise.all(promises).then((responses) => {
    for(const response of responses) {
      if (response.status !== 200) return;
      let mergeCommits = response.data.commits.flatMap((commit) => {
        // mainへのマージコミット
        if (commit.parents.length === 2) {
          const match = commit.commit.message.match(regex);
          if (match !== undefined && match !== null) {
            return Number(match[1]);
          }
        }
        return [];
      });
      mergeCommits = mergeCommits.filter(onlyUnique);
      resultCommits = resultCommits.concat(mergeCommits);
    }
  });
  return resultCommits;
}

次に、プルリクエストの一覧を取得します。 GitHub上ではこのようなURLで表されるページと同じものです。 APIの仕様は、Pullsを確認してください。 ページャーがついているので何ページか多めに取得しておきます。 CsCheckNoCsCheckというラベルが予め付いている想定なので、その2つのラベルで結果の仕分けをするという独自ロジックを追加します。 当てはまらなかった場合には、Othersに格納しておきます。

https://github.com/$owner/$repository/pulls

.github/src/create_staging_release_pr.js

async function getPullRequests(){
  let mergePulls = await getCommits() ?? [];
  const pullResults = {"CsCheck": [], "NoCsCheck": [], "Others": []};
  const foundIds = [];

  const pullRequests = await getPulls(mergePulls);
  for (const key in pullResults) {
    pullResults[key] = pullRequests?.filter((pull)=> {
      const matched = pull.labels.some((label)=> label.name === key);
      if(matched) {
        foundIds.push(pull["id"]);
        return pull;
      }
      return null;
    });
  }

  pullResults["Others"] = pullRequests?.filter((pull)=> !foundIds.includes(pull.id));
  return pullResults;
}

async function getPulls(pulls){
  const octokit = github.getOctokit(GITHUB_TOKEN);
  const options = {
    state: 'closed',
    base: HEAD_BRANCH,
    per_page: PAGER_COUNT,
    page: 1,
    headers: {
      'X-GitHub-Api-Version': '2022-11-28'
    }
  }
  const promises = generatePromises(octokit, retrivePulls, options);
  let pullResults = [];
  await Promise.all(promises).then((responses)=>{
    for(const response of responses){
      response.data.filter((pull) => {
        if (pulls.includes(pull.number)) {
          pullResults.push(pull);
        }
      });
    }
  });
  return pullResults;
}

上記のコードから呼び出される、ユーティリティ関数を追加します。 今回、細かい内容には触れません。

.github/src/create_staging_release_pr.js

function generatePromises(octokit, method, options={}) {
  const promises = [];
  for (let i = 0; i < PAGER_LIMIT ; i++) {
    const local_option = {...options}
    local_option["page"] = i + 1;
    promises.push(method(octokit, local_option));
  }
  return promises;
}

async function retrivePulls(octokit, options){
  return octokit.request(
    `GET ${github.context.apiUrl}/repos/${OWNER}/${REPO}/pulls`,
    options
  );
}

async function retriveCommits(octokit, _options){
  return octokit.request(
    `GET ${github.context.apiUrl}/repos/${OWNER}/${REPO}/compare/${BASE_BRANCH}...${HEAD_BRANCH}`
  );
}

function formatDate(date) {
  const year= date.getFullYear();
  const month= date.getMonth() + 1;
  const day= date.getDate();
  return `${year}/${month}/${day}`;
}

function onlyUnique(value, index, array) {
  return value !== null && array.indexOf(value) === index;
}

4. プルリクエストを作成する

プルリクエストを作成する際に、指定する本文を生成します。 mustache形式のテンプレートファイルを用意しましょう。 先程、PullsAPI経由で取り出した結果の値が置換できます。 公式の、Example responseの部分に利用できる値が掲載されているので、必要なものを取り出してください。

.github/git-pr-release.template

# {{ Title }}

## CS確認事項あり
{{#CsCheck}}
- [{{#checked}}x{{/checked}}{{^checked}} {{/checked}}] #{{number}} {{#assignees}}@{{login}}{{/assignees}}{{^assignees}}{{#user}}@{{login}}{{/user}}{{/assignees}}
{{/CsCheck}}

## CS確認事項なし
{{#NoCsCheck}}
- [{{#checked}}x{{/checked}}{{^checked}} {{/checked}}] #{{number}} {{#assignees}}@{{login}}{{/assignees}}{{^assignees}}{{#user}}@{{login}}{{/user}}{{/assignees}}
{{/NoCsCheck}}

## その他
{{#Others}}
- [{{#checked}}x{{/checked}}{{^checked}} {{/checked}}] #{{number}} {{#assignees}}@{{login}}{{/assignees}}{{^assignees}}{{#user}}@{{login}}{{/user}}{{/assignees}}
{{/Others}}

呼び出し方法はこれだけです。

.github/src/create_staging_release_pr.js

const fileContent = fs.readFileSync(TEMPLATE_FILE_NAME, 'utf-8');
const description = Mustache.render(fileContent, pullRequestList);

Create pull requestの呼び出しをする関数を追加します。

.github/src/create_staging_release_pr.js

async function createPullRequest(title, body){
  const octokit = github.getOctokit(GITHUB_TOKEN);
  const requestBody = {
    owner: OWNER,
    repo: REPO,
    title: title,
    body: body,
    head: HEAD_BRANCH,
    base: BASE_BRANCH,
    headers: {
      'X-GitHub-Api-Version': '2022-11-28'
    }
  };
  const response = await octokit.request(
    `POST /repos/${OWNER}/${REPO}/pulls`,
    requestBody
  );
  return response;
}

5. ラベルを付与する

ラベルを付与する関数を追加します。 Issuesのupdate用のAPIを利用してラベルを付与します。

.github/src/create_staging_release_pr.js

async function addLabelToPullRequest(pullRequestNumber, labels) {
  const octokit = github.getOctokit(GITHUB_TOKEN);
  const response = await octokit.request(
    `POST /repos/${OWNER}/${REPO}/issues/${pullRequestNumber}/labels`,
    { labels: labels });
  console.log('Label added:', response.status);
}

6. 上記のロジックを呼び出す

1-5で記述した関数を組み合せて主たるロジックを生成します。

.github/src/create_staging_release_pr.js

try {
  getPullRequests().then(pullRequestList => {
    pullRequestList["Title"] = TITLE;
    const fileContent = fs.readFileSync(TEMPLATE_FILE_NAME, 'utf-8');
    const description = Mustache.render(fileContent, pullRequestList);
    createPullRequest(TITLE, description).then(
      (response)=> {
        console.log("Request to create PR: #", response.data.number);
        const prID = response.data.number;
        addLabelToPullRequest(prID, LABELS.split("¥s")).then((response)=>{
          console.log("Created labels: ",response);
        });
    });
    core.setOutput("Done!");
  });
} catch(e) {
  console.log(e);
  core.setOutput(e.message());
  return;
}

4. インテグレーションテスト

1. ローカルでテストの実施

デバッグ中は、node .github/src/create_staging_release_pr.jsを実行して検証することが可能でした。 しかし、それだと、create_staging_release_pr.jsのみしか検証できていません。 実際には、ymlの検証も必要なので、actを利用してデバッグします。 act --listを実行すると、実行可能なjobの一覧が表示されます。

-jオプションに続き、Job IDを指定する事で、特定のjobが実行できるので、こちらで試してみます。

act --secret GITHUB_TOKEN=*先程取得したトークン -j release_pr_staging --container-architecture linux/amd64

成功した場合には、Job succeededと表示されます。

2. GitHubの画面からテストの実施

変更をコミットしてプルリクエストを作成しましょう。 2-1のjobを定義するに掲載されている画面でUse workflow fromで指定するブランチに、現在開発中のブランチを指定することでテストができます。 ローカル検証が問題なければ試してみましょう。

5. まとめ

以上でGitHub Actionsで簡単にプルリクを自動生成することができるようになりました。 自社サービスのリリース業務や保守の自動化など幅広く対応する事ができそうですね! 是非、色々なユースケースを見つけて活用してみてください。


最後に

スペースリーでは一緒に働いてくださる方を募集中です。 詳しくは採用サイトをご覧ください。