🕌

「レビュー待ち」ラベル付与からのリードタイムを可視化

に公開

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

背景

前回の記事では、PRの状態管理ラベルを整備し、GitHub Actions を使ってレビュー依頼を自動でリマインドする仕組みを導入しました。

しかし、新たな課題が見えてきました。それは、「どのPRが、どれくらいの時間レビューを待たされているのか」が分からないという問題です。リマインダーはあくまで「気づかせる」ための仕組みであり、レビュープロセスそのものが健全な速度で回っているかを評価する指標にはなりません。

そこで、次のステップとして 「レビュー待ち」のリードタイムを計測・可視化する仕組み を導入しました。これにより、開発プロセスにおけるボトルネックを特定し、データに基づいた改善サイクルを回すことを目指します。

課題:レビューの「待ち時間」というブラックボックス

  • レビュー滞留が感覚値でしか分からない: 「このPR、なんだかレビューに時間がかかっているな」と感じることはあっても、それが具体的に何時間なのか、チームの平均と比べてどうなのかを客観的に把握できません。

  • 改善の打ち手があいまいになる: データがないため、「レビューが遅い」という問題に対して「もっと頑張ってレビューしよう」といった精神論に陥りがちです。ボトルネックがレビュアーの負荷にあるのか、PRの内容の複雑さにあるのかを切り分けられません。

  • チームの成長を測定できない: プロセス改善の効果を測るための定量的な指標がありませんでした。

    • マージされた時間を取得するようなことはまだしていないです。測定を残すような仕組みは保留にしています。

これらの課題を可視化するため、私たちは「『レビュー待ち』ラベルが付与された瞬間から、PRがレビューされないまま放置されている時間を自動で計測し、Slackに通知する」仕組みを構築しました。

※本当はチームの課題を可視化するために Four Keys とか導入したいけど、それをいれるための監視等の基盤がないので、一旦保留にしています。

この記事で解決すること

  • 「レビュー待ち」ラベルが いつ付いたか を基準に 経過時間(d/h/m) を算出
  • しきい値(例: 24h/48h/72h) に応じて Slack に 注意アイコン を自動付与
  • 古い順(待機が長い順) に並べて、レビュアーが どれから見れば良いか一目で分かる
  • 祝日・週末の 自動スキップ、PR数が多くても 取りこぼさない(paginate)、GitHub APIの気まぐれ(mergeable_state: "unknown")にも 再取得で強く する

全体像(なにをやったか)

  1. 「レビュー待ち」ラベルの最新付与時刻を、issues/{number}/events から取得
  2. そこからの経過時間をJSTで見やすく整形(3d 4h 12m など)
  3. WARN_THRESHOLDS(JSON)で、経過時間→アイコンを柔軟に設定
  4. 経過時間の長い順にソートして Slack に投稿
  5. 要リベース(conflict/behind)を自動判定して強調表示
  6. 実運用の棘を回避(祝日・週末スキップ、paginate、Slack文字数上限対策 等)

イメージ

  • 例)72h+ :fire:, 48h+ :rotating_light:, 24h+ :hourglass_flowing_sand: のように古い順で並ぶ

#123 Fix: 検索APIのN+1(要リベース :warning:)
リードタイム: 3d 2h 5m(since 2025-09-02 09:15 JST)
レビュアー: @alice @bob


導入ステップ(ざっくり)

  1. 「レビュー待ち」ラベル運用は前回記事のとおり
  2. Slack webhook と SLACK_USER_MAP(GitHub login → Slack ユーザーIDのJSON)を secrets に設定(これも前回記事のとおり)
  3. 下記 サンプル.github/workflows/review-reminder.yml として配置

サンプル(コピペ用)

ポイント:

  • permissions 明示(読み取りのみ)
  • concurrencytimeout で暴走を抑止
  • 週末/祝日スキップ(API落ちでも通知は継続)
  • paginate で PR 取りこぼし防止
  • mergeable_state="unknown" 再取得
  • Slack ブロック分割で文字数上限(~3000/section)を回避
name: PR Review Reminder

permissions: read-all

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

