🚀

AIと自動化でPRレビューを爆速化!インターン生の生産性を最大化した開発体制の裏側

に公開

はじめに

こんにちは!SalesNowでデータチームに参画している tomo_hiro です。

私たちのチームでは、フルリモート・フルフレックスという自由な働き方を推奨していますが、その環境がゆえに、特にインターン生のPull Request(PR)レビューに時間がかかってしまうという課題がありました。

この記事では、その課題をどのように特定し、レビュー担当制度の導入AIとの協業による自動化を通じて、PR作成からクローズまでの時間を大幅に短縮した取り組みについてご紹介します。

本記事が、以下のような方々の参考になれば幸いです。

  • PRレビューのプロセスを円滑に進めたいエンジニアやプロジェクトリーダー
  • エンジニアインターンを考えている学生さん
  • SalesNowのデータチームに興味がある方

課題:なぜPRレビューは遅延していたのか?

私たちのチームが直面していたのは、インターン生からのPRレビュー依頼がクローズされるまでに時間がかかってしまう、という問題です。原因は大きく分けて2つありました。

  1. コミュニケーションのタイムラグ
    フルリモート・フルフレックスの環境では、作業時間が各自で異なるため、レビュー依頼から確認、そして指摘事項の反映までにどうしてもタイムラグが生まれがちでした。

  2. レビュー担当者の曖昧さ
    「誰がレビューすべきか」が明確でなく、結果的に誰もが「他の誰かが見てくれるだろう」と考えてしまう、いわゆる"お見合い状態"が発生していました。

この状況は、インターン生の開発体験を損なうだけでなく、チーム全体の生産性にも影響を与えかねない重要な課題でした。

解決策1:明確な役割分担!「レビュー担当」と「サポート担当」

まず着手したのは、人的なボトルネックの解消です。各案件に対して、以下の2つの役割を明確に定めました。

  • レビュー担当: PRレビュー依頼が来たその日のうちに、初回のフィードバックを返すことを目指す。
  • サポート担当: レビュー担当と協力し、PRが24時間以内にクローズされるようサポートする。

そして、インターン生にはPR作成時や関連する報告・連絡・相談の際に、必ず両担当者をSlackでメンションしてもらうルールを設けました。

これにより、「誰がボールを持っているか」が常に明確になり、レビュープロセスの初動が格段に速くなりました。

解決策2:AIと自動化によるPRプロセスの可視化と改善

人的な体制を整える一方で、私たちはさらなる改善のためにテクノロジーの力を活用することにしました。特にこのセクションは、本ブログで一番強調したいポイントです。

なぜ自動化が必要だったか?

  • 定量的評価: 「レビューが速くなった」という感覚だけでなく、具体的な数値で改善効果を測定したかった。
  • 放置PRの撲滅: どんなに体制を整えても、見落としは起こり得ます。放置されているPRを自動で検知し、アラートを出す仕組みが必要でした。

この2つの目的を達成するために、私たちは「PR分析スクリプト」と「放置PR検知ワークフロー」を開発しました。

1. PR分析スクリプトの作成(with AI)

PRの作成から初回コメント、そしてクローズまでの時間を計測するため、GitHub CLI (gh) を活用したシェルスクリプトを作成しました。

このPR分析スクリプトは、Gemini CLIとの対話を通じて作成しました。

当初から明確な指示書があったわけではなく、Gemini CLIと対話を重ねながら要件を固め、段階的にスクリプトを完成させていきました。

記事の付録に掲載している「指示書」は、その対話の過程で明確化された最終的な要件を後からドキュメントとしてまとめたものです。このように対話的に開発を進められるのがCLIツールの面白い点です。

ここでは、その結果として固まった要件(指示書)の特に重要なポイントを抜粋します。

  • 目的の明確化: 「何を」「どのような形式で」出力したいかを最初に定義
  • 再利用性の確保: 誰が使っても同じ結果になるよう、コマンドライン引数でリポジトリ等を指定可能に
  • 計算ロジックの定義: 「レビュー時間」の定義から、Botのコメントを除外するなど、細かい仕様を言語化

このように、要件を明確に定義することで、AIは極めて精度の高いコードを一度で生成してくれます。「AIにどう指示を出すか」というプロンプトエンジニアリングのスキルが、今後の開発においてますます重要になると感じた経験でした。

2. 放置PR検知ワークフロー (with n8n)

次に、自動化ツール「n8n」を使って、レビューが滞っているPRを検知し、Slackに通知するワークフローを構築しました。

