GitHub Actions + GPT-4 TurboでPull Requestのコードレビューを自動化する

## はじめに

皆様ChatGPTに実装したコードを渡してレビューさせたことはありますでしょうか。
意外と実装の間違いや改善点を提案してくれて重宝しますよね。

今回はPull Request（以降PR）を作成した際に、ChatGPTがレビューした結果がコメントされるようになる手順を紹介します。
またせっかくなのでChatGPTは最新の`GPT-4 Turbo`を使います。

## 結果

対応手順の前にまず結果になります。
わかりやすい間違いを含んだSwiftクラスを用意しPRを作成しました。

```swift:sample.swift
class SampleSwiftClass {
    
    let baseNumber = 5
    /// baseNumberとnumberを加算して返却する
    /// - parameter number: 加算する数値
    /// - Returns: 加算後の数値
    func plus(number: Int) -> Int {
        return self.baseNumber + number
    }
    
    func minus(number: Int) -> Int {
        return self.baseNumber + number
    }
}
```

PR作成をトリガーにしてレビューが実施され、このように結果がコメントされます。

![スクリーンショット 2023-12-13 001154.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618958/dd25ad00-05ea-4044-efc3-0f2a15a75ef4.png)

![スクリーンショット 2023-12-13 001212.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618958/d8ea34db-49e1-dd3d-fc89-1244efda3af1.png)

## 手順

こちらを実現するための手順を説明します。

#### 1. OpenAI の API Keyを発行する

以下で`Create new secret key`を選択してAPI Keyを発行します。
https://platform.openai.com/api-keys

※発行後はkeyを表示できないので注意

#### 2. OpenAI APIの支払い設定

以下で支払い設定します。初回最低5ドルの支払いが必要です。
https://platform.openai.com/account/billing/overview

また以下で使いすぎや不正利用防止で上限を決めましょう。
https://platform.openai.com/account/limits

#### 3. GitHubリポジトリにOpenAI API keyを設定する

リポジトリのSettings > secrets and variables > Actionsで`New repository secret`を押下して1のAPI keyを`OPENAI_API_KEY`という名称で設定します。

![スクリーンショット 2023-12-13 004800.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618958/e4f0dbd4-8a4a-b394-34db-e35b4b8d762b.png)

#### 4. GitHub Actionsの設定ファイルを作成し配置する

以下を作成しリポジトリの`.github/workflows/`に配置します。

```yml:pr_review.yml
name: PR Review Workflow
on:
  # PRに対しActionsを動作させる
  pull_request:
    types: [opened, synchronize, reopened]

jobs:
  review:
    runs-on: ubuntu-latest
    # ActionsからPRに対しコメントするために必要
    permissions:
      pull-requests: write

    steps:
    # レビュースクリプト実行のためコードをチェックアウトする
    - name: Checkout Code
      uses: actions/checkout@v2
    - name: Set up Python
      uses: actions/setup-python@v2
      with:
        python-version: '3.8'
    - name: Install dependencies
      run: |
        pip install openai
        pip install PyGithub
    - name: Run script
      run: python scripts/code_review_script.py
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        REPOSITORY: "your-username/your-repository-name"
        PR_NUMBER: ${{ github.event.number }}
```
※`REPOSITORY: "your-username/your-repository-name"`はリポジトリに合わせて修正してください。

#### 5. コードレビュー依頼スクリプトを作成する

Pythonで作成してます。やっていることは以下になります。詳細はコードを見てください。
* GitHub APIでPRのdiffを取得
* コードレビューを依頼するプロンプトを作成
    * diffに改善点があればコメントする
    * 重要度に応じて"MUST:","IMO:","NITS:"のラベルを付ける
    * 結果はjson形式でフォーマットを指定
    * PRにすでにコメントされている内容と重複するコメントはしない、など
        ※GitHub APIでPRのコメントを取得して指定
* OpenAI APIを利用、modelは`gpt-4-1106-preview`(GPT-4 Turbo)を指定
* レビュー結果jsonを元にGitHub APIを使ってPRにレビューコメントを投稿


