🦔

PRの状態管理とGitHub Actionsによるレビュー催促の自動化を導入

に公開

背景

開発チームでは、次のような課題が発生していました。

  • PR の状態が分かりにくい
    → 「作業中なのか」「レビュー可能なのか」「修正対応中なのか」が見えない
  • レビュー依頼が通知に埋もれる
    → 気づくまでに時間がかかり、レビューが後回しになる

このため、まず PR の状態管理ラベル を整備し、誰が見ても状況を把握できるようにしました。
その上で、レビュー待ちのPRをGitHub Actionsで定時に Slack へ通知する仕組みを導入しました。


1. PR の状態管理ラベル

PR の状態を正確に把握するために、次のラベルを運用しています。

ラベル 使いどころ
WIP 作業中。まだレビュー不要
レビュー待ち 作業完了。レビュー依頼済み。このラベルが付いた PR が通知対象
修正中 レビュー指摘の修正対応中
保留 外部要因や依存関係で一時停止(例: 上流マージ待ち)
DO NOT MERGE 検証用や一時的な PR。マージ禁止

効果

  • 状態が明確になり、PR が「放置されているのか」「対応中なのか」がすぐ分かる
  • レビュアーが優先して確認すべき PR を迷わず判断できる

2. GitHub Actionsでレビュー催促を自動化

ラベル運用を整備した後、「レビュー待ち」ラベルが付いた PR を対象に定時リマインダーを実行するようにしました。

特徴

  • 平日 09:00 / 11:00 / 13:00 / 15:00(JST) に実行

  • 祝日は自動スキップholidays-jp API 利用)

  • 要リベース判定を自動化

    • コンフリクトが発生している場合は ❌ (:no_entry_sign:)
    • ベースブランチより遅れている場合は ⚠️ (:warning:)
  • レビュアーを Slack メンションに変換

    • GitHub login → Slack ユーザー ID を JSON マッピングで対応
  • Slack へ分かりやすいリストを投稿

こんな感じです↓


実装例(サンプル)

.github/workflows/review-reminder.yml

name: PR Review Reminder

on:
  schedule:
    - cron: '0 0 * * 1-5' # JST 09:00
    - cron: '0 2 * * 1-5' # JST 11:00
    - cron: '0 4 * * 1-5' # JST 13:00
    - cron: '0 6 * * 1-5' # JST 15:00
  workflow_dispatch:

env:
  LABEL_NAME: 'レビュー待ち'

jobs:
  remind:
    runs-on: ubuntu-latest
    steps:
      - name: Skip on Japan public holidays
        id: holiday
        run: |
          TODAY=$(TZ=Asia/Tokyo date +%Y-%m-%d)
          HOLIDAYS=$(curl -fsSL https://holidays-jp.github.io/api/v1/date.json)
          if echo "$HOLIDAYS" | jq -e --arg d "$TODAY" 'has($d)' >/dev/null; then
            echo "skip=true" >> $GITHUB_OUTPUT
          else
            echo "skip=false" >> $GITHUB_OUTPUT
          fi

      - name: Query PRs with label
        id: prs
        if: steps.holiday.outputs.skip != 'true'
        uses: actions/github-script@v7
        with:
          script: |
            const label = process.env.LABEL_NAME;
            const mapping = JSON.parse(process.env.SLACK_USER_MAP || '{}');

            const prs = await github.request('GET /repos/{owner}/{repo}/pulls', {
              owner: context.repo.owner,
              repo: context.repo.repo,
              state: 'open',
              per_page: 100
            });

            const filtered = prs.data.filter(pr =>
              (pr.labels || []).some(l => l.name === label)
            );

            const header = `👀 レビュー待ちのPR(${filtered.length}件)`;
            if (filtered.length === 0) {
              core.setOutput('body', '• 該当なし :tada:');
              core.setOutput('header', header);
              return;
            }

            const lines = await Promise.all(filtered.map(async (pr) => {
              const prDetail = await github.request('GET /repos/{owner}/{repo}/pulls/{number}', {
                owner: context.repo.owner,
                repo: context.repo.repo,
                number: pr.number
              });

              let rebaseMark = '';
              const state = prDetail.data.mergeable_state;
              if (state === 'dirty') {
                rebaseMark = ' — :no_entry_sign: *要リベース*';
              } else if (state === 'behind') {
                rebaseMark = ' — :warning: *要リベース*';
              }

              const reviewers = (pr.requested_reviewers || []).map(r => {
                return mapping[r.login] ? `<@${mapping[r.login]}>` : `@${r.login}`;
              });

              const firstLine = `• <${pr.html_url}|#${pr.number} ${pr.title}> (by @${pr.user.login}, base: ${pr.base.ref})${rebaseMark}`;
              return reviewers.length > 0
                ? `${firstLine}\n> *レビュアー:* ${reviewers.join(' ')}`
                : firstLine;
            }));

            core.setOutput('header', header);
            core.setOutput('body', lines.join('\n'));
        env:
          SLACK_USER_MAP: ${{ secrets.SLACK_USER_MAP }}

      - name: Post to Slack
        if: steps.holiday.outputs.skip != 'true'
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.PR_REVIEW_SLACK_WEBHOOK_URL }}
        run: |
          payload=$(jq -n \
            --arg header "${{ steps.prs.outputs.header }}" \
            --arg body "${{ steps.prs.outputs.body }}" \
            '{
              text: $header,
              blocks: [
                { "type": "header", "text": { "type": "plain_text", "text": $header, "emoji": true } },
                { "type": "section", "text": { "type": "mrkdwn", "text": $body } }
              ]
            }')
          curl -s -X POST -H 'Content-Type: application/json' \
            --data "$payload" \
            "$SLACK_WEBHOOK_URL"

