Stanby Tech Blog

求人検索エンジン「スタンバイ」を運営するスタンバイの開発組織やエンジニアリングについて発信するブログです。

DevinによるPRレビューの自動化

はじめに

こんにちは。プロダクト部SEOグループの伊田です。 スタンバイに入社して2ヶ月が経ち、徐々に業務にも慣れてきています!

最近、社内の技術カンファレンスで登壇する機会をいただきました。その発表の中で紹介した取り組みの1つが、Devinを使ったPRレビューの自動化です。 今回は、その仕組みを導入した背景と実際の運用方法について紹介します。

導入した背景

私たちSEOGでは、日々の開発でGitHubのPull Request(以下、PR)を使ってコードレビューしていますが、 機械的に確認できる細かい項目も多く、レビュアーの負担になっていると感じていました。 そこで考えたのが、「PRのレビューの初期段階をAIに任せられないか?」ということでした。

  • 基本的なコーディング規約のチェック
  • 明らかなバグやtypoの指摘
  • テストカバレッジの確認
  • セキュリティ上の問題点の洗い出し

これらをDevinが自動で行ってくれれば、人間のレビュアーはより本質的な部分(ドメイン知識が必要な部分や、設計の妥当性など)に集中でき、レビュアーの負荷軽減とレビュー時間の短縮が期待できそうです。 そこで、PR作成と同時にDevinにレビューさせる仕組みを構築しました。

仕組みの概要

実際には2種類の起動方法を用意しています。 PR作成をトリガーにするパターンと、PRコメントをトリガーにするパターンがあります。

パターン1: PR作成時の自動レビュー

PR作成と同時に自動的にDevinがレビューを開始します。日常的なレビューフローへの組み込みが目的となります。

  • トリガー: pull_request.opened イベント
  • デフォルトプロンプト: /devin !prr (PRレビュー用のカスタムコマンド)

パターン2: PRコメントによるカスタムタスク

PRコメントで /devin の後にカスタムプロンプトを入力することで、任意のタスクを実行できます。用途としては、レビュー以外の観点や、特定の用途で使用します。

例:

/devin このPRのテストカバレッジを改善してください
/devin セキュリティ上の問題がないか確認してください
/devin パフォーマンスの最適化案を提案してください
  • トリガー: issue_comment.created イベント(/devin で始まるコメント)
  • リクエスト者情報も記録され、Slackに表示

実装コード

実装には2つのワークフローファイルが必要です。

呼び出し元ワークフロー(devin-slack-code-actions.yml)

name: Devin Slack Code Actions

on:
  issue_comment:
    types: [created]
  pull_request:
    types: [opened]
  workflow_dispatch:
    inputs:
      pr_number:
        description: 'Pull Request number to review'
        required: false
        type: number

jobs:
  parse-devin-prompt:
    runs-on: ubuntu-latest
    # Run on: PR opened, issue comments starting with /devin, or manual workflow dispatch
    if: |
      github.event_name == 'workflow_dispatch' ||
      github.event_name == 'pull_request' ||
      (github.event_name == 'issue_comment' &&
       github.event.issue.pull_request &&
       startsWith(github.event.comment.body, '/devin'))
    outputs:
      pr_number: ${{ steps.extract.outputs.pr_number }}
      custom_prompt: ${{ steps.extract.outputs.custom_prompt }}
      comment_author: ${{ steps.extract.outputs.comment_author }}

    steps:
    - name: Extract information
      id: extract
      run: |
        if [ "${{ github.event_name }}" == "pull_request" ]; then
          PR_NUMBER=${{ github.event.pull_request.number }}
          CUSTOM_PROMPT="/devin !prr"
          COMMENT_AUTHOR="${{ github.event.pull_request.user.login }}"
        elif [ "${{ github.event_name }}" == "issue_comment" ]; then
          PR_NUMBER=${{ github.event.issue.number }}
          COMMENT="${{ github.event.comment.body }}"
          CUSTOM_PROMPT=$(echo "$COMMENT" | sed 's|^/devin[[:space:]]*||' | xargs)
          COMMENT_AUTHOR="${{ github.event.comment.user.login }}"
        else
          PR_NUMBER=${{ inputs.pr_number }}
          CUSTOM_PROMPT=""
          COMMENT_AUTHOR=""
        fi

        echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
        echo "custom_prompt=$CUSTOM_PROMPT" >> $GITHUB_OUTPUT
        echo "comment_author=$COMMENT_AUTHOR" >> $GITHUB_OUTPUT

  call-devin-slack:
    needs: parse-devin-prompt
    uses: stanby-inc/stanby-github-actions-common/.github/workflows/devin-slack-code-action.yml@master
    secrets: inherit
    with:
      pr_number: ${{ fromJSON(needs.parse-devin-prompt.outputs.pr_number) }}
      custom_prompt: ${{ needs.parse-devin-prompt.outputs.custom_prompt }}
      comment_author: ${{ needs.parse-devin-prompt.outputs.comment_author }}
      repository: ${{ github.repository }}

