GitHub Actionsを使ってプルリクエストのサマリーをLLMに書かせる

2023/12/01に公開

こんにちは、Happy Elements 株式会社でエンジニアをしておりますryoooです。

はじめに

本記事ではプルリクエストの差分の内容を要約して、プルリクエストにサマリーを書く機能を開発しましたので簡単に紹介します。

プルリクエストを作成したタイミングでGitHub Actionsを叩いて、すべてのプルリクエストのコメント末尾に以下のようなコメントを出力する機能となっています。

※ 人間のコメントは残しつつ、その下にLLMのサマリーコメントを追記します。

これにより、非エンジニアのコミットにも一律でサマリーがつく状況にできたため、レビュー者の負担軽減やGitHub内の検索窓の活用が見込めます。

私のチームでは、エンジニア以外の職種の方もGitHubにコミットを行いながら作業を行うのですが、サマリーの書き方が人によってバラつきがちなので、なかなか便利に使えていると感じています。

つくり

1. プルリクエストが作成されたり、追加コミットがプッシュされたときにAPIを叩く

name: Gpt Reviewer

permissions:
  contents: read
  pull-requests: write

on:
  pull_request_target:
    types: [opened, synchronize, reopened]

concurrency:
  group:
    ${{ github.repository }}-${{ github.event.number || github.head_ref ||
    github.sha }}-${{ github.workflow }}-pr

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - name: Summary and review
        shell: bash
        env:
          PR_NO: ${{ github.event.number }}
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
          SHA: ${{ github.event.pull_request.head.sha }}
        run: |
          curl \
            -F "pr_no=$PR_NO" \
            -F "base_sha=$BASE_SHA" \
            -F "sha=$SHA" \
            https://xxx.xxx.xxx/github/update_summary_and_review

2. Railsサーバー側で差分を取得

差分ファイルとpatchは以下のように取得可能です。

github_client = Octokit::Client.new(access_token: ENV['GITHUB_ACCESS_TOKEN'])
pr = github_client.pull_request(params[:repo], params[:pr_no])
compare = github_client.client.compare(params[:repo], pr.base.sha, pr.head.sha)

3. システムプロンプト

要約のシステムプロンプトは以下を英訳したものを使っています。

あなたの仕事は、ユーザーから送られてくるgit hunkに対する変更の簡潔な概要を提供することです。
あなたは変更点のみを要約することに重点を置き、事実にこだわります。

次の内容でmarkdownフォーマットを使用して、変更の簡潔な概要を提供してください。

  - *ウォークスルー*: 特定のファイルではなく、全体の変更に関する高レベルの要約を80トークン以内でまとめること。
  - *変更点*: 変更の分類、ファイル名とその要約をテーブル形式でまとめること。スペースを節約するために、同様の変更を持つファイルを1行にまとめることができます。また、ファイル名はフルパスでなくて構いません。

※ 変更の分類は次の中から分類すること:
  "feat", "fix", "docs", "refactor", "style", "test", "chore", "revert"

出力の例は以下のとおりです。

  ### ウォークスルー
  新たなスキルシーケンスノードとしてAttackToTargetNodeとPositionToDirectionNodeを追加し、一部ソースコードをリファクタリングしました。

  ### 変更点
  | 分類 | ファイル | 概要 |
  |:---:|:---|:---|
  | feat | AttackToTargetNode.cs<br>PositionToDirectionNode.cs | 新たなスキルシーケンスノードを追加 |
  | refactor | AttackToTargetCommandData.cs | `Camelize`メソッドのリファクタリングを実施 |
  | chore | EffectOnCollision.cs | `TimeSpan.FromSeconds(0)`を`TimeSpan.Zero`に変更 |

この回答はそのままリリースノートに使用されるので、追加のコメントは避けてください。
英訳

Your job is to provide a succinct summary of changes to the git hunk sent by users.
You focus on summarizing only the changes and sticking to the facts.

Please provide a succinct summary of the changes using the following content in markdown format.

  • Walkthrough:
    Summarize a high-level summary of overall changes, not specific files, in 80 tokens or less.
  • Changes:
    Summarize the classification of changes, file names and their summaries in table format. To save space, you can summarize files with similar changes in one line. Also, the file name does not have to be a full path.

