同僚が開発しているSlackのレビュー通知bot slack-review-notify を使っていて、re-review通知だけが営業時間制御の対象外であることに気づきました。保留→蓄積→翌朝一括送信の仕組みを提案・実装し、PRを出した話です。設計判断やE2Eテスト戦略に加えて、同僚のOSSに機能を提案するときに意識したことも書きます。
slack-review-notifyとは
slack-review-notifyは、GitHubのPRレビューリクエストをSlackに通知するbotです。同僚の@haruotsuくんが開発・公開しています。

主な機能はこちら。
- PRにラベルを付けるとSlackに通知
- レビュアーをランダムに自動アサイン
- 未レビューのPRをリマインド
- 営業時間の設定(チャンネルごと)
- レビュー完了の自動検知とお礼メッセージ
- 休暇管理
- 日本語 / 英語の切り替え
Slackコマンドだけで設定が完結するので、導入もかんたんです。

課題: 深夜のre-review通知
このbotにはすでに「営業時間外にラベルが付いたPRは翌朝まで通知を保留する」機能がありました。ところが、re-review(レビュー修正後の再レビュー依頼)だけはこの制御の対象外でした。
深夜に作業してGitHub上のre-reviewボタンを押すと、レビュアーのSlackへ即座に通知が飛びます。深夜に対応する必要はないのに、通知自体は来てしまう。チームで使っていて気になったので、改善を提案しました。
設計: 保留 → 蓄積 → 翌朝一括送信
通知を止めるだけなら単純ですが、以下の要件を満たす必要がありました。
- 送信者へのフィードバック: re-reviewを送った人に「営業時間外なので翌朝通知します」と即座に伝える
- 複数回の蓄積: 1つのPRに対して複数人が営業時間外にre-reviewする可能性がある
- 翌営業日の朝に確実に送信: 1分間隔のタスクチェッカーで営業時間開始を検知する
- 並行更新への耐性: タスクチェッカーとWebhookハンドラが同時に同じタスクを更新する可能性がある
データモデル
ReviewTaskモデルに3つのフィールドを追加しました。
type ReviewTask struct {
// ... 既存フィールド ...
PendingReReviewNotify bool // 保留中フラグ
PendingReReviewSender string // 送信者(カンマ区切りで蓄積)
PendingReReviewReviewer string // レビュアー(カンマ区切りで蓄積)
}
複数回のre-reviewは <@U123>,<@U456> のようにカンマ区切りで蓄積します。専用テーブルを作る選択肢もありましたが、既存のタスクチェッカーの仕組みに乗せるにはフィールド追加のほうがシンプルでした。
処理フロー
Webhookハンドラ(re-review受信時)
├─ 営業時間内 → 即座に通知(従来どおり)
└─ 営業時間外
├─ pending_re_review_notify = true
├─ sender/reviewerをカンマ区切りで追記(CAS付き)
└─ 「営業時間外のため翌営業日の朝に通知します」をスレッドに投稿
タスクチェッカー(1分間隔)
├─ pending_re_review_notify = true のタスクを検索
├─ 営業時間内かチェック
├─ sender/reviewerのペアごとに通知を送信
└─ フラグをクリア(CAS付き)
CASパターンで並行更新を防ぐ
処理フローを見ると、Webhookハンドラによるフラグのセットとタスクチェッカーによるクリアのタイミングで競合が起こりえます。排他制御なしだと、タスクチェッカーがフラグをクリアした直後にWebhookハンドラが新しいre-reviewをセットする場合があります。このとき、先にセットされた内容が失われます。
この問題に対してCAS(Compare-And-Swap)パターンを採用しました。UPDATE文のWHERE句にupdated_atを含めることで、読み取り時と更新時の間にレコードが変更されていないことを保証します。
// CAS: updated_at が変わっていなければ更新する
result := db.Model(&models.ReviewTask{}).
Where("id = ? AND updated_at = ?", taskID, expectedUpdatedAt).
Updates(map[string]interface{}{
"pending_re_review_notify": false,
"pending_re_review_sender": "",
"pending_re_review_reviewer": "",
"updated_at": now,
})
if result.RowsAffected == 0 {
log.Printf("CAS miss (concurrent update): task=%s", taskID)
}
RowsAffected == 0 のときは別のプロセスが先に更新しています。この場合は次回のチェックサイクル(1分後)で再処理されるので、データが失われることはありません。
データベースレベルのロック(SELECT ... FOR UPDATE)を使わない理由は2つあります。このbotがSQLiteで動いていること、そして1分間隔のポーリングなので「次のサイクルで拾えばよい」という割り切りができることです。
slackhogでE2Eテストを書く
Slack botのテストは厄介です。実際のSlack APIを叩くわけにはいかないし、モックだけでは通知が本当にスレッドに届くかわかりません。
同僚の@harakeishiくんがまさにこの課題に対してslackhogというSlack APIエミュレータを開発・公開していたので、今回のPRではこれを使ってE2Eテストを書きました。Dockerコンテナとして起動するだけでSlack互換のAPIが手に入り、通知の到達までテストコード内で検証できます。

