はじめに
こんにちは、zrn-nsです。
Githubの自動レビューアサイン機能を使用していて、 コードレビューの依頼が特定のメンバーに集中してしまう問題 に直面したことはないでしょうか。レビューアサインが特定メンバーに集中すると、作業時間の確保が難しくなったり、タスクのスケジューリングが難しくなります。
この課題を解消するため、 GitHub Actionsを使ってコードレビュー依頼を自動的に均等に割り振る仕組みを構築したので、ご紹介します。
背景・課題
筆者のチームでは、特定のメンバーにレビュー依頼が集中してしまう という課題を抱えていました。
GitHub標準の自動アサイン機能と「アサインの波」問題
GitHubにはチームのコードレビュー設定として、レビュワーの自動割り当て機能が用意されています。アルゴリズムとして「Round Robin」(順番にアサイン)と「Load Balance」(30日間のレビュー数を均等化)の2種類が選択できます。
筆者のチームではLoad Balanceを利用していましたが、短期間に同じ人にレビュー依頼が集中する「アサインの波」 が発生していました。
例えば、あるメンバーに月曜から水曜にかけて連続でレビューがアサインされ、木曜以降は別のメンバーに集中する、といった現象です。30日間というスパンで見ればバランスが取れているのかもしれませんが、短期的には負荷が偏ってしまいます。
これはLoad Balanceのロジックが長期のレビュー数のみを考慮しており、直近数日間のアサイン状況が十分に加味されていないためではないかと推測しています。
この問題を解決するため、短期的な負荷を重視したスコアリング を行う独自の自動アサインシステムをGitHub Actionsで構築することにしました。
解決策の概要
処理フロー
トリガー
- Pull Request が Ready for Review になったとき
-
/assign-reviewersコメントが投稿されたとき
処理ステップ
- ブランチ除外パターンをチェック
- 設定ファイルからレビュワー候補を取得
- 各メンバーのbusyステータスを確認
- 過去のレビューアサイン履歴を分析
- スコアを計算し、負荷が少ないメンバーを選定
- レビュワーが合計2名になるように追加アサイン
主な機能
- 自動トリガー: Draft PRがReady for Reviewになった時点で自動実行
-
手動トリガー:
/assign-reviewersコマンドで手動実行も可能 - busyステータス考慮: GitHubのユーザーステータスがbusyの場合は除外
- 履歴ベースの選定: 過去のアサイン回数を分析し、負荷を均等化
- 重み付けスコアリング: 直近のアサインを重視したスコア計算
- ブランチ除外: 特定パターンのブランチは自動アサインをスキップ
実装詳細
ワークフロー定義
.github/workflows/assign_reviewers.yml:
name: "Auto Assign Reviewers"
on:
# トリガー1: /assign-reviewers コメント
issue_comment:
types: [created]
# トリガー2: Draft → Ready for Review
pull_request:
types: [ready_for_review]
jobs:
assign-reviewers:
name: "assign-reviewers"
# コメントトリガーの場合: PRコメントで /assign-reviewers が含まれている
# ready_for_review トリガーの場合: 常に実行
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.html_url, '/pull') && github.event.comment.body == '/assign-reviewers') ||
(github.event_name == 'pull_request' && github.event.action == 'ready_for_review')
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup yq
uses: mikefarah/yq@v4
- name: ブランチが除外パターンに該当するかチェック
if: github.event_name == 'pull_request'
id: check_exclusion
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
# PRのブランチ名を取得
HEAD_REF=$(gh pr view "${PR_NUMBER}" --repo "${REPO}" --json headRefName --jq '.headRefName')
echo "ブランチ名: ${HEAD_REF}"
# 除外パターンを取得
EXCLUSION_PATTERNS=$(yq '.exclusion_patterns[]' .github/review-assign-rules.yml 2>/dev/null || echo "")
if [[ -z "${EXCLUSION_PATTERNS}" ]]; then
echo "除外パターンが設定されていません。通常通りアサインを実行します。"
echo "excluded=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "除外パターン:"
echo "${EXCLUSION_PATTERNS}"
# パターンマッチング
excluded=false
while IFS= read -r pattern; do
if [[ -z "${pattern}" ]]; then
continue
fi
# シェルのパターンマッチング(case文を使用)
case "${HEAD_REF}" in
${pattern})
echo "✅ ブランチ '${HEAD_REF}' は除外パターン '${pattern}' に該当します"
excluded=true
break
;;
esac
done <<< "${EXCLUSION_PATTERNS}"
if [[ "${excluded}" == "true" ]]; then
echo "🚫 Ready for review時の自動アサインをスキップします"
echo "💡 手動でアサインする場合は /assign-reviewers コメントを使用してください"
echo "excluded=true" >> $GITHUB_OUTPUT
else
echo "✅ 除外パターンに該当しないため、通常通りアサインを実行します"
echo "excluded=false" >> $GITHUB_OUTPUT
fi
- name: レビュワー自動アサインスクリプトを実行
if: |
github.event_name == 'issue_comment' ||
(github.event_name == 'pull_request' && steps.check_exclusion.outputs.excluded == 'false')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
chmod +x .github/scripts/assign_reviewers.sh
.github/scripts/assign_reviewers.sh
- name: 結果をPRにコメント(コメントトリガー時のみ)
if: github.event_name == 'issue_comment'
uses: peter-evans/create-or-update-comment@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.issue.number }}
body: |
✅ レビュワー自動アサインを実行しました。
過去のレビューアサイン状況を分析し、負荷を均等化しました。
設定ファイル
.github/review-assign-rules.yml:
# レビュワー自動アサインルール設定
# レビュワー候補リスト
# 休暇中や一時的に除外したいメンバーは、行頭に # を付けてコメントアウト
reviewers:
- member-a
- member-b
- member-c
- member-d
....
# 自動アサイン除外ブランチパターン
# Ready for review時の自動アサインをスキップするブランチ名のパターン
# 手動で /assign-reviewers コマンドを実行した場合は、これらのパターンに該当してもアサイン
#
# パターン記法:
# - ワイルドカード(*)が使用可能
# - 前方一致: "release/*" → release/1.0.0, release/2.0.0 など
# - 後方一致: "*/base" → feature/xxx/base, bug/yyy/base など
exclusion_patterns:
- "*/base"
- "release/*"
# 重み付け設定
# 短期・長期のアサイン回数に重み付けを行い、スコアを計算
# スコア = (短期回数 × 短期重み) + (長期回数 × 長期重み)
# スコアが低いメンバーが優先的にアサイン
scoring:
short_term:
days: 1 # 短期の期間(日)
weight: 5 # 短期の重み
long_term:
days: 7 # 長期の期間(日)
weight: 1 # 長期の重み
メインスクリプト
.github/scripts/assign_reviewers.sh:
#!/bin/bash
set -euo pipefail
# DRY_RUN モード(実際にアサインせずに結果のみ表示)
DRY_RUN="${DRY_RUN:-false}"
# 環境変数の確認
if [[ -z "${PR_NUMBER:-}" ]] || [[ -z "${REPO:-}" ]]; then
echo "エラー: 必要な環境変数が設定されていません"
exit 1
fi
# スクリプトのディレクトリを取得
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REVIEWERS_FILE="${SCRIPT_DIR}/../review-assign-rules.yml"
echo "=========================================="
echo "レビュワー自動アサインスクリプト開始"
echo "=========================================="
echo "PR番号: ${PR_NUMBER}"
echo "リポジトリ: ${REPO}"
# 1. 設定ファイルからレビュワー候補を取得
echo "📋 設定ファイルからレビュワー候補を取得中..."
TEAM_MEMBERS=$(yq '.reviewers[]' "${REVIEWERS_FILE}" 2>/dev/null)
# 重み付け設定を読み込み
SHORT_TERM_DAYS=$(yq '.scoring.short_term.days // 1' "${REVIEWERS_FILE}")
SHORT_TERM_WEIGHT=$(yq '.scoring.short_term.weight // 5' "${REVIEWERS_FILE}")
LONG_TERM_DAYS=$(yq '.scoring.long_term.days // 7' "${REVIEWERS_FILE}")
LONG_TERM_WEIGHT=$(yq '.scoring.long_term.weight // 1' "${REVIEWERS_FILE}")
echo "📊 重み付け設定:"
echo " 短期: ${SHORT_TERM_DAYS}日以内 × 重み${SHORT_TERM_WEIGHT}"
echo " 長期: ${LONG_TERM_DAYS}日以内 × 重み${LONG_TERM_WEIGHT}"
# 2. ユーザーのbusyステータスを確認する関数
check_user_busy_status() {
local username="$1"
local query='query($login: String!) {
user(login: $login) {
status {
indicatesLimitedAvailability
message
}
}
}'
local result
result=$(gh api graphql -f query="${query}" -f login="${username}" 2>/dev/null || echo "{}")
local is_busy
is_busy=$(echo "${result}" | jq -r '.data.user.status.indicatesLimitedAvailability // false')
echo "${is_busy}"
}
# 3. busyステータスのユーザーを確認
echo "🔍 各メンバーのステータスを確認中..."
declare -A user_status
AVAILABLE_MEMBERS=()
for member in ${TEAM_MEMBERS}; do
is_busy=$(check_user_busy_status "${member}")
user_status["${member}"]="${is_busy}"
if [[ "${is_busy}" == "true" ]]; then
echo " ⏸️ ${member}: busy(除外)"
else
echo " ✅ ${member}: available"
AVAILABLE_MEMBERS+=("${member}")
fi
done
# 4. 現在のPRの情報を取得
echo "🔍 PR #${PR_NUMBER} の情報を取得中..."
PR_AUTHOR=$(gh pr view "${PR_NUMBER}" --repo "${REPO}" --json author --jq '.author.login')
EXISTING_REVIEWERS=$(gh pr view "${PR_NUMBER}" --repo "${REPO}" --json reviewRequests --jq '.reviewRequests[].login' || echo "")
echo "PRオーナー: ${PR_AUTHOR}"
echo "既存のレビュワー: ${EXISTING_REVIEWERS:-なし}"
# 5. 日付を計算
SHORT_TERM_AGO=$(date -u -d "${SHORT_TERM_DAYS} days ago" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -v-"${SHORT_TERM_DAYS}"d '+%Y-%m-%dT%H:%M:%SZ')
LONG_TERM_AGO=$(date -u -d "${LONG_TERM_DAYS} days ago" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -v-"${LONG_TERM_DAYS}"d '+%Y-%m-%dT%H:%M:%SZ')
# 6. GraphQL APIでPRとレビューアサインイベントを取得
echo "🔍 過去${LONG_TERM_DAYS}日間のレビューアサイン状況を分析中..."
REPO_OWNER=$(echo "${REPO}" | cut -d'/' -f1)
REPO_NAME=$(echo "${REPO}" | cut -d'/' -f2)
# GraphQLクエリ(ページネーション対応)
GRAPHQL_QUERY='
query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequests(
first: 100,
after: $cursor,
states: [OPEN, MERGED],
orderBy: {field: UPDATED_AT, direction: DESC}
) {
pageInfo {
hasNextPage
endCursor
}
nodes {
number
updatedAt
timelineItems(first: 100, itemTypes: [REVIEW_REQUESTED_EVENT, REVIEW_REQUEST_REMOVED_EVENT]) {
nodes {
__typename
... on ReviewRequestedEvent {
createdAt
requestedReviewer {
... on User {
login
}
}
}
... on ReviewRequestRemovedEvent {
createdAt
requestedReviewer {
... on User {
login
}
}
}
}
}
}
}
}
}
'
# PRデータを取得(ページネーション対応)
ALL_PRS="[]"
HAS_NEXT_PAGE="true"
CURSOR=""
while [[ "${HAS_NEXT_PAGE}" == "true" ]]; do
if [[ -z "${CURSOR}" ]]; then
RESULT=$(gh api graphql -f query="${GRAPHQL_QUERY}" -f owner="${REPO_OWNER}" -f repo="${REPO_NAME}" 2>/dev/null)
else
RESULT=$(gh api graphql -f query="${GRAPHQL_QUERY}" -f owner="${REPO_OWNER}" -f repo="${REPO_NAME}" -f cursor="${CURSOR}" 2>/dev/null)
fi
HAS_NEXT_PAGE=$(echo "${RESULT}" | jq -r '.data.repository.pullRequests.pageInfo.hasNextPage')
CURSOR=$(echo "${RESULT}" | jq -r '.data.repository.pullRequests.pageInfo.endCursor')
NEW_PRS=$(echo "${RESULT}" | jq '.data.repository.pullRequests.nodes')
ALL_PRS=$(echo "${ALL_PRS}" "${NEW_PRS}" | jq -s 'add')
# 最も古いPRのupdatedAtを確認し、長期期間より前なら終了
OLDEST_UPDATED=$(echo "${NEW_PRS}" | jq -r '.[-1].updatedAt // empty')
if [[ -n "${OLDEST_UPDATED}" ]] && [[ "${OLDEST_UPDATED}" < "${LONG_TERM_AGO}" ]]; then
break
fi
done
# 7. PRからレビューアサインイベントを抽出
# PR単位でレビュワーごとの最終状態を判定し、REQUESTEDのみをカウント
FILTERED_EVENTS=$(echo "${ALL_PRS}" | jq --arg long_term_ago "${LONG_TERM_AGO}" '
[.[] |
[.timelineItems.nodes[] |
select(.createdAt != null) |
select(.requestedReviewer.login != null) |
{
type: .__typename,
createdAt: .createdAt,
reviewer: .requestedReviewer.login
}
] |
group_by(.reviewer) |
.[] |
sort_by(.createdAt) |
last |
select(.type == "ReviewRequestedEvent") |
select(.createdAt >= $long_term_ago) |
{
createdAt: .createdAt,
reviewer: .reviewer
}
]
')
# 8. レビュワーごとにカウントを初期化
declare -A short_term_count
declare -A long_term_count
for member in "${AVAILABLE_MEMBERS[@]}"; do
if [[ "${member}" != "${PR_AUTHOR}" ]]; then
short_term_count["${member}"]=0
long_term_count["${member}"]=0
fi
done
# 9. アサインイベントをカウント
while IFS= read -r event; do
reviewer=$(echo "${event}" | jq -r '.reviewer')
created_at=$(echo "${event}" | jq -r '.createdAt')
if [[ -v short_term_count[${reviewer}] ]]; then
((long_term_count["${reviewer}"]++)) || true
if [[ "${created_at}" > "${SHORT_TERM_AGO}" ]] || [[ "${created_at}" == "${SHORT_TERM_AGO}" ]]; then
((short_term_count["${reviewer}"]++)) || true
fi
fi
done < <(echo "${FILTERED_EVENTS}" | jq -c '.[]')
# 10. スコアを計算して表示
echo "📊 レビューアサイン状況(available メンバーのみ):"
echo " メンバー: 短期(${SHORT_TERM_DAYS}日) / 長期(${LONG_TERM_DAYS}日) → スコア"
declare -A scores
for member in "${!short_term_count[@]}"; do
short=${short_term_count[${member}]}
long=${long_term_count[${member}]}
score=$((short * SHORT_TERM_WEIGHT + long * LONG_TERM_WEIGHT))
scores["${member}"]=${score}
echo " ${member}: ${short} / ${long} → スコア ${score}"
done
# 11. レビュワー候補から既存レビュワーとPRオーナーを除外
CANDIDATE_REVIEWERS=()
for member in "${AVAILABLE_MEMBERS[@]}"; do
if [[ "${member}" == "${PR_AUTHOR}" ]]; then
continue
fi
is_existing=false
for existing in ${EXISTING_REVIEWERS}; do
if [[ "${member}" == "${existing}" ]]; then
is_existing=true
break
fi
done
if [[ "${is_existing}" == "false" ]]; then
CANDIDATE_REVIEWERS+=("${member}")
fi
done
# 12. 必要なレビュワー数を計算(2名体制を想定)
if [[ -z "${EXISTING_REVIEWERS}" ]]; then
EXISTING_COUNT=0
else
EXISTING_COUNT=$(echo "${EXISTING_REVIEWERS}" | wc -l | tr -d ' ')
fi
NEEDED_COUNT=$((2 - EXISTING_COUNT))
if [[ ${NEEDED_COUNT} -le 0 ]]; then
echo "✨ 既に2名以上のレビュワーがアサインされています。"
exit 0
fi
echo "🎯 追加で ${NEEDED_COUNT} 名のレビュワーをアサインします"
# 13. スコアが低い順にソートして候補を選択
SORTED_CANDIDATES=$(for member in "${CANDIDATE_REVIEWERS[@]}"; do
score="${scores[${member}]:-0}"
echo "${score} ${member}"
done | sort -n -k1)
# 14. 上位N名を選択(同じスコアの場合はランダム)
SELECTED_REVIEWERS=()
current_min_score=""
candidates_with_same_score=()
while IFS= read -r line; do
score=$(echo "${line}" | awk '{print $1}')
member=$(echo "${line}" | awk '{print $2}')
if [[ -z "${current_min_score}" ]]; then
current_min_score="${score}"
fi
if [[ "${score}" == "${current_min_score}" ]]; then
candidates_with_same_score+=("${member}")
else
remaining=$((NEEDED_COUNT - ${#SELECTED_REVIEWERS[@]}))
if [[ ${remaining} -gt 0 ]]; then
shuffled=($(printf '%s\n' "${candidates_with_same_score[@]}" | shuf))
for ((i=0; i<${#shuffled[@]} && i<${remaining}; i++)); do
SELECTED_REVIEWERS+=("${shuffled[i]}")
done
fi
if [[ ${#SELECTED_REVIEWERS[@]} -ge ${NEEDED_COUNT} ]]; then
break
fi
current_min_score="${score}"
candidates_with_same_score=("${member}")
fi
done <<< "${SORTED_CANDIDATES}"
# 最後の同じスコアグループの処理
if [[ ${#SELECTED_REVIEWERS[@]} -lt ${NEEDED_COUNT} ]] && [[ ${#candidates_with_same_score[@]} -gt 0 ]]; then
remaining=$((NEEDED_COUNT - ${#SELECTED_REVIEWERS[@]}))
shuffled=($(printf '%s\n' "${candidates_with_same_score[@]}" | shuf))
for ((i=0; i<${#shuffled[@]} && i<${remaining}; i++)); do
SELECTED_REVIEWERS+=("${shuffled[i]}")
done
fi
echo "🎲 選択されたレビュワー:"
printf '%s\n' "${SELECTED_REVIEWERS[@]}"
# 15. レビュワーをアサイン
if [[ "${DRY_RUN}" == "true" ]]; then
echo "⚠️ DRY RUN モードのため、実際のアサインはスキップします"
else
for reviewer in "${SELECTED_REVIEWERS[@]}"; do
echo "👥 @${reviewer} をレビュワーにアサイン中..."
gh pr edit "${PR_NUMBER}" --repo "${REPO}" --add-reviewer "${reviewer}"
done
fi
echo "=========================================="
echo "✅ レビュワー自動アサイン完了"
echo "=========================================="
echo "アサインされたレビュワー: ${SELECTED_REVIEWERS[*]}"
スコアリングアルゴリズム
本システムの核となるのは、レビュー負荷を数値化するスコアリングアルゴリズムです。
スコア計算式
スコア = (短期アサイン回数 × 短期重み) + (長期アサイン回数 × 長期重み)
デフォルト設定:
- 短期: 1日以内のアサイン × 重み5
- 長期: 7日以内のアサイン × 重み1
設計意図
短期の重みを大きくしている理由は、同日に同じメンバーへ大量にアサインが行われるのを防ぐためです。7日間の累積だけで判断すると、1日の間に特定のメンバーへレビュー依頼が集中してしまう可能性があります。
同スコア時の処理
スコアが同じメンバーが複数いる場合は、 shuf コマンドによるランダム選択を行います。これにより、長期的に見ても特定メンバーに偏らない仕組みになっています。
実行ログ例
実際の実行ログを以下に示します(一部マスク済み)。
==========================================
レビュワー自動アサインスクリプト開始
==========================================
PR番号: ****
リポジトリ: *****/****
📋 設定ファイルからレビュワー候補を取得中...
レビュワー候補:
member-a
member-b
member-c
member-d
...
📊 重み付け設定:
短期: 1日以内 × 重み5
長期: 7日以内 × 重み1
🔍 各メンバーのステータスを確認中...
✅ member-a: available
✅ member-b: available
✅ member-c: available
✅ member-d: available
🔍 PR #**** の情報を取得中...
PRオーナー: member-d
既存のレビュワー: member-a
📅 集計期間:
短期: 2025-12-15T09:58:27Z 以降(1日以内)
長期: 2025-12-09T09:58:27Z 以降(7日以内)
🔍 過去7日間のレビューアサイン状況を分析中...
📊 フィルタ後のアサインイベント数: 39
📊 レビューアサイン状況(available メンバーのみ):
メンバー: 短期(1日) / 長期(7日) → スコア
member-b: 2 / 8 → スコア 18
member-d: 2 / 6 → スコア 16
member-a: 3 / 8 → スコア 23
member-c: 2 / 6 → スコア 16
✅ レビュワー候補(busy、既存レビュワー、PRオーナーを除外):
member-b
member-c
🎯 追加で 1 名のレビュワーをアサインします
📈 候補者をスコア順にソート:
16 member-c
18 member-b
🎲 選択されたレビュワー:
member-c
👥 @member-c をレビュワーにアサイン中...
==========================================
✅ レビュワー自動アサイン完了
==========================================
アサインされたレビュワー: member-c
このログから、以下の動作が確認できます。
- PRオーナー(member-d)と既存レビュワー(member-a)が候補から除外
- 負荷スコアが最も低いメンバーであるmember-cを自動アサイン
運用上のポイント
メンバーの追加・削除
review-assign-rules.yml のreviewersリストを編集するだけで対応可能です。長期休暇中のメンバーは行をコメントアウトすることで一時的に除外できます。(GithubのBusyステータスを設定することでも除外可能です)
reviewers:
- member-a
# - member-b # 休暇中(12/20-1/3)
- member-c
ブランチ除外パターンの追加
baseブランチやreleaseブランチなど、自動アサインが不要なケースは exclusion_patterns で除外できます。
exclusion_patterns:
- "*/base"
- "release/*"
- "hotfix/*"
手動実行
自動アサインがスキップされた場合や、追加でレビュワーをアサインしたい場合は、PRコメントで /assign-reviewers と入力するだけでOKです。
まとめ
GitHub標準の自動レビュワーアサインロジックで発生していた「アサインの波」問題を解決するため、短期的な負荷を重視した独自の自動アサインシステムを構築しました。
ポイントは 短期・長期の重み付けスコアリング です。直近1日のアサインに高い重みを付けることで、「今まさにレビュー中」のメンバーへの追加アサインを避け、チーム内での負荷分散を実現しています。
補足として、現状のコードではレビュワーを2名で固定していますが、これは設定ファイルで変更可能にしても良いと思います。
以上。同様の課題を抱えているチームの参考になれば幸いです。