👾

ChatGPT APIとGitHub ActionsでPull RequestのAI Code Reviewをつくってみた

2023/04/05に公開

モチベーション

巷で何かと話題な GhatGPT API で自分もなにか作ってみたいと思いました。
anc95/ChatGPT-CodeReviewなどもありますが、自分でプロンプトや仕組みを考えてみたかったので。
実用性はあまり考慮していません。

最初に結果

Pull Request を Open すると、コードレビューの結果をコメントに書いてくれます。
なお、今回は私が結果を確認しやすいように、レビュー対象を Ruby のコードに絞っています。

結果例 1

ARGV[0]の入力チェックや、review_code実行時のエラーハンドリングをするといいかもという旨のコメントをしてくれました。

対象ファイルと変更内容

cli/chatgpt_code_reviewer_cli.rb
+ # frozen_string_literal: true
+
+ require_relative '../lib/chatgpt_code_reviewer'
+
+ # Usage: git diff file_path | ruby cli/chatgpt_code_reviewer_cli.rb file_path
+ file_path = ARGV[0]
+ diff = $stdin.read
+
+ puts ChatGPTCodeReviewer.new.review_code(file_path, diff)

レビュー内容

review result 1

github-actions
Overall, the code changes look good. The new file `cli/chatgpt_code_reviewer_cli.rb` was created and contains the same code as the previous file, but with additional comments and formatting.

There are no major issues with the changes made in the diff. However, it would be better to add more error handling to the code. For example, if the user does not input a file path as an argument, the program should not crash and rather provide an error message to the user. This can be implemented by adding an `if` statement to check if the `ARGV[0]` is `nil` or not.

Additionally, it would be a good idea to handle any exceptions that may occur during the code review process. This can be done by wrapping the code in a `begin` and `rescue` block and printing the exception message if an exception occurs.

Overall, the code changes are fine and do not introduce any major issues, but it could be improved by adding more error handling.

結果例 2

N+1が発生するようなコードを書いてみました。ちゃんと指摘されていますね。
しかし、変更していない箇所についてもレビューされています。

app/controllers/posts_controller.rb
  class PostsController < ApplicationController
    def index
      @posts = Post.all.order(id: :desc)
      render json: @posts
    end

+   def show
+     @post = Post.find(params[:id])
+     @post.comments.each do |comment|
+       Rails.logger.info "comment likes: #{comment.likes.count}"
+     end
+     render json: @post
+   end
  end

レビュー内容
review result 2

github-actions
Overall, the code looks good with no syntax errors or obvious logic issues. However, after reviewing the git diff, there are a couple of things to note:

1. app/controllers/posts_controller.rb:6
   - The `index` method is missing a newline after the first line. It's always a good practice to have a newline after the class definition and after each method definition to improve code readability.

2. app/controllers/posts_controller.rb:8-16
   - The `show` method is added with code to retrieve comments related to the post and print the number of likes for each comment using `Rails.logger`. This seems like a useful piece of information, but it would be better to move this logic into a separate method in the `Post` model, instead of clumping it into the controller. Additionally, to improve performance, we can preload the `likes` association to avoid hitting the database multiple times for each comment.

処理の流れ

  1. 開発者が Pull Request を Open する
  2. GitHub Actions の Workflow で Pull Request の変更ファイルのパスと git diff の結果を取得する
  3. Ruby のコードを実行する。このとき 2.で取得した結果を入力する
  4. Ruby のコード内で ChatGPT API にコードレビュー依頼のリクエストをして結果を受け取る
  5. レビュー内容が Pull Request のコメントに投稿される

手順

OpenAPI の API キーを作成する

以下から発行できます。
https://platform.openai.com/account/api-keys

ちなみに私は無料枠の期限が切れていたため、API の利用にはクレジットカードの登録が必要でした。
本記事を執筆している 2023 年 4 月時点の料金はgpt-3.5-turboモデルが$0.002 / 1K tokensです。