共通ワークフロー(devin-slack-code-action.yml)

name: Devin Slack Code Action

on:
  workflow_call:
    inputs:
      pr_number:
        description: 'Pull Request number'
        required: true
        type: string
      custom_prompt:
        description: 'Custom prompt for Devin'
        required: false
        type: string
        default: ''
      comment_author:
        description: 'Author of the comment that triggered this workflow'
        required: false
        type: string
        default: ''
      repository:
        description: 'Repository name (owner/repo)'
        required: true
        type: string

jobs:
  slack-notification:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v4
      with:
        repository: ${{ inputs.repository }}
        fetch-depth: 0

    - name: Get PR Information
      id: pr_info
      run: |
        PR_NUMBER=${{ inputs.pr_number }}
        # Extract PR info using GitHub API
        PR_DATA=$(gh pr view $PR_NUMBER --repo ${{ inputs.repository }} --json title,author,url 2>/dev/null || echo '{}')
        if [ "$PR_DATA" = "{}" ]; then
          echo "Error: Failed to fetch PR #$PR_NUMBER data"
          exit 1
        fi
        PR_TITLE=$(echo $PR_DATA | jq -r .title)
        PR_AUTHOR=$(echo $PR_DATA | jq -r .author.login)
        PR_URL=$(echo $PR_DATA | jq -r .url)

        echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
        echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT
        echo "pr_author=$PR_AUTHOR" >> $GITHUB_OUTPUT
        echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT
      env:
        GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

    - name: Get Changed Files
      id: changed_files
      run: |
        # Get list of changed files using gh command
        CHANGED_FILES=$(gh pr diff ${{ inputs.pr_number }} --repo ${{ inputs.repository }} --name-only | head -20)
        # Store as plain text, not JSON escaped
        echo "files<<EOF" >> $GITHUB_OUTPUT
        echo "$CHANGED_FILES" >> $GITHUB_OUTPUT
        echo "EOF" >> $GITHUB_OUTPUT
      env:
        GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

    - name: Send Slack Message to Devin
      id: slack_message
      env:
        SLACK_BOT_TOKEN: ${{ secrets.DEVIN_SLACK_BOT_TOKEN }}
        SLACK_CHANNEL_ID: ${{ secrets.DEVIN_SLACK_CHANNEL_ID }}
        DEVIN_BOT_USER_ID: ${{ secrets.DEVIN_BOT_USER_ID }}
      run: |
        # Construct the message with @Devin mention
        if [ -n "${{ inputs.custom_prompt }}" ]; then
          # Custom prompt from /devin comment
          # Properly escape the custom prompt for JSON
          ESCAPED_PROMPT=$(echo "${{ inputs.custom_prompt }}" | jq -Rs .)
          REVIEW_REQUEST="<@$DEVIN_BOT_USER_ID> PR #${{ steps.pr_info.outputs.pr_number }} - $(echo $ESCAPED_PROMPT | jq -r .)"
          REQUESTER_INFO="_Requested by: ${{ inputs.comment_author }}_"
        else
          # Default request
          REVIEW_REQUEST="<@$DEVIN_BOT_USER_ID> Please check PR #${{ steps.pr_info.outputs.pr_number }} at ${{ steps.pr_info.outputs.pr_url }}"
          REQUESTER_INFO=""
        fi

        # Prepare changed files text for JSON
        CHANGED_FILES_TEXT=$(echo "${{ steps.changed_files.outputs.files }}" | jq -Rs .)

        # Send message using Slack API
        RESPONSE=$(curl -X POST https://slack.com/api/chat.postMessage \
          -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
          -H "Content-Type: application/json" \
          -d @- <<EOF
        {
          "channel": "$SLACK_CHANNEL_ID",
          "text": "$REVIEW_REQUEST\n\nPR: ${{ steps.pr_info.outputs.pr_url }}\nTitle: ${{ steps.pr_info.outputs.pr_title }}",
          "blocks": [
            {
              "type": "section",
              "text": {
                "type": "mrkdwn",
                "text": "$(echo "$REVIEW_REQUEST" | jq -Rs . | jq -r .)"
              }
            },
            {
              "type": "section",
              "text": {
                "type": "mrkdwn",
                "text": "*PR #${{ steps.pr_info.outputs.pr_number }}: $(echo "${{ steps.pr_info.outputs.pr_title }}" | jq -Rs . | jq -r .)*\nAuthor: ${{ steps.pr_info.outputs.pr_author }}\nURL: ${{ steps.pr_info.outputs.pr_url }}$([ -n "$REQUESTER_INFO" ] && echo "\n$REQUESTER_INFO" || echo "")"
              }
            },
            {
              "type": "section",
              "text": {
                "type": "mrkdwn",
                "text": "*Changed Files:*\n\`\`\`$(echo $CHANGED_FILES_TEXT | jq -r .)\`\`\`"
              }
            },
            {
              "type": "divider"
            },
            {
              "type": "section",
              "text": {
                "type": "mrkdwn",
                "text": "Please check the PR for full diff."
              }
            },
            {
              "type": "actions",
              "elements": [
                {
                  "type": "button",
                  "text": {
                    "type": "plain_text",
                    "text": "View PR on GitHub"
                  },
                  "url": "${{ steps.pr_info.outputs.pr_url }}"
                }
              ]
            }
          ]
        }
        EOF
        )

        # Check if message was sent successfully
        SUCCESS=$(echo $RESPONSE | jq -r .ok)
        if [ "$SUCCESS" != "true" ]; then
          echo "Failed to send Slack message"
          echo "Response: $RESPONSE"
          exit 1
        fi

        echo "Slack message sent successfully!"
        TIMESTAMP=$(echo $RESPONSE | jq -r .ts)
        echo "timestamp=$TIMESTAMP" >> $GITHUB_OUTPUT

    - name: Comment on PR
      if: success()
      run: |
        if [ -n "${{ inputs.custom_prompt }}" ]; then
          COMMENT_BODY="🤖 Devin has been notified about this PR via Slack.

        Custom request: _${{ inputs.custom_prompt }}_
        Requested by: @${{ inputs.comment_author }}

        Slack message sent at: $(date -u +"%Y-%m-%d %H:%M:%S UTC")"
        else
          COMMENT_BODY="🤖 Devin has been notified about this PR via Slack.

        Slack message sent at: $(date -u +"%Y-%m-%d %H:%M:%S UTC")

        Please check your Slack channel for Devin's response."
        fi

        gh pr comment ${{ inputs.pr_number }} --repo ${{ inputs.repository }} --body "$COMMENT_BODY"
      env:
        GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