導入手順

  1. ラベル運用をチームで合意

    • 「レビュー待ち」ラベルが付いた PR が通知対象
    • WIP / 修正中 / 保留 / DO NOT MERGE も合わせて導入
  2. Slack Webhook を作成し、GitHub Secretsに保存

    • PR_REVIEW_SLACK_WEBHOOK_URL
  3. GitHub login ⇔ Slack ユーザー ID マッピングを Secrets に保存

    • Secrets 名: SLACK_USER_MAP
    {
      "shimadama": "U0123456AA",
      "madamada": "U0123456BB"
    }
    
  4. 上記 YAML を配置

    • 以降は「レビュー待ち」ラベル付き PR が定時に通知される

効果

  • レビュー開始までの時間が短縮
  • PR が「放置」か「修正中」かがすぐ分かる
  • 定時通知でチーム全体がレビュー対象を意識できる

まとめ

  • ステップ 1: PR 状態管理ラベルを導入 → 状態が明確になり、認識のズレを防ぐ
  • ステップ 2: GitHub Actions でレビュー催促を自動化 → レビュー遅延を防ぎ、開発をスムーズに進められる

おまけ:社内向け運用ドキュメント(テンプレ)

# PR運用

## 運用ルール
このチャンネルは GitHub 通知を集約し、進捗とレビュー依頼の見逃しを防ぎます。
/github subscribe <org>/<repo> issues pulls commits:* reviews comments

レビュー/コメント時は必ずメンション(@ユーザー名)。
SlackメンバーIDとGitHubアカウントは紐付け変換(漏れは随時補完)。

## PRリマインダー
通知時刻: 09:00 / 11:00 / 13:00 / 15:00(JST)

## PRのステータス管理ラベル
- WIP: まだレビュー不要
- レビュー待ち: レビュー依頼済み(リマインダー対象)
- 修正中: レビュー指摘への対応中
- 保留: 依存や外部要因で一時停止
- DO NOT MERGE: 検証用。絶対にマージしない

おまけ:なぜリベースが必要か

  • 最新のベースブランチと差分が大きいPR

    • コンフリクトやbehind状態を放置すると、マージ時に大規模な修正が必要になる
    • レビュアーが見ている差分が古い状態のままになり、レビューの質が下がる
  • コミット履歴の汚染

    • git mergeを繰り返すと「マージコミット」が増加し、履歴がノイズで読みにくくなる
    • git rebaseによって履歴をフラットに保つことで、誰がどの変更を入れたのかが明確になり、後からのトラブルシューティングが容易になる
  • チーム開発におけるメリット

    • レビュアーが安心してレビューできる(最新のコードベースに対して確認できるため)
    • CIの実行結果が正しく保証される(古いブランチ状態ではテストが通っていても、最新では落ちる可能性がある)
    • 最終的に「マージ作業で詰まるリスク」を下げ、開発フローを滑らかに保てる

Discussion