PR作成前にAIにレビュー依頼するmakeコマンド作ってみた

# はじめに

　こんにちは！ バックエンドエンジニアまもなく2年目の [@imkaoru](https://qiita.com/imkaoru) です。
今回は最近私生活で取り組んでいた「対話型AI活用」についてお話しできたらと思います。

### 結論

下の図のような、ローカルの修正内容をAIにレビュー依頼できる makeコマンド が欲しくて作ってみました。

![スクリーンショット 2024-03-23 20.52.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2612580/366192d7-1c28-3730-f231-7e99d938aafe.png)

1分ほどの動画です。(下の方にも動画置いてますが同じものです。)

<iframe width="560" height="315" src="https://www.youtube.com/embed/sTB4nTwIbFc?si=9rQSPymMLKVavK7Y" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen>
</iframe>

# 業務フローの課題

　入社から1年ほど経ち「ここもっとこうできたらいいかも」を考えるようになってきました。
 
その中で、リードタイムを向上させるために何ができるか考えた時に「レビュー」がボトルネックの一つなのではないかと思いました。
業務フローの中でも、他の開発者との兼ね合いで最も待ち時間が発生する部分だからです。

誤解のないよう補足すると、「レビュー」は品質向上だけでなく、そこから学びやコミュニケーションのきっかけになったりと、とても大切なプロセスだし、自分のような未熟な開発者にこそ恩恵の多いプロセスだと思ってます。感謝！

# 実現したいこと

私のチームでは基本的に、以下のようなレビューフローとなっています。

1. 実装
2. PR作成
3. 開発者1名以上のapprove
4. PO(プロダクトオーナー)のapprove

このフロー自体を削減するのではなく、**PR時点でAIによるレビューが担保されている状態**にしたいと考えました。

1. 実装
2. **AIレビュー**
3. PR作成
4. 開発者1名以上のapprove
5. PO(プロダクトオーナー)のapprove

こうすることで、実装者は自らコードの修正ミスや考慮不足に気づくことができ、PR作成時点である程度コード品質を高めておくことができます。

また、それによって以下のようなメリットがあります。

- PR作成後の指摘と修正にかける時間を削減できる
- または、よりよい指摘が先輩からいただける状態でPRを出せる

先輩からすると、指摘箇所が多すぎたとき「これはまぁ…いっか、うん」となっていることもありそうです。多分。

### 市場のAIレビューツールではダメなのか

レビューをAIに依頼できるツールは、CodeRabbit, PR-Agent, ChatGPT CodeReview などいろいろと存在します。
それぞれ導入方法を紹介くださっている記事もあり、OpenAIのキーとyamlファイルを用意するだけで導入できそうなツールが多そうでした。

しかし、どれも主要機能としては作成したPRに対する「要約」と「初回レビュー」だったため、今回私が目的とする「PR作成時点でAIによるレビューが担保されているようにしたい」とはちょっと違うかもと思い、自作を試みました。

PR作成後のAIレビューを修正してから開発者レビュー依頼する、だと PRコメント や 紐付けているSlack通知 が増えてしまうことがネックとなるためです。

ただ、以下の記事を読み **PR概要** と **「レビューのしやすさ」という項目の表示** をAIにお願いするのめっちゃよさそう！と思いました。

- [CodeRabbit を１ヵ月開発チームで実際に使ってみました！](https://zenn.dev/lovegraph/articles/48a3b7288299a2)
- [Pull RequestをAIにレビューさせるのはまだ早かった](https://zenn.dev/kanasugi/articles/a3c0b4b2857302)

AIレビューツール導入するなら [Copilot for Pull Requests](https://githubnext.com/projects/copilot-for-pull-requests/) 使ってみたいなぁ。

# どのように実現するか

では、具体的な部分について説明していきます。

### 前提

以下の業務フローがチームに根付いていることを想定しています。

- 作業ブランチ名が以下のような規則で運用していること
    
    例: `feature/#12_hogehoge`
    <br>

- GitHubにissueが作成されていて、タスクの内容がしっかり記載されていること
    - 起票後コメント等でやり取りした場合でも、最新の状態でissue概要欄を更新すること

    <details><summary>推奨項目</summary>
    
    - issue概要
    - 目的
    - 背景
    - 詳細
        - 何をどうしたいのか

    </details>
    
### 実際の流れ

先ほどの「AIレビュー」プロセスについてです。

<details><summary>1. 「make prereview」でプロンプトを作成</summary>

1. もし以前のプロンプトが存在する場合は削除する
2. 次に以下の情報を取得し、リクエストに使用するプロンプトを生成
    - プロンプトテンプレートファイル
    - issue内容
    - コード差分
3. ここで、必要に応じてファイルを手動編集するのもOK

</details>

<details><summary>2. 「make review」でレビューを行う</summary>

1. `make prereview`で作成したプロンプトをOpenAI APIにリクエスト
    - `make review`に続けてモデルを指定可能
        - 例えば「GPT-4 Turbo」を使いたい場合は`make review gpt-4-0125-preview`とする
        - 特に指定しなければ「GPT-3.5 Turbo」をデフォルトとしている
        - 他に選べるモデルについては[こちらの記事](https://www.goatman.co.jp/media/openai-api-pricing/)がわかりやすいです！
2. 結果がターミナルに出力される
3. ファイルを削除する

</details>

必要に応じて「実装内容の修正 + commit」を行い、再びAIにレビュー依頼する

ちなみに、`make review`コマンド1つで「プロンプト作成 → GPTによるレビュー → プロンプト削除」ができるようにしていますが、`make prereview`を事前に叩くことでプロンプトを手動編集することができます。
レビュー前にプロンプトの確認や追記をしたい場合の使用を想定しています。

# コード説明

### ざっくりのディレクトリ構成
    
```
root/
 ├ prepare/
 │　└ prepare.py
 ├ request_gpt4/
 │　└ request_gpt4.py
 ├ config.ini
 ├ main.py
 ├ Makefile
 └ prompt_template.md
```
<br>

以下に主要なスクリプトを載せていますので、もしよろしければご活用ください。
実務でPythonを使用していないので、ひどいコードかもしれない点ご了承ください！(言い訳)

### prompt_template.md
    
```markdown
<!-- 必要に応じて内容修正よろしくっ！ -->

あなたは、[Python]に精通したプロのバックエンドエンジニアです。
以下の「issue内容」「修正差分」「レビュー観点」をもとに、コードレビューを行ってください。

# issue内容

# 修正差分

# レビュー観点

- 修正後のコードについて、issue内容に沿った実装になっているか
- 修正後のコードについて、修正に懸念点がないか
- 修正後のコードについて、さらに改善できる部分がないか
- 修正後のコードについて、コード品質に問題がないか
- 修正後のコードについて、適切なコメントがされているか
```

### Makefile

:::note warn
今回は、仮想環境に **requests**, **openai** ライブラリをインストールしています。
そのためコマンド実行の度にアクティベートしていますが、本来は不要です。

また `source` ~ `deactivate` までが1つのshコマンドのため、前後を`"`で囲まないとうまく動きません。
記事にしたときに配色が崩れていたため、ここでは可読性を取って削除しています。
:::
    
```makefile
# Python設定
VENV := ./venv/bin/activate
PYTHON := python3

# プロンプトの準備(レビュー前にプロンプトの確認や追記をしたい場合)
prereview:
    @zsh -c source $(VENV) && \
    $(PYTHON) -c 'from prepare import prepare_review; prepare_review()' && \
    deactivate

# 指定したモデルでレビューを実行する内部コマンド（直接使用しない）
review-execute:
ifeq ($(MODEL),)
    @zsh -c source $(VENV) && \
    $(PYTHON) -c 'from request_gpt4 import request_review; request_review()' && \
    deactivate
else
    @zsh -c source $(VENV) && \
$(PYTHON) -c 'from request_gpt4 import request_review; request_review(\"$(MODEL)\")' && \
    deactivate
endif

# プロンプトの準備 + GPTによるレビュー
# 使用方法: make review [MODEL=モデル名] （MODELは省略可能）
review:
    @$(MAKE) review-execute MODEL="$(filter-out $@,$(MAKECMDGOALS))"
# 引数を受け取るためのダミーターゲット
%:
    @:

# プロンプトファイルを削除
remove-prompt:
    rm -f ./prompt.md
```

### prepare.py
    
```python
import os
import sys
import configparser
import requests
import subprocess

config = configparser.ConfigParser()
config.read('./config.ini')
# GitHub設定
GITHUB_TOKEN = config['github']['token']
GITHUB_USER = '[ユーザー名]'
GITHUB_REPO = '[リポジトリ名]'
# プロンプトファイル生成場所
PROMPT_PATH = config['prompt']['path']

def get_issue_number():
    """現在のブランチ名からIssue番号を抽出する"""
    try:
        branch_name = subprocess.check_output(['git', 'branch', '--show-current']).decode('utf-8').strip()
        if '#' not in branch_name:
            print('作業ブランチに移動してください。')
            sys.exit(1)  # エラーとしてプログラムを終了
        issue_number = branch_name.split('/')[1].split('_')[0].replace('#', '')
        return issue_number
    except Exception as e:
        print(f'ブランチ名の取得に失敗しました: {e}')
        sys.exit(1)  # その他のエラーが発生した場合、プログラムを終了

def get_issue_content(issue_number):
    """GitHub APIを使用してIssueの内容を取得する"""
    url = f'https://api.github.com/repos/{GITHUB_USER}/{GITHUB_REPO}/issues/{issue_number}'
    headers = {'Authorization': f'token {GITHUB_TOKEN}'}
    response = requests.get(url, headers=headers)
    if response.status_code == 200:
        return response.json()['body']
    else:
        print('Issueの内容を取得できませんでした')
        sys.exit(1)  # エラーが発生した場合にプログラムを終了

def get_git_diff():
    """ローカルのGitリポジトリでの変更点（現在のブランチとmainブランチの差分）を取得する"""
    try:
        diff_output = subprocess.check_output(['git', 'diff', 'main...'], universal_newlines=True)
        return diff_output
    except subprocess.CalledProcessError as e:
        print(f'git diffを取得中にエラーが発生しました: {e}')
        sys.exit(1)  # エラーが発生した場合にプログラムを終了

def create_prompt(issue_content, diff_content):
    """prompt_template.mdの内容をコピーし、Issue内容と差分を追記して新しいファイルを作成する"""
    try:
        with open('prompt_template.md', 'r') as template_file:
            template_content = template_file.read()
        prompt_content = template_content.replace('# issue内容', f'# issue内容\n\n{issue_content}').replace('# 修正差分', f'# 修正差分\n\n{diff_content}')
        with open(PROMPT_PATH, 'w') as prompt_file:
            prompt_file.write(prompt_content)
    except Exception as e:
        print(f"ファイル生成に失敗しました: {e}")
        if os.path.exists(PROMPT_PATH):  # エラーが発生した場合はprompt.mdを削除
            os.remove(PROMPT_PATH)
            print("prompt.mdファイルを削除しました")
        sys.exit(1)  # プログラムを終了

def prepare_review():
    if os.path.exists(PROMPT_PATH):
        os.remove(PROMPT_PATH)
        print("prompt.mdファイルを削除しました")
    issue_number = get_issue_number()
    issue_content = get_issue_content(issue_number)
    diff_content = get_git_diff()
    create_prompt(issue_content, diff_content)
```

### request_gpt4.py
    
```python
import os
import sys
import configparser
from openai import OpenAI
from prepare import prepare_review

config = configparser.ConfigParser()
config.read('./config.ini')
# プロンプトファイル生成場所
PROMPT_PATH = config['prompt']['path']
# OpenAIモデルのデフォルト値
MODEL_DEFAULT = config['openai']['model']

def read_prompt_file():
    """生成されたprompt.mdファイルの内容を読み込む"""
    try:
        with open('prompt.md', 'r') as file:
            return file.read()
    except Exception as e:
        print(f"ファイルの読み込みに失敗しました: {e}")
        sys.exit(1)  # プログラムを終了

def request_review(model=MODEL_DEFAULT):
    """OpenAI APIを使用してコードレビューをリクエストする"""
    # ファイルがなければprepare_reviewを実行して生成
    if not os.path.exists(PROMPT_PATH):
        print("プロンプトファイルが存在しないため、生成します")
        prepare_review()
    try:
        print(f"使用model: {model}")
        prompt = read_prompt_file()
        client = OpenAI()
        response = client.chat.completions.create(
            model=model,  # 引数から受け取ったモデル名を使用
            temperature=0.5,  # 多様性を少し持たせたい場合は、0より大きい値に設定
            messages=[
                {"role": "system", "content": "You are a helpful assistant."},
                {"role": "user", "content": prompt}
            ],
        )
        result = response.choices[0].message.content
        print("レビュー結果:\n", result)
    except Exception as e:  # OpenAI特有の例外も含めたエラーハンドリング
        print(f"リクエストに失敗しました: {e}")
    finally:
        if os.path.exists(PROMPT_PATH):
            os.remove(PROMPT_PATH)
            print("prompt.mdファイルを削除しました")
```

ちなみに、`GITHUB_TOKEN`は **config.ini** に、`OPENAI_API_KEY`は **shファイル** で管理することにしました。

# デモ

下記のissueをもとに、実際にレビュー依頼を投げてみました。

![スクリーンショット 2024-03-23 20.53.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2612580/80062c63-1f81-d9d8-9be8-f0e7b6352d50.png)

<iframe width="560" height="315" src="https://www.youtube.com/embed/sTB4nTwIbFc?si=9rQSPymMLKVavK7Y" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen>
</iframe>

うまくいきました！
レスポンスまで10~15秒ほどかかかります。(もっと早くできるのかな？)

モデルによって精度に違いがあることがわかりますね。

# 振り返り

### 実現できたこと

- 毎回適切なプロンプトを用意する必要なく、コマンド1つでレビュー依頼できる
- ローカルのmainブランチと現在のブランチの差分をプロンプトに反映
- `prompt_template.md`のレビュー観点を変えることで、柔軟な依頼が可能
- プロンプト作成 → 手動で追記修正 → リクエストもOK
- `make review`時にモデルを指定できる

### 業務を想定した課題

- API化し、汎用的に使用できるようにしたい
- 業務では一般的なissueの位置にnotionを使っているため、issue内容の取得にnotionAPIを使うように改修しないといけなさそう
- OpenAI APIを使用することについて、エンジニア組織で導入OKか確認する必要がある
    - 金銭的なコストが発生する
    - セキュリティ的なリスクをどうするか
    - どのようにアカウント管理するか

# まとめ

　以下の項目についてチームで共通認識を作っておくことで、レビュー精度をより向上させることができると思います。

- issue内容が充実していること
- PR単位が小さいこと

私のチームでは、スクラム開発をしているため「PR単位が小さいこと」は意識できていますが、
「issue内容が充実していること」についてはissueテンプレート等を作っておき活用するのもありかなぁと思ったりしました。

他にも、業務で使うにはいろいろと課題があるので、引き続き改善してみたいと思います。
読んでいただきありがとうございました！

## 参考記事

- [OpenAI APIの使い方講座【公式Quickstart】](https://qiita.com/Isaka-code/items/b50983796636503b44c5)
- [Go言語開発を便利にするMakefileの書き方](https://qiita.com/yoskeoka/items/317a3afab370155b3ae8)

# 追記

　ここまでは「業務でも使えたらいいなぁ」とワクワクしながらやってみたことのまとめでした。

その後チームの先輩に、業務を想定した課題について相談してみたところさまざまなFBをいただけました。例えば、

- どのように「品質向上」できたかを測るか、という観点でレビュー時のコメントにラベルをつけてみるのはどうか
- しばらく運用してからラベルを集計することで、PRに対するコメントの性質が変わったかどうかを可視化できるのではないか

「レビューコメントに使用するラベル」については、「[コードレビューにラベルを付けるだけでチームの心理的安全性を高めた話](https://zenn.dev/hacobell_dev/articles/code-review-comment-prefix)」という記事が参考になるかも、と共有いただきやりたいことが広がってきました。

先輩やっぱすごい！！！