LIVESENSE ENGINEER BLOG

リブセンスエンジニアの活動や注目していることを発信しています

Pull Requestでレビューしたい! はてなブログでホストされたエンジニアブログだとしても

どうも、かたいなかです。

採用広報チームでのブログ推進の一環として、はてなブログにある弊社エンジニアブログ記事をGitHubで管理するしくみを整えました。

この記事では、どのようなGitHubでの記事編集フローを構築したかをまとめます。

記事のレビューのフローがバラバラ・・・

弊社のエンジニアブログの記事の運用での大きな問題のひとつに、レビューのフローが記事によってバラバラになってしまっていることがありました。

具体的には、以下のような一長一短ある複数のフローが、記事によってバラバラに運用されている状況になっていました。

    • ConfluenceやGoogle Docsに一度草案を書き上げ、レビュー後にはてなブログに移動
      • ⭕ 気になったところにすぐ指摘コメントを付けられる
      • ❌ レビュー時に記事のプレビューが見られない
      • ❌ レビュー完了後にはてなブログに記事を移動させるのが面倒
    • はてなブログで直接編集した記事のレビュー事項にSlackのコメント等で指摘する
      • ⭕ 記事のプレビューを見ながらレビューできる
      • ❌ 指摘事項を引用してレビューコメントをSlackに書く手順が少し煩雑
      • ❌ レビューコメントに対応したかが分かりづらい

その他の問題として、表記揺れなどの機械的に検出したい問題で、目視でのレビューに頼ってしまっている問題もありました。

これらの問題を解決するため、エンジニア採用広報チームでGitHubではてなブログの記事のレビューが行えるようなフローを整えました。

GitHubで記事を管理できるように

実装にあたっては、はてなブログ公式によるGitHubで記事を管理するためのGitHub Actionsワークフローのボイラープレートを活用しました。

staff.hatenablog.com

弊社のブログ編集で想定している記事の執筆フローは以下のような流れです。このうち、太字にしたところは自分たちでワークフローを構築しました。

  1. 記事執筆開始
    1. はてなブログ側で手動で下書き記事を作成
    2. 下書き記事をもとにGitHub ActionsでPull Requestを作成
  2. 記事編集
    • テキストエディタ&GitHubを中心とした編集
      1. Pull Requestのブランチに記事を書きながらコミット&プッシュ
      2. GitHub Actionsでtextlintを実行
      3. GitHub Actionsではてなブログに反映
    • はてなブログのエディタでの編集
      1. はてなブログのエディタで画像追加等の変更を実施
      2. GitHub Actionsではてなブログ側の変更を取り込む
  3. GitHub上で記事をレビュー
  4. はてなブログ側で予約投稿
  5. Pull Requestをマージ
  6. 定期実行のワークフローで公開された記事を公開済み記事のディレクトリに移動

はてなブログ公式のワークフローから変更した点について、以下で詳しく説明します。

GitHub Actionsでtextlintを実行

記事の修正時にはtextlintを実行させるようにしました。

運用開始時点のtextlintとしては、では、すでにEngineering Handbook用に使われていた設定を流用することにしました。

made.livesense.co.jp

実際に運用してみると、エンジニアブログの記事としては厳しすぎるルールもいくつかあったため、都度議論しながらtextlintのルールを調整しています。

ワークフローに組み込む中で、自動修正可能な指摘事項についてはGitHub ActionsでSuggestさせるようにしました。

今までの大量の記事全てにtextlintを実行すると時間がかかってしまうため、PRで変更しているファイルのみに対してtextlintをかけるよう工夫しています。

ワークフローのコード

name: textlint for handbook
on:
  pull_request:
    paths:
      - 'entries/**/*.md'
      - 'draft_entries/**/*.md'
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: install xargs
        run: sudo apt-get install findutils
      - name: npm install
        run: npm install
      - name: get diff files
        id: get-diff-files
        run: |
          # 変更したファイル名のリストを取得
          # xargsで改行区切りのファイル名をスペース区切りに変換
          changed_files=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...HEAD --diff-filter=d -- entries/ draft_entries/ | xargs )
          echo "changed_files=$changed_files" | tee -a $GITHUB_OUTPUT
      - name: run textlint
        uses: tsuyoshicho/action-textlint@v3
        if: steps.get-diff-files.outputs.changed_files != ''
        with:
          reporter: github-pr-review
          level: warning
          filter_mode: nofilter
          fail_on_error: true
          textlint_flags: ${{ steps.get-diff-files.outputs.changed_files }}