処理の流れ

それぞれのパターンの全体の処理の流れについて解説します。

パターン1: PR作成時の自動レビュー

トリガーイベント

開発者がGitHubでPRを作成すると、pull_request.opened イベントがGitHub Actionsをトリガーします。

PR情報の取得

GitHub Actionsが起動し、GitHub CLIを使用してPRの詳細情報を取得します:

# PR情報の詳細取得
gh pr view $PR_NUMBER --json title,author,url

# 変更ファイル一覧の取得(最大20ファイル)
gh pr diff $PR_NUMBER --name-only | head -20
デフォルトプロンプトの設定

デフォルトプロンプト /devin !prr を使用し、レビュー用Playbook(カスタムコマンド)を呼び出します。

Slackへのメッセージ投稿

取得した情報を使って、Slack APIでDevinにメンションを送ります。

Devinによるレビュー実行

Slackでメンションを受けたDevinが以下を実行します:

  1. PRのURLからGitHubにアクセス
  2. 変更内容を取得・分析
  3. Playbookに従ってコードレビューする
  4. レビュー結果をSlackスレッドに投稿
PRへのコメント追加

GitHub Actionsが自動的にPRにコメントを追加します:

🤖 Devin has been notified about this PR via Slack.

Slack message sent at: 2025-10-08 07:48:08 UTC