テストの構成
# slackhog.e2e.yaml
port: 4112
max_messages: 1000
channels:
- C_E2E_BH
- C_E2E_NOBH
# Makefile
e2e: e2e-setup
$(GO) test -tags e2e -v -count=1 . || ($(MAKE) e2e-teardown && exit 1)
$(MAKE) e2e-teardown
-tags e2e でビルドタグを分離しているので、通常の go test ではE2Eテストは実行されません。
テストシナリオ
3つのシナリオを検証しました。
| シナリオ | 検証内容 |
|---|---|
| 営業時間内のre-review | slackhogのスレッドに即座に通知が到達する |
| 営業時間外のre-review → 営業時間開始 | 保留フラグがセットされ、チェッカー実行後にスレッドに到達する |
| 営業時間外に複数回re-review → 営業時間開始 | sender/reviewerが蓄積され、2件のスレッド返信として到達する |
ここまでで機能の設計・実装・テストの全体像が揃いました。以下では営業時間外のケースを例に、テストの具体的な流れを見ていきます。
// 1. 営業時間を極端に狭く設定(03:00-03:01)→ ほぼ確実に営業時間外
config := models.ChannelConfig{
BusinessHoursStart: "03:00",
BusinessHoursEnd: "03:01",
Timezone: "Asia/Tokyo",
}
// 2. Webhookを送信 → 保留される
resp := sendWebhook(t, ts.URL, payload)
// 3. フラグがセットされていることを確認
assert.True(t, deferredTask.PendingReReviewNotify)
// 4. 営業時間設定を変更して「今は営業時間内」にする
db.Model(&config).Updates(map[string]interface{}{
"business_hours_start": "00:00",
"business_hours_end": "23:59",
})
// 5. チェッカーを手動実行
services.CheckPendingReReviewNotifications(db)
// 6. slackhogのスレッドに通知が到達していることを確認
replies := getSlackhogReplies(t, parentID)
ポイントは「営業時間設定を動的に変更する」ことです。テスト時刻へ依存しない設計なので、実時刻を操作するライブラリは不要でした。
slackhogの確認用API
slackhogにはメッセージの確認用APIがあり、チャンネルごとのメッセージ一覧やスレッドの返信を取得できます。
// チャンネルのメッセージ一覧
GET /_api/messages?channel=C_E2E_BH
// 特定メッセージのスレッド返信
GET /_api/messages/{parentID}/replies
Web UIも用意されていて、テスト実行中にブラウザで http://localhost:14112 を開くとSlack風の画面でメッセージを確認できます。

同僚のOSSに機能提案するときに意識したこと
今回の変更はPR #92として提出しました。同僚のプロダクトに機能を足すにあたって意識した点を振り返ります。
既存の設計に乗る。新しいテーブルやgoroutineを追加せず、既存の ReviewTask モデルと1分間隔のタスクチェッカーの仕組みに乗せました。レビュアーが把握すべき新しい概念を減らすためです。
テストを手厚くする。自分のリポジトリなら「動いたからOK」で進めるところを、ユニットテスト8件 + E2Eテスト3件を書きました。CASのヒット/ミス、設定が消えた場合のフォールバック、完了済みタスクのスキップなどエッジケースも網羅しています。
「なぜこの設計か」を説明する。PR descriptionにはCASパターンを選んだ理由、カンマ区切りにした判断、テストシナリオの一覧を記載しました。コードだけでは伝わらない設計意図をレビュアーが追えるようにするためです。
なお、今回の実装はClaude Codeを活用して進めました。同僚の大事なプロダクトに手を入れるからこそ、設計方針やエッジケースの洗い出しは自分で判断しつつ、コーディングやテスト作成はClaude Codeに任せて石橋を叩くように進めました。
まとめ
- slack-review-notifyはGitHub PRのレビューフローをSlackで管理するbot。営業時間制御やリマインダーなど実用的な機能が揃っている
- re-review通知の営業時間外での遅延は、保留フラグ + カンマ区切り蓄積 + CASパターンで実装した
- Slack botのE2Eテストにはslackhogが便利。Docker起動だけでSlack互換APIが手に入り、メッセージの到達確認までテストコードで完結する
- 同僚のOSSに機能を提案するときは、既存設計に乗ること、テストを手厚くすること、設計意図を明文化することが大事



コメント