GitHub Actionsではてなブログ側の変更を取り込む

画像の追加やはてな記法での記述が必要な箇所などでははてなブログ側で編集したほうが良い場面もあります。

そのため、はてなブログ側で行った記事の変更をGitHubに取り込む機能も用意しました。

Pull Requestに /sync とコメントすると、GitHub上のマークダウンファイルをはてなブログの下書きに同期するワークフローが実行されます。

ワークフローのコード

name: sync draft from hatenablog

on:
  issue_comment:
    types: [created]
jobs:
  pull-draft:
    runs-on: ubuntu-latest
    env:
      BLOGSYNC_PASSWORD: ${{ secrets.OWNER_API_KEY }}
    if: github.event.issue.pull_request != null && startsWith(github.event.comment.body, '/sync')
    steps:
      # /syncがコメントされた場合にいいねのリアクションをつけることで処理が開始されたことがわかりやすくする
      - name: create a reaction
        uses: actions/github-script@v7
        with:
          script: |
            await github.rest.reactions.createForIssueComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              comment_id: context.payload.comment.id,
              content: "+1",
            })
      # コメントが付いたPRのブランチの情報を取得
      - uses: xt0rted/pull-request-comment-branch@v2
        id: pull-request-comment-branch
      - name: generate a token
        id: generate-token
        uses: actions/create-github-app-token@v1
        with:
          app-id: ${{ secrets.LIVESENSE_MADE_GITHUB_APP_ID }}
          private-key: ${{ secrets.LIVESENSE_MADE_GITHUB_APP_PRIVATE_KEY }}
      - uses: actions/checkout@v4
        with:
          ref: ${{ steps.pull-request-comment-branch.outputs.head_ref }}
          fetch-depth: 0
          token: ${{ steps.generate-token.outputs.token }}
      - name: setup
        uses: hatena/hatenablog-workflows/.github/actions/setup@v1
      # 対象の記事のパスやIDを取得
      - name: set entry variables
        id: set-entry-variables
        run: |
          # 変更があるファイルを取得(現状は1ファイルを変更したときのみ対応)
          entry_path=$(git diff --name-only origin/${{ steps.pull-request-comment-branch.outputs.base_ref }}..${{ steps.pull-request-comment-branch.outputs.head_ref }} --diff-filter=d -- draft_entries/ | head -n 1 )
          # outputにセット
          echo "ENTRY_PATH=$entry_path" >> $GITHUB_OUTPUT
        shell: bash
      # fetchする
      - name: blogsync fetch
        run: |
          blogsync fetch ${{ steps.set-entry-variables.outputs.ENTRY_PATH }}
      - name: commit
        run: |
          git config user.name "actions-user"
          git config user.email "action@github.com"
          if [[ $(git diff) -eq 0 ]]; then
            exit 0
          fi

          git commit -am "Sync draft from hatenablog"
          git push

定期実行のワークフローで公開された記事を公開済み記事のディレクトリに移動

はてなブログ公式ボイラープレートでは、Pull Requestマージ時に記事を即公開し、下書き用のディレクトリから公開記事用のディレクトリに移動するようなフローが用意されていました。

弊社では記事公開に予約投稿機能や予約投稿時に同時にXに投稿する機能を活用しているため、そのまま使うことはできませんでした。

そこで、記事の投稿は今まで通り、予約投稿によって行うことにしました。そのうえで、記事を公開するワークフローの代わりに、定期的に記事が公開されたかをチェックし、公開されていたら記事を移動するPRを自動作成するワークフローを作成しました。

ワークフローのコード

name: move newly published entries
on:
  schedule:
    - cron: '1/15 * * * *'
  workflow_dispatch: {}