Please check your Slack channel for Devin's response.

PRにDevinのレビュー指摘をそのまま書き戻してしまうと、PRのコメントが長くなり、見通しが悪くなると感じたため、DevinとのやりとりはSlack上でやらせるようにしています。

パターン2: PRコメントによるカスタムタスク

トリガーイベント

開発者がPRコメントで /devin [カスタムプロンプト] と入力すると、issue_comment.created イベントがGitHub Actionsをトリガーします。

ワークフロー起動と情報抽出

GitHub Actionsが起動し、以下の情報を抽出します:

  • PR番号
  • コメント本文からカスタムプロンプトを抽出(/devin の後ろの文字列)
  • コメント作成者情報
Slackへのメッセージ投稿

カスタムプロンプトを含めてDevinにメンションを送ります:

Devinによるタスク実行

Slackでメンションを受けたDevinが以下を実行します:

  1. カスタムプロンプトの内容を理解
  2. PRのコードを取得・分析
  3. 指示されたタスク(テストカバレッジ改善、セキュリティチェックなど)を実施
  4. 実行結果をSlackスレッドに投稿
PRへのコメント追加

GitHub Actionsが自動的にPRにコメントを追加します:

🤖 Devin has been notified about this PR via Slack.

Custom request: このPRのテストカバレッジを改善してください
Requested by: @yuto-ida

Slack message sent at: 2025-09-15 10:30:00 UTC

こちらも同様に、DevinとのやりとりはSlack上でやらせるようにしています。

Slack経由で呼び出すメリット

この仕組みでは、Devin APIを直接呼び出すのではなく、Slack経由でDevinにメンションを送る方式を採用しています。これには以下のようなメリットがあります。

どのプランでも利用できる

Devin APIを使用する場合、APIアクセスは特定のプラン(Teamプラン、Enterpriseプラン)に限定されます。 しかし、Slack経由でDevinを呼び出す方式では、どのDevinプランでも動作するため、プランに縛られず利用できます。

コミュニケーションの可視化

Slackのスレッド上でDevinとやり取りが行われるため、PRコメントのやりとりが長くなることなく、 チーム全体でDevinの作業状況やレビュー結果を確認しやすくなります。

Devinのレビュープロセス

Devinによるレビューの品質を保つため、専用のPlaybook(カスタムコマンド)を作成しています。

Devin PRレビュー用Playbook

# Devin PRレビュー用Playbook

## Procedure

1. step 1: コンテキスト確認
   - PRの概要・目的・関連するIssueやチケットを確認する
   - 変更範囲(diff)と影響範囲(サービス・モジュール・依存関係)を把握する