```python:code_review_script.py
import requests
import os
import json
from openai import OpenAI

# 各環境変数を定数化
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
GITHUB_TOKEN = os.getenv('GITHUB_TOKEN')
REPOSITORY = os.getenv("REPOSITORY")
PR_NUMBER = int(os.getenv("PR_NUMBER"))
# GitHubのPull Request API URL
PR_API_URL = f'https://api.github.com/repos/{REPOSITORY}/pulls/{PR_NUMBER}'

# PRのdiffを取得する
def get_pr_diff():
    headers = {
        'Authorization': f'token {GITHUB_TOKEN}',
        'Accept': 'application/vnd.github.v3.diff'
    }
    diff_response = requests.get(PR_API_URL, headers=headers)
    return diff_response.text

# Open AI APIでコードレビューを行い結果をjsonで返却する
def get_openai_review(prompt):
    client = OpenAI(api_key=OPENAI_API_KEY)
    # レスポンスをjson、modelにGPT-4 Turboを指定
    chat_completion = client.chat.completions.create(
        messages=[
            {
                "role": "user",
                "content": prompt,
            }
        ],
        response_format={"type":"json_object"}, 
        model="gpt-4-1106-preview",
    )
    review_result = chat_completion.choices[0].message.content
    return review_result

# コードレビューを依頼するプロンプトを作成
def create_prompt(code_diff):
    prompt = (f'Review the following code:\n\n{code_diff}\n\n'
              '- Be sure to comment on areas for improvement.\n'
              '- Please make review comments in Japanese.\n'
              '- Ignore the use of "self." when using variables and functions.\n'
              '- Please prefix your review comments with one of the following labels "MUST:","IMO:","NITS:".\n'
              '  - MUST: must be modified\n'
              '  - IMO: personal opinion or minor proposal\n'
              '  - NITS: Proposals that do not require modification\n'
              '- The following json format should be followed.\n'
              '{"files":[{"fileName":"<file_name>","reviews": [{"lineNumber":<line_number>,"reviewComment":"<review comment>"}]}]}\n'
              '- If there is no review comment, please answer {"files":[]}\n')
    prompt += create_ignore_pr_reviews_prompt()
    return prompt

# 既にコメントされている場合は同じコメントをしないように依頼するプロンプトを作成
def create_ignore_pr_reviews_prompt():
    url = f'{PR_API_URL}/comments'
    headers = {'Authorization': f'token {GITHUB_TOKEN}'}
    response = requests.get(url, headers=headers)
    comments = response.json()
    if len(comments) == 0:
        return ""
    ignore_prompt = '- However, please ensure the content does not duplicate the following existing comments:\n'
    for comment in comments:
        body = comment['body']
        path = comment.get('path')
        line = comment.get('line') or comment.get('original_line')
        ignore_prompt += f'  - file "{path}", line {line}: {body}\n'
    return ignore_prompt

# レビューコメントを投稿する
def post_review_comments(review_files):
    url = f'{PR_API_URL}/commits'
    headers = {
        'Authorization': f'token {GITHUB_TOKEN}',
        'Accept': 'application/vnd.github.v3+json'
    }
    pr_commits_response = requests.get(url, headers=headers)
    pr_commits = pr_commits_response.json()
    last_commit = pr_commits[-1]['sha']
    for file in review_files["files"]:
        for review in file["reviews"]:
            comment_url = f'{PR_API_URL}/comments'
            comment_data = {
                'body': review["reviewComment"],
                'commit_id': last_commit,
                'path': file["fileName"],
                'position': review["lineNumber"]
            }
            requests.post(comment_url, headers=headers, data=json.dumps(comment_data))

code_diff = get_pr_diff()
prompt = create_prompt(code_diff)
review_json = get_openai_review(prompt)
post_review_comments(json.loads(review_json))
```
※ymlに記載してますが配置箇所はリポジトリの`scripts/`です。

#### 6. PRを作成／PUSHする

これでPRを作成すれば自動でレビューが行われ、PRにコメントされます。
PRのブランチにPUSHした場合も実施されます。
※かかる時間は1~2分です。

## 課題

ある程度形になってはいますが以下の課題があります。

* PRのタイトルや詳細をレビュー内容に含めたい。
* レビューの参考情報としてPRにコメントされることがあるのでレビュー内容に含めたい。
    * 特定のプレフィックスをつけるようにすればできそう。
* エラーハンドリングをまったくしてないので対応したい。
* 指摘修正しても出来る限りの改善点を指摘してくれるので終わらない。
    * 上限を決める等、プロンプトの方で改善したい。
* PRにコメントが多いとプロンプトが肥大化するので料金がかかる。
* pythonではなくjsの方がメンテナンスできる人が多いかもしれない。
* PUSHする度にレビューしてほしいわけではないので、レビュー実施のON/OFFが出来るようにしたい。

## 最後に

AIの良い所の一つとして細かく実装しなくても判断してくれるので、このような自動化は楽に作成することができました。
`Copilot Workspace`のようなものが浸透していけば、これもあっという間に時代遅れになってしまうかもしれませんが、うまく使いこなし簡単なレビューはAIに任せて、効率良く開発を進めましょう。

ちなみに今回作成する過程で結果に記載した小さいSwiftクラスのPRを、計60回程度レビューしてもらいましたが、OpenAI APIにかかった料金は0.63ドル=約92円でした。


## 参考

* [GitHub API Docs Pulls](https://docs.github.com/ja/rest/pulls?apiVersion=2022-11-28)
* [API Reference - OpenAI API (python)](https://platform.openai.com/docs/api-reference/authentication?lang=python)