jobs:
  move-newly-published-entries:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: setup
        uses: hatena/hatenablog-workflows/.github/actions/setup@v1
      - name: check if entries' date have past
        id: check-if-entries-date-have-past
        run: |
          if ls draft_entries/*.md > /dev/null 2>&1; then
            echo "There are draft entries"
          else
            echo "There are no draft entries"
            exit 0
          fi

          touch /tmp/to_be_published
          for file in draft_entries/*.md; do
            echo "Checking $file"

            draft_published_date=$(yq --front-matter=extract '.Date' $file)

            # 日付形式として正しいかチェック
            if date --date="$draft_published_date" > /dev/null 2>&1; then
              echo "Valid date format: $draft_published_date"
            else
              echo "Invalid date format: $draft_published_date"
              continue
            fi

            draft_published_date_in_unix_time=$(date --date="$draft_published_date" '+%s')
            now=$(date '+%s')

            if [ $draft_published_date_in_unix_time -lt $now ]; then
              echo "Publishing $file"
              echo "$file">>/tmp/to_be_published
            fi
          done

          # xargsで改行区切りをスペース区切りに
          echo "to_be_published=$(cat /tmp/to_be_published | xargs)" | tee -a $GITHUB_OUTPUT
      # ファイルをフェッチ&移動する
      - name: blogsync fetch
        if: steps.check-if-entries-date-have-past.outputs.to_be_published != ''
        id: blogsync-fetch
        env:
          BLOGSYNC_PASSWORD: ${{ secrets.OWNER_API_KEY }}
        run: |
          entry_root=$(yq '.default.local_root' blogsync.yaml)

          for file in "${{ steps.check-if-entries-date-have-past.outputs.to_be_published }}"; do

            blogsync fetch $file

            # fetchしてまだDraftなら何もしない
            is_draft=$(yq --front-matter=extract 'select(.Draft)' "$file")
            if [[ -n "$is_draft" ]]; then
              git restore -- "$file"
              continue
            fi

            # fetchすると公開したURLが取得できるので、URLに合ったディレクトリに移動。
            url=$(yq --front-matter=extract '.URL' "$file")
            if [[ -z "$url" ]]; then
              echo "URL is not found in $file"
              exit 1
            fi

            entry_path="${entry_root}/$(echo $url | sed 's/https:\/\///').md"
            mkdir -p $(dirname $entry_path)
            mv $file $entry_path

            echo "$entry_path" >> /tmp/published
          done

          # xargsで改行区切りをスペース区切りに
          echo "published=$(cat /tmp/published | xargs)" | tee -a $GITHUB_OUTPUT
      - name: construct pull request data
        if: steps.check-if-entries-date-have-past.outputs.to_be_published != ''
        id: construct-pull-request-data
        run: |
          for file in "${{ steps.blogsync-fetch.outputs.published }}"; do
            title=$(yq --front-matter=extract '.Title' $file)
            url=$(yq --front-matter=extract '.URL' $file)

            echo "$title" >> /tmp/published-entries-title
            echo "$url" >> /tmp/published-entries-url
          done

          # xargsで改行区切りをスペース区切りに
          pr_title="【記事公開】$(cat /tmp/published-entries-title | xargs)"

          entry_root=$(yq '.default.local_root' blogsync.yaml)
          url_array=$(cat /tmp/published-entries-url | xargs -L 1 echo -)
          pr_body=$(cat <<-EOF
          はてなブログに公開したファイルを/${entry_root}に移動しました

          - 対象の記事
            ${url_array}
          EOF
          )

          echo "pr_title=$pr_title" | tee -a $GITHUB_OUTPUT
          {
            echo 'pr_body<<EOF'
            echo -e "$pr_body"
            echo EOF
          } | tee -a $GITHUB_OUTPUT
      - name: create pull request
        if: steps.check-if-entries-date-have-past.outputs.to_be_published != ''
        id: create-pull-request
        uses: peter-evans/create-pull-request@v6
        with:
          title: ${{ steps.construct-pull-request-data.outputs.pr_title }}
          branch: from-draft-to-publish
          commit-message: |
            ${{ steps.construct-pull-request-data.outputs.pr_title }}
          labels: |
            skip-push
          body: |
            ${{ steps.construct-pull-request-data.outputs.pr_body }}
      - name: Enable Pull Request Automerge
        if: steps.check-if-entries-date-have-past.outputs.to_be_published != '' && steps.create-pull-request.outputs.pull-request-operation == 'created'
        uses: peter-evans/enable-pull-request-automerge@v3
        with:
          pull-request-number: ${{ steps.create-pull-request.outputs.pull-request-number }}
          merge-method: squash

GitHubで記事を管理できるようにしてどうだったか

エンジニアが慣れ親しんだGitHubの便利なUIでレビューが行えることに加えて、textlintでの自動チェックも実行されることで、かなりレビューが効率化されたように感じます。

また、はてなブログのUIで編集するフローを作成したことで運用に柔軟さをもたらしており、エンジニア以外のGitHubに慣れ親しんでいないメンバーがこれまで通りの仕組みではてなブログを編集する際にも対応できるようになっています。

効率化された執筆&レビューのフローによって、今後ますます弊社ブログが充実したコンテンツで溢れることを願っています!

参考