このワークフローは以下のステップで実行されます。

  1. 定期実行: 平日の毎朝10時にワークフローをトリガーします。
  2. PR取得: GitHub APIを通じて、対象リポジトリの全てのPRを取得します。
  3. 経過時間計算: 各PRが作成されてから何時間経過したかを計算します。
  4. 条件分岐: 以下のすべての条件を満たすPRを抽出します。
    • Stateが open
    • 作成から 8時間 以上経過
    • intern ラベルが付与されている
  5. Slack通知: 条件に合致したPRが存在する場合、Slackの指定チャンネルにメンション付きでアラートを送信します。

この仕組みにより、レビュー担当者の見落としや対応漏れをシステムが自動で検知し、チーム全体にリマインドしてくれるようになりました。人間系の温かいサポートと、機械系の冷徹なリマインドの組み合わせが、迅速なレビューサイクルを支えています。

取り組みの成果と今後の展望

今回の仕組みを導入したことで、PRレビューのプロセスが可視化され、改善に向けた第一歩を踏み出すことができました。

今後は、作成した分析スクリプトを用いてリードタイムなどの数値を定期的に計測し、この取り組みの効果を定量的に評価していく予定です。そして、そのデータをもとに、さらなる改善点を見つけ出し、チーム全体の生産性向上に繋げていきたいと考えています。

まずは、インターン生がレビューの待ち時間でストレスを感じることなく、スムーズに開発を進められる環境を目指します。将来的にはこの仕組みを他のプロジェクトにも展開していくことも視野に入れています。

まとめ

本記事では、SalesNowのデータチームが、PRレビューの遅延という課題に対し、担当制度の導入AI・自動化技術の活用によってどのように立ち向かったかをご紹介しました。

  • 体制づくり: 「レビュー担当」「サポート担当」を明確化し、責任の所在をはっきりさせる。
  • 可視化: AIと協業して作成したスクリリプトで、レビュープロセスを定量的に計測する。
  • 自動化: n8nワークフローで放置PRを自動検知し、チームにリマインドする。

この取り組みは、テクノロジーとチームワークを融合させることで、開発プロセスをいかに効率化できるかという好例になったと感じています。

SalesNowでは新メンバーを募集しています!

SalesNowでは、このような開発プロセスの改善に積極的に取り組み、エンジニアが最高のパフォーマンスを発揮できる環境づくりに力を入れています。

少しでもご興味を持っていただけた方は、ぜひ下記の採用ページからお気軽にご応募ください!学生インターンも、正社員も、私たちはいつでも大歓迎です。

最後までお読みいただき、ありがとうございました!


付録

本記事で紹介したPR分析スクリプトを作成するためのAIへの指示書、生成されたスクリプト、およびn8nワークフロー定義の全文を以下に掲載します。

AIへの指示書: github_pr_analysis_instructions.md

概要

このドキュメントは、Gemini CLIとの対話を通じてPR分析スクリプトを作成した際の、最終的な要件をまとめたものです。AIにタスクを依頼する際のプロンプト設計の参考としてお使いください。

ファイルコンテンツ

# 指示書:GitHubプルリクエスト分析シェルスクリプトの作成

## 目的

GitHubリポジトリのプルリクエスト(PR)に関する情報を集計し、CSV形式で出力する再利用可能なシェルスクリプトを一度の指示で作成する。

## 作成するスクリプトの要件

`gh` (GitHub CLI) を使用して、以下の仕様を満たすシェルスクリプトを作成してください。

### 1. 基本機能

- 指定されたGitHubリポジトリのプルリクエスト情報を取得し、**CSV形式で標準出力に**出力する。
- CSVのヘッダーは以下の通りとすること。
  `"ID","Title","Owner","Time to First Comment (h)","Time to Close (h)","Merged"`

### 2. コマンドライン引数

以下の引数を受け取れるようにしてください。

- `-r <repository>`: **(必須)** リポジトリ名。
- `-o <owner>`: **(任意)** リポジトリのオーナー名。指定がない場合は、`gh`コマンドで現在認証しているユーザーをデフォルト値とすること。
- `-l <label>`: **(任意)** フィルタリング対象のラベル名。指定がない場合は、全てのPRを対象とすること。

### 3. データ処理要件

- オープンなPRとクローズされたPRの両方を対象とすること (`--state all`)。
- 最終的な出力は、**PRのIDの降順**でソートすること。
- PRのタイトルにCSVの区切り文字を壊す可能性のある二重引用符 (`"`) が含まれている場合、正しくエスケープ処理をすること(例:`"` を `""` に置換)。

### 4. CSV列の詳細な仕様

- **ID**: PR番号。
- **Title**: PRのタイトル。
- **Owner**: PRの作成者(`author.login`)。
- **Time to First Comment (h)**:
    - PR作成から**最初のコメント**がつくまでの所要時間。
    - ただし、PR作成者自身、`coderabbitai`、およびその他のBotによるコメントは除外して計算すること。
    - コメントがない場合は、このセルは空欄にすること。
    - 単位は**時間(hour)**とし、小数点第2位までの数値で出力すること(例: `0.75`)。