※ Classify the changes from the following:
"feat", "fix", "docs", "refactor", "style", "test", "chore", "revert"

Here is an example of the output.

ウォークスルー

新たなスキルシーケンスノードとしてAttackToTargetNodeとPositionToDirectionNodeを追加し、一部ソースコードをリファクタリングしました。

変更点

分類 ファイル 概要
feat AttackToTargetNode.cs
PositionToDirectionNode.cs
新たなスキルシーケンスノードを追加
refactor AttackToTargetCommandData.cs Camelizeメソッドのリファクタリングを実施
chore EffectOnCollision.cs TimeSpan.FromSeconds(0)TimeSpan.Zeroに変更

Since this answer will be used directly in the release notes, please avoid additional comments.

4. 人間が入力した文字列の後ろにLLMが生成したサマリーを追記

プルリクエストの先頭のコメントの一番下の行に追記するようにしています。
※ 追加でコミットが入った場合は置換して、常に最新のコミットに対するサマリーだけが残るようにしています。

def replace_summary_content(original_comment, ai_generated_summary, head_sha)
  start_comment = '<!-- This is an auto-generated comment by gpt start -->'
  end_comment = '<!-- This is an auto-generated comment by gpt end -->'

  pr_lines = StringIO.new
  is_user_comment_line = true
  summary_added = false
  original_comment.to_s.split("\n").each do |line|
    if line.include?(start_comment)
      # LLMが入力したサマリーが見つかった場合は、新しいサマリーで置換する
      is_user_comment_line = false
      pr_lines.write("#{start_comment}\n")
      pr_lines.write("# gpt summary (#{head_sha[0..6]})\n")
      pr_lines.write("#{ai_generated_summary}\n")
      pr_lines.write("#{end_comment}\n")
      summary_added = true
    elsif line.include?(end_comment)
      is_user_comment_line = true
    else
      pr_lines.write("#{line}\n") if is_user_comment_line
    end
  end

    # original_comment(プルリクエストの先頭のコメント)の中にLLMが入力したサマリーが見つからなかった場合
  unless summary_added
    pr_lines.write("\n") # 空行がないとマークダウン```のフォーマット記号が最下行にある場合にフォーマットが崩れる
    pr_lines.write("#{start_comment}\n")
    pr_lines.write("# gpt summary\n")
    pr_lines.write("#{ai_generated_summary}\n")
    pr_lines.write("#{end_comment}\n")
  end
  pr_lines.write("\n")
  pr_lines.rewind
  pr_lines.read
end

5. プルリクエストのコメントを更新

  new_comment = replace_summary_content(pr.body, ai_generated_summary, pr.head.sha)
  github_client.update_issue(params[:repo], params[:pr_no], body: new_comment)

今後やりたいこと

  • レビュー機能
    • コードレビューも行って範囲コメントもつけるような対応したのですが、LLMにレビューさせるシステムプロンプトがいまいちうまく仕上がっておらず、レビュー機能は現在止めています。
      • うまくいっていない点
        • レビュー時の指摘のレベルを人間に合わせるのが難しい
          • LLMの指摘が細かすぎて、不要なコメントが増えてしまって困っています。
        • 正確な行番号を出力させるのが難しい
          • 範囲コメント用の行番号について、間違えることがちょいちょいあります。
      • ↑このあたりについてAIエディタのCursorはとてもうまくできているので、工夫すればなんとかなるんだろうなという気はしています。

おわりに

最後まで読んでくださりありがとうございました!
LLM周りをRubyでやってるのは珍しいかもしれませんが、既存の社内システムと結合しやすくすることでSlack連携やナレッジDBとのつなぎ込みがスムーズになってますし快適(※)に開発できています。
※ LLMはAzure OpenAIなのでRubyからも簡単に利用できるのですが、日本語用のEmbeddingモデルの利用箇所だけは残念ながらRubyサーバーの横にPythonサーバーを立てる形になってしまっています。
社内ツールにはあまりコストをかけたくないので、今後も少人数で高速に開発できるようにRubyを活用したいと考えています。

以下のアドベントカレンダーでも24日に記事を書きますので、よろしければこちらも参照ください:)

https://adventar.org/calendars/9137

Happy Elements

Discussion