詳しくは以下を参照
https://openai.com/pricing

ChatGPT API を呼び出す Ruby コードの作成

ちなみに Ruby で書いている理由は私が普段仕事で書くことが多いからです。

ChatGPT API を使うために Ruby OpenAI をインストールします。

gemfile
gem "ruby-openai"
bundle install

次に ChatGPT API にコードレビュー依頼のリクエストを送り、結果を受け取るクラスを作成します。

lib/chatgpt_code_reviewer.rb
# frozen_string_literal: true
require 'openai'

class ChatGPTCodeReviewer
  def initialize
    # NOTE: 作成したOPENAIのAPIキーを環境変数から取得
    access_token = ENV.fetch('OPENAI_API_KEY')
    @client = OpenAI::Client.new(access_token:)
  end

  # NOTE: レビューするファイルのパスと変更差分(git diffの結果)を入力
  def review_code(file_path, diff)
    file_content = File.read(file_path)
    prompt = make_prompt(file_content, diff)
    request_to_openai(prompt)
  end

  private

  def request_to_openai(prompt)
    response = @client.chat(
      # TODO: 必要最低限のパラメータを指定している。見直しの余地がありそう
      # https://platform.openai.com/docs/api-reference/chat/create
      parameters: {
        model: 'gpt-3.5-turbo',
        messages: [{ role: 'user', content: prompt }]
      }
    )
    response.dig('choices', 0, 'message', 'content')
  end

  # TODO: プロンプトも見直しの余地がありそう
  def make_prompt(file_content, diff)
    <<~PROMPT
      I want you to act as an Ruby on Ruby, Ruby on Rails expert code reviewer.
      I enter all the content for one rb file.
      In addition, enter the git diff results of the file changes.
      All contents are entered following "All contents:".
      The git diff results are entered following "Diff:".
      The format of the git diff is "+" at the beginning of added lines and "-" at the end of deleted lines.
      After considering The "All Contents:", return the code review against the "Diff:"
      Reviews for specific lines should begin with the format "directories/filename:row number" to indicate the line number of the file.
      An example of the format is "app/models/hello.rb:2".
      All contents:
      ```
      #{file_content}
      ```
      Diff:
      ```
      #{diff}
      ```
    PROMPT
  end
end

GitHub Actions から実行するため、CLI 経由で実行できるようにします。
ここで git diff の結果はパイプラインで受け取るようにしています。

cli/chatgpt_code_reviewer_cli.rb
# frozen_string_literal: true

require_relative '../lib/chatgpt_code_reviewer'

# Usage: git diff file_path | ruby cli/chatgpt_code_reviewer_cli.rb file_path
file_path = ARGV[0]
diff = $stdin.read

puts ChatGPTCodeReviewer.new.review_code(file_path, diff)

プロンプトの説明

All contents:以下にはレビュー対象のファイル全体の内容が入ります。
Diff:以下にはそのファイルの変更差分として git diff の結果がはいります。

実際にリクエストされるプロンプトの例
I want you to act as an Ruby on Ruby, Ruby on Rails expert code reviewer.
I enter all the content for one rb file.
In addition, enter the git diff results of the file changes.
All contents are entered following "All contents:".
The git diff results are entered following "Diff:".
The format of the git diff is "+" at the beginning of added lines and "-" at the end of deleted lines.
After considering The "All Contents:", return the code review against the "Diff:"
Reviews for specific lines should begin with the format "directories/filename:row number" to indicate the line number of the file.
An example of the format is "app/models/hello.rb:2".
All contents:
```
# frozen_string_literal: true

class PostsController < ApplicationController
  def index
    @posts = Post.all.order(id: :desc)
    pp 'hoge'
    render json: @posts
  end

  def show
    @post = Post.find(params[:id])
    render json: @post
  end
end
```
Diff:
```
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index cae4a20..b9e4be5 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -2,7 +2,13 @@

 class PostsController < ApplicationController
   def index
-    @posts = Post.all.order(id: :desc)
+    @posts = Post.all
+    pp hoge
     render json: @posts
   end
+
+  def show
+    @post = Post.find(params[:id])
+    render json: @post
+  end
 end
```