- **Time to Close (h)**:
    - PR作成から**クローズ**されるまでの所要時間。
    - まだクローズされていないPRの場合、このセルは空欄にすること。
    - 単位は**時間(hour)**とし、小数点第2位までの数値で出力すること(例: `21.59`)。
- **Merged**:
    - マージされていれば `"Yes"`、マージされずにクローズされていれば `"No"` を出力すること。

### 5. 技術的なヒント

- 時間の計算は秒単位で行った後、3600で割ることで時間に変換してください。Bashは浮動小数点計算をネイティブサポートしないため、`awk` や `bc` などの外部コマンドを利用することを推奨します。
- `gh`コマンドが出力するISO 8601形式の日時文字列を正しく解釈して計算してください。

生成されたPR分析スクリプト: gh_pr_report.sh

概要

指定されたGitHubリポジトリのPR情報を集計し、レビューまでの時間やクローズまでの時間をCSV形式で出力するシェルスクリプトです。
注意: このスクリプトはmacOS (BSD) のdateコマンドの構文を使用しています。Linux環境で実行する際にエラーとなる可能性があります。

ファイルコンテンツ

#!/bin/bash

# Default values
DEFAULT_OWNER=$(gh api user --jq .login)
DEFAULT_LABEL=""

# Usage function
usage() {
  echo "Usage: $0 -r <repository> [-o <owner>] [-l <label>]"
  echo "  -r <repository>: The name of the repository (required)."
  echo "  -o <owner>: The owner of the repository (default: $DEFAULT_OWNER)."
  echo "  -l <label>: The label to filter pull requests by (default: all PRs)."
  exit 1
}

# Parse arguments
while getopts "r:o:l:" opt; do
  case $opt in
    r) REPO="$OPTARG" ;;
    o) OWNER="$OPTARG" ;;
    l) LABEL="$OPTARG" ;;
    *) usage ;;
  esac
done

# Check for required repository argument
if [ -z "$REPO" ]; then
  usage
fi

# Set owner to default if not provided
OWNER=${OWNER:-$DEFAULT_OWNER}

# Prepare label flag for gh command
LABEL_FLAG=""
if [ -n "$LABEL" ]; then
  LABEL_FLAG="--label \"$LABEL\""
fi

# Output CSV header
echo '"ID","Title","Owner","Time to First Comment (h)","Time to Close (h)","Merged"'

# Get list of PRs, sorted by number descending
PR_LIST=$(gh pr list -R "$OWNER/$REPO" --state all $LABEL_FLAG --json number --jq '.[].number' | sort -rn)

# Check if any PRs were found
if [ -z "$PR_LIST" ]; then
  exit 0
fi

# Process each PR
echo "$PR_LIST" | while read -r pr_number; do
  # Get PR details in JSON format
  pr_json=$(gh pr view "$pr_number" -R "$OWNER/$REPO" --json number,title,author,createdAt,closedAt,mergedAt,comments)

  # Extract fields using jq
  id=$(jq -r '.number' <<< "$pr_json")
  title=$(jq -r '.title' <<< "$pr_json" | sed 's/"/""/g') # Escape double quotes for CSV
  pr_author=$(jq -r '.author.login' <<< "$pr_json")
  created_at=$(jq -r '.createdAt' <<< "$pr_json")
  closed_at=$(jq -r '.closedAt' <<< "$pr_json")
  merged_at=$(jq -r '.mergedAt' <<< "$pr_json")

  # --- Calculate Time to First Comment in Hours ---
  first_comment_at=$(jq -r --arg author "$pr_author" '.comments | map(select(.author.login != $author and .author.login != "coderabbitai" and .author.login != null and .author.is_bot != true)) | .[0].createdAt // ""' <<< "$pr_json")

  time_to_comment_hours=""
  if [ -n "$first_comment_at" ]; then
    created_ts=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$created_at" "+%s")
    comment_ts=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$first_comment_at" "+%s")
    time_in_seconds=$((comment_ts - created_ts))
    time_to_comment_hours=$(echo "$time_in_seconds 3600" | awk '{printf "%.2f", $1 / $2}')
  fi

  # --- Calculate Time to Close in Hours ---
  time_to_close_hours=""
  if [ "$closed_at" != "null" ]; then
    created_ts=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$created_at" "+%s")
    closed_ts=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$closed_at" "+%s")
    time_in_seconds=$((closed_ts - created_ts))
    time_to_close_hours=$(echo "$time_in_seconds 3600" | awk '{printf "%.2f", $1 / $2}')
  fi

  # --- Determine Merged Status ---
  if [ "$merged_at" != "null" ]; then
    merged="Yes"
  else
    merged="No"
  fi

  # --- Output CSV Row ---
  echo "\"$id\",\"$title\",\"$pr_author\",\"$time_to_comment_hours\",\"$time_to_close_hours\",\"$merged\""