2. step 2: コード品質チェック
   - コーディング規約(命名・フォーマット・Lintルール)に沿っているか確認
   - 複雑すぎる処理やネストを避け、読みやすさを保っているか
   - 冗長なコードや重複がないか

3. step 3: 設計・アーキテクチャ確認
   - 責務の分離ができているか(関数・クラスの粒度)
   - 再利用性・拡張性を妨げる設計になっていないか
   - 既存の設計原則やプロジェクト方針に従っているか

4. step 4: 動作確認ポイントの提示
   - ユニットテストや統合テストが適切に書かれているか
   - 主要なエッジケース(例外処理・null/undefined・エラーリカバリ)がカバーされているか
   - 手動確認が必要な場合、その範囲を明記する

5. step 5: セキュリティ・パフォーマンス確認
   - セキュリティリスク(SQLインジェクション、XSS、認証認可の漏れ)がないか
   - 不要に重い処理やボトルネックになりそうな実装がないか

6. step 6: フィードバック作成
   - 指摘は「なぜ」改善が必要か背景を添える
   - 修正案や参考リンクを提示する
   - 承認・修正要望・ブロッカーを明確に区分する

## Advice & Pointers

- 「Good first」コメントも添える:良い実装や工夫された点は積極的に褒める
- プロジェクトのガイドラインやスタイルガイドを引用すると説得力が増す
- コメントは簡潔に、かつ具体的に(例:「変数名をuserListにすると用途が明確になります」)
- 優先度をつけて伝える(must / should / nice-to-have)

## Forbidden actions

- 人格批判や感情的なコメントをしない
- 抽象的すぎる指摘(例:「よくない」だけ)は禁止。必ず理由と代替案を添える
- 過剰な修正依頼(プロジェクト方針と無関係な個人的好み)はしない
- テストを無視してレビュー承認しない
- セキュリティリスクの見落としを放置しない

運用して分かったこと

実際に運用を始めてから、Devinのレビューには以下のような効果があると感じています。

レビューの質の向上

Devinの指摘で特に役立っているのは以下です:

見落としがちな基本的なミスの検出
  • 変数名のtypo
  • 未使用のimport文
  • エラーハンドリングの漏れ
  • テストケースの不足
セキュリティ関連の指摘
  • SQL injectionの可能性
  • 機密情報のログ出力
  • XSS脆弱性のリスク
パフォーマンスに関する提案
  • 不要なループ処理
  • メモリ効率の改善案
コーディング規約の統一

人間だとつい見逃してしまう細かい規約違反も、Devinは一貫して指摘してくれます。これにより、コードベース全体の品質が均一に保たれるようになりました。

レビュアーの負担軽減

細かいチェック項目をDevinが担当してくれるため、人間のレビュアーはより高度な判断が求められる部分に対して集中できるようになりました。

  • 設計思想の妥当性
  • ビジネスロジックの正確性
  • アーキテクチャとの整合性

限界と人間レビューの重要性

もちろん、Devinにも限界があります:

  • ビジネス要件との整合性:ドメイン知識が必要な判断は難しい
  • コンテキストの理解:PRの背景や意図を完全に理解するのは難しい

そのため、Devinのレビューはあくまでも一次チェックと位置づけ、最終的な承認は必ず人間のレビュアーが行っています。 Devinと人間の役割分担を明確にすることで、両者の強みを活かした効率的なレビューフローが実現できていると感じています。

まとめ

今回、PR作成と同時にDevinを起動する仕組みを導入したことで、レビュアーの負担が軽減され、開発者は本質的な部分に対して集中できるようになったと感じています。

重要なのは、Devinはあくまでもレビューをサポートするツールであり、人間のレビュアーを置き換えるものではないという点です。 AIと人間がそれぞれの強みを活かし、協力してレビューする体制を構築できたことが、この取り組みの成功要因だと考えています。

今後も運用を続けながら改善を重ね、より効率的で質の高い開発プロセスを目指していきたいです。

スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com