以下のように考えてプロンプトを組み立てています。

  • Ruby, Rails のエキスパートとして振る舞うに指定
    • 具体的な役割を与えると回答の精度が上がるという記事をみかけたので取り入れました
  • 英語で依頼。日本語よりもレビュー結果の精度が高そう、かつ Token の消費量を抑えるため
    • 英語で「最終的な結果は日本で返してください」と依頼するのも試したが、精度が落ちた印象を受けました
  • ファイル全体と変更差分を入力
    • 差分だけだと変更内容を正しく把握できず、レビューの精度が低いのではと考えました
  • git diff の読み方を説明
    • もしかしたらなくても問題なく解釈してくれるかもしれません
  • どの行に対してのレビューかわかるように依頼
    • ファイルパス:行番号のフォーマットでレビューを返すように依頼し、例示しています

GitHub Actions の Workflow 作成

事前準備としてOPENAI_API_KEYという名前で、発行した OpenAI API のキーを Actions secrets を追加します。
以下の URL から追加できます。
https://github.com/[owner]/[repositoy]/settings/secrets/actions

.github/workflows/chagtgptRubyCodeReview.yml
name: ChatGPT Ruby Code Review

on:
  pull_request:
    # 修正をpushしたときにも実行するなら synchronize を追加する
    types: [opened, ready_for_review]
    paths:
      - '**.rb'

jobs:
  code-review:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0
          # HEADがマージ元ブランチの最新コミットになるようにしている
          ref: ${{ github.event.pull_request.head.sha }}

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.2.1
          bundler-cache: true
      # github.base_ref を利用して、マージ先のブランチとの差分を取得している
      - name: review code
        env:
          OPENAI_API_KEY: ${{secrets.OPENAI_API_KEY}}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          git diff --name-only origin/${{ github.base_ref }}..HEAD | grep '\.rb$' | while read file; do
            git diff origin/${{ github.base_ref }}..HEAD "${file}"
            review_result=$(git diff origin/${{ github.base_ref }}..HEAD "${file}" | bundle exec ruby cli/chatgpt_code_reviewer_cli.rb "${file}")
            gh pr comment ${{ github.event.pull_request.number }} --body "${review_result}"
          done

注意事項

  • Pull Request の変更差分が多いと Token の消費が多くなり、Workflow の実行時間も長くなるので注意が必要です。
    • API で送れる token にも上限がありpt-3.5-turboは上限4096 tokensです。
  • 今回作成したコードは全般的に想定外の入力を考慮していません。
    • プロンプトもインジェクションできるかもしれません

つくってみた所感

  • プロンプトは改善の余地が多くありそう

    • 前提条件を追加するとか
      • 言語やライブラリのバージョンを教えてあげる
    • レビュー観点を具体的にするとか
      • セキュリティ、パフォーマンス、テストの網羅性、可読性など
    • 複数ファイルの変更全てをまとめてレビュー依頼するとか
      • 今回は実装を簡単にするため 1 ファイル単位で依頼している
  • レビュー結果にはブレがある

    • 全く同じ入力でも。結果にばらつきがある。
    • temperatureなどの API パラメータで調整できそう
  • レビュワーがいない状況で利用できそう

    • 周りに詳しい人がいなかったり、個人開発などのシーン。
    • 結局、レビュー内容が適切か判断するのに知識が必要なので、気づきのきっかけくらいに捉えるのが良さそう
    • AI なので良くも悪くもレビュー内容を気軽に採用・無視できる
  • Pull Request のファイル差分のインラインコメントとして投稿したらもっと良さそう

    • 今回はやらなかった
    • プロンプトを工夫して、ファイルパス:行番号のフォーマットを更にいい感じに強制すればできそう

参考

以上

Discussion