done

n8n ワークフロー定義: Give_me_a_review_ASAP.json

概要

一定時間以上オープン状態になっているinternラベル付きのPRを検知し、Slackに通知するn8nのワークフロー定義です。
注意: このワークフローは、毎日10時に実行されるようにcron式(0 10 * * 1-5)が設定されています。この時間はn8nを実行している環境のタイムゾーンに依存します。必要に応じて、お使いの環境のタイムゾーン設定を確認し、実行時間を調整してください。
詳細はn8nの公式ドキュメントをご参照ください: https://docs.n8n.io/hosting/configuration/configuration-examples/time-zone/

ファイルコンテンツ

{
  "name": "Give me a review ASAP",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 10 * * 1-5"
            }
          ]
        }
      },
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ],
      "id": "7973bfed-14fa-464d-ac7e-4bc9ae922520",
      "name": "Schedule Trigger"
    },
    {
      "parameters": {
        "resource": "repository",
        "operation": "getPullRequests",
        "owner": {
          "__rl": true,
          "value": "=",
          "mode": "url"
        },
        "repository": {
          "__rl": true,
          "value": "=",
          "mode": "url"
        },
        "returnAll": true,
        "getRepositoryPullRequestsFilters": {}
      },
      "type": "n8n-nodes-base.github",
      "typeVersion": 1.1,
      "position": [
        220,
        0
      ],
      "id": "ab302ea1-4295-4138-b4dc-3b99d4da3b41",
      "name": "Get pull requests of a repository",
      "webhookId": "f32db092-7905-4264-9f4e-534817d43063",
      "credentials": {
        "githubApi": {
          "id": "B0u7U0NcsYWtQRMp",
          "name": "GitHub account"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const now = new Date();\n\nconst oldPRs = $input.all().map(pr => {\n  const createdAt = new Date(pr.json.created_at);\n  const hoursPassed = (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60);\n  \n  return {\n    ...pr,\n    hoursPassed,\n  };\n});\n\nreturn oldPRs.map(pr => ({ json: pr }));"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        0
      ],
      "id": "7eb47eb9-366a-4eec-a512-1831e7f611cf",
      "name": "Code"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "58c6a741-9803-4e31-8dcd-71a221f81bb8",
              "leftValue": "={{ $json.json.state }}",
              "rightValue": "open",
              "operator": {
                "type": "string",
                "operation": "equals",
                "name": "filter.operator.equals"
              }
            },
            {
              "id": "af2c0eaa-7e27-4af7-8845-9781f66f4fcb",
              "leftValue": "={{ $json.hoursPassed }}",
              "rightValue": 8,
              "operator": {
                "type": "number",
                "operation": "gte"
              }
            },
            {
              "id": "3fec264a-b62b-4177-82b5-f26e80565666",
              "leftValue": "={{$json.json[\"labels\"].map(label => label.name)}}",
              "rightValue": "intern",
              "operator": {
                "type": "array",
                "operation": "contains",
                "rightType": "any"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        660,
        0
      ],
      "id": "4e8d54bb-82a4-401c-92b0-80c444d8e59d",
      "name": "If"
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "=",
          "mode": "id"
        },
        "text": "=🚨 <!channel> 以下のPRが作成から8時間以上経過していますが、まだクローズされていません。\n優先度を上げてご確認・レビュー対応をお願いします。\n\n- タイトル: {{ $json.json.title }}\n- 作成者: {{ $json.json.user.login }}\n- 作成日時: {{ $json.json.created_at }}\n- URL: {{ $json.json.html_url }}",
        "otherOptions": {}
      },
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        940,
        -20
      ],
      "id": "44a6dbf0-2490-40c0-b17e-683f703c51da",
      "name": "Send a message",
      "webhookId": "63303652-36e0-42d9-bcad-c74bd9c048d1",
      "credentials": {
        "slackApi": {
          "id": "7NIMtdlxlp1QuGEs",
          "name": "Slack account"
        }
      }
    }
  ],
  "pinData": {},
  "connections": {
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Get pull requests of a repository",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get pull requests of a repository": {
      "main": [
        [
          {
            "node": "Code",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code": {
      "main": [
        [
          {
            "node": "If",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If": {
      "main": [
        [
          {
            "node": "Send a message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "e427be33-df91-4ffd-98fb-8e88aa31b60a",
  "meta": {
    "templateCredsSetupCompleted": true,
    "instanceId": "a4ce47c745ce3a51cf7ad0dd74ff81c0bd160cd5c49ba203ac3af6eb0fb583bf"
  },
  "id": "AnIaH0jxnBJnP2fs",
  "tags": []
}
SalesNow Tech Blog

Discussion