on:
  schedule:
    # JST 09:00 / 11:00 / 13:00 / 15:00 = UTC 00:00 / 02:00 / 04:00 / 06:00(平日)
    - cron: '0 0 * * 1-5'
    - cron: '0 2 * * 1-5'
    - cron: '0 4 * * 1-5'
    - cron: '0 6 * * 1-5'
  workflow_dispatch:

env:
  LABEL_NAME: 'レビュー待ち'
  # 経過時間に応じた注意アイコン(自由に編集可)
  WARN_THRESHOLDS: >-
    [{"hours":24,"icon":":hourglass_flowing_sand:"},{"hours":48,"icon":":rotating_light:"},{"hours":72,"icon":":fire:"}]

jobs:
  remind:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Skip on weekends / Japan public holidays (JST)
        id: holiday
        shell: bash
        run: |
          set -euo pipefail
          TODAY=$(TZ=Asia/Tokyo date +%Y-%m-%d)
          # weekend short-circuit (Sat=6, Sun=7)
          if [[ $(TZ=Asia/Tokyo date +%u) -ge 6 ]]; then
            echo "skip=true" >> "$GITHUB_OUTPUT"
            echo "Weekend. Skip."
            exit 0
          fi
          HOLIDAYS=$(curl -fsSL https://holidays-jp.github.io/api/v1/date.json || true)
          if [[ -n "${HOLIDAYS}" ]] && echo "${HOLIDAYS}" | jq -e --arg d "${TODAY}" 'has($d)' >/dev/null; then
            echo "skip=true" >> "$GITHUB_OUTPUT"
            echo "JP public holiday (${TODAY}). Skip."
          else
            echo "skip=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Query open PRs with the label (lead time, sort, thresholds)
        id: prs
        if: steps.holiday.outputs.skip != 'true'
        uses: actions/github-script@v7
        env:
          SLACK_USER_MAP: ${{ secrets.SLACK_USER_MAP }}
        with:
          script: |
            // ----- Config -----
            const label = process.env.LABEL_NAME;

            // Slack map (github login -> Slack user id)
            let mapping = {};
            try { mapping = JSON.parse(process.env.SLACK_USER_MAP || "{}"); }
            catch (e) { core.warning(`SLACK_USER_MAP invalid: ${e.message}`); }

            // thresholds [{hours, icon}]
            let thresholds = [];
            try {
              const raw = JSON.parse(process.env.WARN_THRESHOLDS || "[]");
              if (Array.isArray(raw)) {
                thresholds = raw
                  .filter(t => Number.isFinite(t.hours) && t.hours >= 0 && typeof t.icon === "string" && t.icon.trim() !== "")
                  .map(t => ({ hours: Number(t.hours), icon: String(t.icon) }));
              }
            } catch (e) {
              core.warning(`WARN_THRESHOLDS invalid: ${e.message}`);
            }
            if (thresholds.length === 0) {
              thresholds = [
                { hours: 24, icon: ":hourglass_flowing_sand:" },
                { hours: 48, icon: ":rotating_light:" },
                { hours: 72, icon: ":fire:" }
              ];
              core.notice("Using default thresholds 24/48/72h.");
            }
            thresholds.sort((a, b) => a.hours - b.hours);

            // ----- Utils -----
            const fmtJST = (iso) => {
              try {
                return new Intl.DateTimeFormat("ja-JP", {
                  timeZone: "Asia/Tokyo",
                  year: "numeric", month: "2-digit", day: "2-digit",
                  hour: "2-digit", minute: "2-digit", hour12: false
                })
                .format(new Date(iso))
                .replace(/\//g, "-")
                .replace(/(\d{4})-(\d{2})-(\d{2}),\s?(\d{2}):(\d{2})/, "$1-$2-$3 $4:$5");
              } catch { return iso; }
            };
            const formatDuration = (ms) => {
              if (!Number.isFinite(ms) || ms < 0) return "0m";
              const totalMin = Math.floor(ms / 60000);
              const d = Math.floor(totalMin / (60 * 24));
              const h = Math.floor((totalMin % (60 * 24)) / 60);
              const m = totalMin % 60;
              const parts = [];
              if (d) parts.push(`${d}d`);
              if (h || d) parts.push(`${h}h`);
              parts.push(`${m}m`);
              return parts.join(" ");
            };
            const warnIconFor = (sinceHours) => {
              if (!Number.isFinite(sinceHours) || sinceHours < 0) return "";
              let icon = "";
              for (const th of thresholds) if (sinceHours >= th.hours) icon = ` ${th.icon}`;
              return icon;
            };
            const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
            const sanitizeLinkText = (s) => esc(String(s)).replace(/\|/g, '¦');
            const chunkLines = (lines, limit = 2900) => {
              const chunks = []; let buf = '';
              for (const line of lines) {
                if ((buf + line + '\n').length > limit) { chunks.push(buf); buf = ''; }
                buf += line + '\n';
              }
              if (buf) chunks.push(buf);
              return chunks;
            };
            const withBackoff = async (fn, tries = 2) => {
              let last; for (let i=0;i<tries;i++){ try { return await fn(); }
                catch(e){ last = e; if (e?.status === 429) await new Promise(r=>setTimeout(r, 1500*(i+1))); else break; } }
              throw last;
            };

            // ----- Fetch PRs (paginate) -----
            const pulls = await github.paginate(github.rest.pulls.list, {
              owner: context.repo.owner, repo: context.repo.repo,
              state: "open", per_page: 100
            });

            // Filter by label
            const targetLabel = String(label || "").toLowerCase();
            const candidate = pulls.filter(pr =>
              (pr.labels || []).some(l => (l.name || "").toLowerCase() === targetLabel)
            );

            if (candidate.length === 0) {
              const header = "👀 レビュー待ちのPR(0件)";
              core.setOutput("count", "0");
              core.setOutput("header", header);
              core.setOutput("body_blocks", JSON.stringify([
                { "type": "header", "text": { "type": "plain_text", "text": header, "emoji": true } },
                { "type": "section", "text": { "type": "mrkdwn", "text": "• 該当なし :tada:" } }
              ]));
              return;
            }

            // ----- Build rows -----
            const prRows = await Promise.all(candidate.map(async (pr) => {
              // details
              let prDetail = null;
              try {
                prDetail = await withBackoff(() => github.request("GET /repos/{owner}/{repo}/pulls/{pull_number}", {
                  owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number
                }));
                if (prDetail?.data?.mergeable_state === "unknown") {
                  await new Promise(r => setTimeout(r, 1500));
                  prDetail = await withBackoff(() => github.request("GET /repos/{owner}/{repo}/pulls/{pull_number}", {
                    owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number
                  }));
                }
              } catch {}

              // events -> labeled time
              let events = [];
              try {
                events = await github.paginate("GET /repos/{owner}/{repo}/issues/{issue_number}/events", {
                  owner: context.repo.owner, repo: context.repo.repo,
                  issue_number: pr.number, per_page: 100
                });
              } catch { events = []; }

              let labeledAt = null;
              for (let i = events.length - 1; i >= 0; i--) {
                const e = events[i];
                if (e.event === "labeled" && e.label && (e.label.name || "").toLowerCase() === targetLabel) {
                  labeledAt = e.created_at; break;
                }
              }
              const sinceMs = labeledAt ? (Date.now() - new Date(labeledAt).getTime()) : -1;
              const sinceHours = sinceMs >= 0 ? Math.floor(sinceMs / 3600000) : -1;

              // rebase markers(compareは必要時だけ)
              let rebaseMark = "";
              let behind = 0;
              const state = prDetail?.data?.mergeable_state;
              if (state === "dirty") {
                rebaseMark = " — :no_entry_sign: *要リベース*";
              } else if (state === "behind") {
                rebaseMark = " — :warning: *要リベース*";
              } else {
                try {
                  const compare = await github.request("GET /repos/{owner}/{repo}/compare/{basehead}", {
                    owner: context.repo.owner, repo: context.repo.repo,
                    basehead: `${pr.base.ref}...${pr.head.sha}`
                  });
                  behind = compare?.data?.behind_by ?? 0;
                  if (behind > 0) rebaseMark = " — :warning: *要リベース*";
                } catch {}
              }

              // reviewers
              let missingMap = 0;
              const reviewersUsers = (pr.requested_reviewers || []).map(r => {
                const id = mapping[r.login];
                if (!id) missingMap++;
                return id ? `<@${id}>` : `@${r.login}`;
              });
              const reviewersTeams = (pr.requested_teams || []).map(t => {
                const org = pr.base?.repo?.owner?.login || context.repo.owner;
                return `@${org}/${t.slug}`;
              });
              const reviewers = [...reviewersUsers, ...reviewersTeams];
              const reviewerLine = reviewers.length ? `> *レビュアー:* ${reviewers.join(" ")}` : null;

              const warnIcon = warnIconFor(sinceHours);
              const firstLine = `• <${pr.html_url}|#${pr.number} ${sanitizeLinkText(pr.title)}>  (by @${pr.user.login}, base: ${esc(pr.base.ref)})${rebaseMark}${warnIcon}`;
              const leadTimeLine = labeledAt
                ? `> *リードタイム:* ${formatDuration(sinceMs)}(since ${fmtJST(labeledAt)} JST)`
                : `> *リードタイム:* 不明`;

              return {
                sinceMs,
                lines: [firstLine, leadTimeLine, reviewerLine].filter(Boolean).join("\n"),
                missingMapCount: missingMap,
                teamCount: reviewersTeams.length
              };
            }));

            // sort by longest waiting first (unknown last)
            prRows.sort((a, b) => {
              if (a.sinceMs < 0 && b.sinceMs < 0) return 0;
              if (a.sinceMs < 0) return 1;
              if (b.sinceMs < 0) return -1;
              return b.sinceMs - a.sinceMs;
            });

            const header = `👀 レビュー待ちのPR(${prRows.length}件)`;
            const lines = prRows.map(r => r.lines);
            const pages = chunkLines(lines);

            const missingTotal = prRows.reduce((s, r) => s + r.missingMapCount, 0);
            const teamTotal = prRows.reduce((s, r) => s + r.teamCount, 0);
            if (missingTotal > 0) core.notice(`SLACK_USER_MAP has ${missingTotal} unmapped user(s).`);
            if (teamTotal > 0) core.notice(`There are ${teamTotal} team reviewer(s).`);

            const blocks = [
              { type: "header", text: { type: "plain_text", text: header, emoji: true } },
              ...pages.map(p => ({ type: "section", text: { type: "mrkdwn", text: p } }))
            ];
            core.setOutput("count", String(prRows.length));
            core.setOutput("header", header.trim());
            core.setOutput("body_blocks", JSON.stringify(blocks));

      - name: Post to Slack
        if: steps.holiday.outputs.skip != 'true'
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.PR_REVIEW_SLACK_WEBHOOK_URL }}
        run: |
          set -euo pipefail
          header="${{ steps.prs.outputs.header }}"
          blocks='${{ steps.prs.outputs.body_blocks }}'
          payload=$(jq -n --arg header "$header" --argjson blocks "$blocks" \
            '{ text: $header, blocks: $blocks }')
          curl -fsS -X POST -H 'Content-Type: application/json' \
               --data "$payload" \
               "$SLACK_WEBHOOK_URL"

運用のコツ(失敗しないために)

  • SLACK_USER_MAP の欠落は Notice に出るので、見つけたら随時補完(例: {"octocat":"UXXXXXXX"}
  • 祝日API ダウンでも通知は止めない方針(フォールバック済み)
  • PR が多い時は GraphQL API に移行すると更にHTTP回数を削減できる
  • Slackの文字数上限で落ちないよう、セクション分割を採用

しきい値の決め方(例)

  • 24h⏳:当日中に気づいてね
  • 48h🚨:優先確認
  • 72h🔥:燃えてる(本当に見て…)

しきい値は WARN_THRESHOLDS の JSON を編集するだけで変更可能です。


付録:Secrets/Vars の例

  • PR_REVIEW_SLACK_WEBHOOK_URL(Slack Incoming Webhook)

  • SLACK_USER_MAP(JSON)

    { "shimadama": "U0123456AA", "madamada": "U0123456BB" }
    
  • WARN_THRESHOLDSenv で上書き可能(リポジトリ変数 vars.WARN_THRESHOLDS に移してもOK)

Discussion