GitHub Copilot Code Reviewの自動学習ループ — 一度指摘すれば次からはAIが怒ってくれる仕組みを作ってみた

要約

  • PRのレビューコメントをAzure OpenAIで分析し、GitHub Copilotの設定ファイルを半自動で更新します
  • AIが直接コミットしないHuman-in-the-Loop設計で、誤ったルール混入を防げます
  • gpt-5-miniなら1回の分析コストは1円未満。Before/Afterで実際にGitHub Copilot Code ReviewのPRレビュー精度が変わることを確認しました

はじめに

こんにちは、サイオステクノロジーの藤井です。

Pull Requestのレビューをしているときに「この指摘、前のPRでも同じことを言った気がする」ってことありますよね。私はありました。

今回は、そんなレビュアーの不満を解消する仕組みを作ってみました。日々同じ指摘を繰り返すことに疲弊している、シニアエンジニアやテックリードの方々の一助になれば幸いです。

背景:レビューコメントは「暗黙知の宝庫」である

チーム開発には、明文化されていない「暗黙知」がつきものです。昨日の設計会議で決まった命名規則、先週の障害対応で得た「このパターンは使わない」という教訓、半年かけて育ててきたプロジェクト固有のベストプラクティス、「ほかの実装者のPRレビューで指摘された事」なんていう実装者側にもどうにも知りえない場合も多々あります。

PRをレビューする際、これらを踏まえた視点は品質担保に不可欠ですが、これらがわざわざWikiやREADMEに書き起こされることは稀です。

ここにGitHub Copilot Code Reviewを導入しても、素のAIはチーム固有の暗黙知を知りません。設定ファイルでルールを教えることは可能ですが、新たな暗黙知が生まれるたびに手動で設定ファイルをメンテし続ける運用は、どう考えても面倒で長続きしません。

結果として、AIがスルーした暗黙知を、人間のレビュアーが何度も繰り返し指摘する羽目になります。

こうして人間のレビューコストは膨らみ、開発のボトルネックになっていきます。レビュアーは疲弊しますが、一方で、彼らが残したレビューコメントは生きた知見が詰まった「情報の宝庫」です。
毎回コストをかけて宝(知見)を生み出しているのに、PRがマージされると同時に、それはコードの海に沈んで捨てられてしまう。これは非常にもったいない状態です。

その情報を拾い上げて、GitHub Copilotの設定に自動で反映できれば、同じ指摘を繰り返す手間を減らせると思います。そう考えて、PRのレビューコメントをAIで分析して、GitHub Copilotの設定ファイルを半自動で更新するフィードバックループを作ってみました。次の章では、その仕組みを説明していきます。

作ったものの概要

システム構成図

登場するコンポーネントとその関係は次の通りです。

分析に使用するAIとしては、ソースコードがAIに学習されるリスクを避けたかったので、Azure OpenAIを選びました。
エンタープライズ契約の環境であれば、機密情報である実装コードを入力しても、モデルの学習に二次利用されないことが保証されているからです。

ユースケースのワークフロー

「何が起きるか」の流れはシンプルです。

①実装者が問題のあるPRを作成します
②GitHub Copilotにレビューさせますが、汎用的な指摘しかできないので、当然、暗黙知な問題は見逃します。
③仕方が無いので、人間のレビュアーが指摘します。
④実装者が指摘内容を直し、マージします。
⑤PRマージをトリガーにGitHub Actions が起動し、PRのコメントやdiffを取得します。
⑥Python スクリプトがAOAIにコメント等の分析を依頼し、どのようにCopilotの設定ファイルを更新すべきが出力されます
⑦設定ファイルの更新PR(学習PR) を自動作成します。
⑧人間が内容を確認してマージ
⑨同様な問題を持つ次のPR が作成されます
⑩Copilotno設定ファイルにその問題点を指摘する様に記載されたので、GitHub Copilotは見逃さずに指摘できます。

直接コミットせずPRを挟むHuman-in-the-Loop設計を採用しています。AIの誤判断や個人の好みがそのままルールになるのを防ぐための安全弁です。人間が「このルールはおかしい」と判断すればPRを閉じるだけで済みます。

2種類の設定ファイルを使い分ける

GitHub Copilot の設定ファイルには2種類あります。

ファイル役割
.github/copilot-instructions.mdコーディングルール:コードを書くときに守るべき規約「エラー出力には print() ではなく必ず共通の logger.error() を使用すること」
.github/instructions/review.instructions.mdレビュー観点:レビュー時に確認すべき視点「ログ出力の内容に、パスワードや個人情報(PII)などの機密情報が含まれていないか確認する」

AIはレビューコメントを読み、「これはコードの書き方の規約か、それともレビュー時のチェック観点か」を判断して適切なファイルを更新します。1つの指摘が両方に当てはまる場合は両方に反映します。
詳しくはこちらの記事をご覧ください。GitHub Copilot設定5種を網羅!生産性を最大化する使い分け術

リジェクトPRされたPRからも知見を得る

ワークフローのトリガーによって学習モードが変わります。

トリガー学習モード
PRがマージされたBest Practices として抽出(「こうすると良い」)
learn-from-rejection ラベル付きでクローズされたAnti-Patterns として抽出(「DO NOT …」形式)

リジェクトされたPRは「やってはいけないパターン」の宝庫だと思います。問題のあるコードを含むPRに learn-from-rejection ラベルを付けてクローズするだけで、アンチパターンとして学習させられる様にしました。

セットアップ方法

必要なもの

  • Azure OpenAI リソース(gpt-5-mini を推奨。コストが低く1回のPR分析は1円未満)
  • Endpoint・API Key・デプロイ名を用意します
  • GitHub リポジトリ(導入先)

Step 1: 3ファイルをコピーする

以下の3つのファイルを導入先リポジトリの同じパスに配置します。
付録として、この記事の最下部にファイル全体を置いておきます。

.github/workflows/learn-from-review.yml

ワークフローの定義ファイルです。PRがマージされたとき、または、’learn-from-rejection’ラベル付きでクローズされたときに発火します。

jobs:
  analyze-and-update:
    # 「マージされた」 OR 「'learn-from-rejection'ラベル付きでクローズされた」場合に実行
    # マージした場合は学習対象
    # 破棄するPRの内、学習対象にしたいものは'learn-from-rejection'ラベルを付ける
    # ai-learning/* ブランチからのPRは除外(再帰防止)
    if: >-
      !startsWith(github.head_ref, 'ai-learning/') &&
      (
        github.event_name == 'workflow_dispatch' ||
        github.event.pull_request.merged == true ||
        (github.event.pull_request.state == 'closed' && contains(github.event.pull_request.labels.*.name, 'learn-from-rejection'))
      )
    runs-on: ubuntu-latest
    # 同一PR番号の並列実行を防止
    concurrency:
      group: "learn-${{ github.event.pull_request.number || github.event.inputs.pr_number }}"
      cancel-in-progress: true
    env:
      TARGET_PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.pull_request.number }}

    steps:
        ・・・

scripts/learn_from_pr.py

PRの内容を取得し、AOAIにクエリーを投げるpythonスクリプトです。プロンプトはここに記載しているので、必要に応じて調整してください。

・・・
def main():
    """PRレビューから学習してルールファイルを更新するメイン処理。"""
    # --- 1. GitHubデータの取得 ---
    g = Github(auth=github.Auth.Token(os.environ["GITHUB_TOKEN"]))
    repo = g.get_repo(os.environ["GITHUB_REPOSITORY"])
    pr = repo.get_pull(int(os.environ["PR_NUMBER"]))

    # --- 1-a. Issue コメント (PR全体のコメント): 人間のみ ---
    comments = pr.get_issue_comments()
    all_comments_text = "\n".join(
        [f"{c.user.login}: {c.body}" for c in comments if c.user.type != "Bot"]
    )

    # --- 1-b. Review コメント: スレッド構造で再構築 (Bot方針変更) ---
    review_comments = pr.get_review_comments()
    all_reviews_text = build_review_threads(review_comments)

    # --- 1-c. PR変更ファイル情報 ---
    pr_files_summary = get_pr_files_summary(pr)

    # 何も人間の会話がなければ終了
    if not all_comments_text and not all_reviews_text:
        print("No human comments found. Skipping.")
        return
・・・

scripts/requirements-action.txt

ワークフローでpythonを動かすときに必要なライブラリの定義です。

Step 2: GitHub Secrets を登録する

Settings > Secrets and variables > Actions で以下を追加します。

Secret名
AZURE_OPENAI_API_KEYAzureポータルのAPIキー
AZURE_OPENAI_ENDPOINThttps://your-resource.openai.azure.com/

GITHUB_TOKEN はGitHub Actionsが自動生成するため不要です。

Step 3: Actionsの権限を設定する

Settings > Actions > General > Workflow permissions で以下を有効化します。

  • ✅ Read and write permissions
  • ✅ Allow GitHub Actions to create and approve pull requests

この設定がないとPRの自動作成がエラーになります。

Step 4: 指示書ファイルを初期化する

AIが書き込む先のファイルを作っておきます。

.github/copilot-instructions.md:

# プロジェクト コーディングルール

## 言語・フレームワーク

- Python 3.8以上
- Webフレームワーク: FastAPI

## コーディング規約

- PEP 8に従う
- 型ヒントを積極的に使用する

.github/instructions/review.instructions.md:

---
applyTo: "**/*.py"
excludeAgent: "coding-agent"
description: "Python PR Review専用ガイドライン - Code Reviewのみに適用"
---
# Python Code Review Guidelines

このファイルはGitHub Copilot Code Review専用の指示です。
PRレビュー時にのみ適用され、Copilot ChatやCoding Agentには適用されません。

## 出力形式

- **日本語で出力してください**
- 問題の重要度を明記してください
  - Critical: 必ず修正が必要(セキュリティ、データ損失リスク)
  - High: 修正を強く推奨(バグ、パフォーマンス問題)
  - Medium: 修正を推奨(コード品質、保守性)
  - Low: 改善提案(スタイル、軽微な改善)

## レビュー観点(Best practices)

フロントマター(---ブロック)を先に書いておくことには意味があります。スクリプト側でフロントマターを本文から分離して保持し、AI出力の本文に再結合してから書き込む設計になっています。AIにフロントマターを渡さないことで、AI出力がフロントマターを上書き・削除するリスクをゼロにできます。

def split_frontmatter(content: str) -> tuple[str, str]:
    """Markdownコンテンツからフロントマター(---~---)を分離する。

    Returns:
        (frontmatter, body) のタプル。
        フロントマターがない場合は ("", content) を返す。
    """
    match = re.match(r"^(---\n.*?\n---\n?)(.*)", content, re.DOTALL)
    if match:
        return match.group(1), match.group(2)
    return "", content


def join_frontmatter(frontmatter: str, body: str) -> str:
    """フロントマターと本文を再結合する。

    フロントマターが空の場合は本文のみ返す。
    フロントマターの末尾に改行がない場合は追加する。
    """
    if not frontmatter:
        return body
    if not frontmatter.endswith("\n"):
        frontmatter += "\n"
    return frontmatter + body

実際に動かしてみた — Before/Afterで確認する

「本当に学習でレビュー精度が上がるのか」を確かめるために、検証してみました。

Before:Copilotは何も言わない

GET /items/{item_id} を以下のように実装したPRを作成し、Copilot Code Reviewを実行しました。

このプロジェクトでは主キーで検索するときは、db.query().filter()ではなく、db.get()を使ってほしいのですが、完全にスルーされました。ルールを知らないので当然ですね。

学習させる

このPRに人間がインラインコメントを追加しました。

PRをマージすると、ワークフローが起動します。Azure OpenAIがコメントを分析し、学習PRが自動生成されます。

生成された学習PRには次の変更が含まれています。

この学習PRをマージしました。

After:Copilotが「リポジトリのルール違反」を指摘する

同パターンのコードを含む別のPR(DELETE /items/{item_id})を作成してCopilot Code Reviewを実行します。

Copilot Code Reviewからこんな指摘が来ました。

学習がレビュー内容に反映されました。しかも「一般的なベストプラクティスとして」ではなく「このリポジトリのルールとして」指摘しています。チームの知見がCopilotに渡ったことが確認できました。

まとめ

この記事で紹介したこと:

  • PRレビューのコメントをAzure OpenAIで分析し、GitHub Copilotの設定ファイルを半自動で更新するフィードバックループを作ってみました
  • 一度レビューすれば、同じ指摘を二度としなくて良いようになります
  • Human-in-the-Loop設計で誤ったルール混入のリスクを抑えながら自動化できます
  • gpt-5-mini なら1回の分析コストは1円未満。シニアエンジニアのレビュー時間削減と比べれば十分ペイすると思います

セットアップは3ファイルのコピーとSecretsの設定だけです。下のコードをそのままコピーして使っていただき、チームの文化・課題・プロセスに合わせてプロンプトやトリガー条件を自由にカスタマイズしてみてください。

このループが育てば、Copilotはチームのことを少しずつ知っていくと思います。

コード全体

.github/workflows/learn-from-review.yml
name: Learn from PR Review (AOAI)

on:
  pull_request:
    types: [closed] # PRが閉じられた時だけ発火
  workflow_dispatch:
    inputs:
      pr_number:
        description: '対象のPR番号'
        required: true
        type: number

permissions:
  contents: write      # copilot-instructions.md を更新してPRを作るために必要
  pull-requests: write # PRを作成するために必要
  issues: read         # コメントを読むために必要

jobs:
  analyze-and-update:
    # 「マージされた」 OR 「'learn-from-rejection'ラベル付きでクローズされた」場合に実行
    # マージした場合は学習対象
    # 破棄するPRの内、学習対象にしたいものは'learn-from-rejection'ラベルを付ける
    # ai-learning/* ブランチからのPRは除外(再帰防止)
    if: >-
      !startsWith(github.head_ref, 'ai-learning/') &&
      (
        github.event_name == 'workflow_dispatch' ||
        github.event.pull_request.merged == true ||
        (github.event.pull_request.state == 'closed' && contains(github.event.pull_request.labels.*.name, 'learn-from-rejection'))
      )
    runs-on: ubuntu-latest
    # 同一PR番号の並列実行を防止
    concurrency:
      group: "learn-${{ github.event.pull_request.number || github.event.inputs.pr_number }}"
      cancel-in-progress: true
    env:
      TARGET_PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.pull_request.number }}

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.10'

      - name: Install dependencies
        run: |
          pip install -r scripts/requirements-action.txt

      - name: Analyze PR and Generate Instructions
        id: analyze
        env:
          # Azure OpenAIの接続情報
          AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
          AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
          AZURE_OPENAI_DEPLOYMENT: "gpt-5-mini" # デプロイ名
          AZURE_OPENAI_API_VERSION: "2024-12-01-preview"
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ env.TARGET_PR_NUMBER }}
        run: |
          python scripts/learn_from_pr.py

      - name: Create Pull Request with Updates
        # 変更なし時はPR作成スキップ
        if: steps.analyze.outputs.files_updated == 'true'
        uses: peter-evans/create-pull-request@v6
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          commit-message: "docs: update review instructions from PR #${{ env.TARGET_PR_NUMBER }}"
          title: "🤖 Update Review Instructions (Learning from PR #${{ env.TARGET_PR_NUMBER }})"
          # Pythonスクリプトが出力した change_log をここに埋め込む
          body: |
            ## AI Analysis Report
            ${{ steps.analyze.outputs.pr_body }}

            ---
            *Auto-generated by Learn-from-Review Workflow*
            Reference PR: #${{ env.TARGET_PR_NUMBER }}
          branch: ai-learning/pr-${{ env.TARGET_PR_NUMBER }}
          base: main
          # 【対策: ルールの肥大化・間違いの防止】
          # AIが直接mainにコミットするのではなく、必ず「PR」を作成する。
          # 人間が最終確認(Merge)しない限り、ルールは適用されない。
scripts/learn_from_pr.py
import os
import re
import sys
import json
import time
from typing import Any

import github
from github import Github
from github.GithubException import GithubException
from github.PaginatedList import PaginatedList
from github.PullRequest import PullRequest
from github.PullRequestComment import PullRequestComment
from openai import AzureOpenAI

# 設定
REVIEW_INSTRUCTIONS_FILE = ".github/instructions/review.instructions.md"
CODING_RULES_FILE = ".github/copilot-instructions.md"

# --- トークン制限対策の定数 ---
# コメント+diff_hunk の合計文字数がこの閾値を超えたら要約LLMを発動
COMMENT_CHAR_THRESHOLD = 15_000

# TODO 要約するLLMのコンテキストサイズに入らないぐらいやり取りが長い場合の対策

# 要約後もこの文字数を超える場合は切り捨て
SUMMARY_MAX_CHARS = 12_000

# --- フロントマター保護・空コンテンツガードの定数 ---
MIN_CONTENT_CHARS = 50  # 最低限の文字数(これ未満は異常とみなす)

# --- APIリトライ設定 ---
MAX_RETRIES = 3
RETRY_BASE_DELAY = 2  # 秒


def split_frontmatter(content: str) -> tuple[str, str]:
    """Markdownコンテンツからフロントマター(---~---)を分離する。

    Returns:
        (frontmatter, body) のタプル。
        フロントマターがない場合は ("", content) を返す。
    """
    match = re.match(r"^(---\n.*?\n---\n?)(.*)", content, re.DOTALL)
    if match:
        return match.group(1), match.group(2)
    return "", content


def join_frontmatter(frontmatter: str, body: str) -> str:
    """フロントマターと本文を再結合する。

    フロントマターが空の場合は本文のみ返す。
    フロントマターの末尾に改行がない場合は追加する。
    """
    if not frontmatter:
        return body
    if not frontmatter.endswith("\n"):
        frontmatter += "\n"
    return frontmatter + body


def validate_content(content: str, file_label: str) -> bool:
    """生成されたコンテンツが有効かどうかを検証する。

    空白のみ・改行のみ・最低文字数未満の場合はFalseを返す。
    フロントマター再結合後に「フロントマターしかない」状態も検知する。
    """
    if not content or not content.strip():
        print(
            f"WARNING: {file_label} の生成コンテンツが空です。書き込みをスキップします。"
        )
        return False

    _, body = split_frontmatter(content)
    if not body.strip():
        print(
            f"WARNING: {file_label} のコンテンツはフロントマターのみです。"
            "書き込みをスキップします。"
        )
        return False

    if len(content.strip()) < MIN_CONTENT_CHARS:
        print(
            f"WARNING: {file_label} の生成コンテンツが短すぎます "
            f"({len(content.strip())} chars < {MIN_CONTENT_CHARS})。"
            "書き込みをスキップします。"
        )
        return False

    return True


def call_llm_with_retry(
    client: Any, model: str, messages: list[dict], **kwargs: Any
) -> Any:
    """LLM API呼び出しを指数バックオフ付きリトライで実行する。

    最大MAX_RETRIES回リトライし、2秒→4秒→8秒の指数バックオフで待機する。
    """
    for attempt in range(MAX_RETRIES):
        try:
            return client.chat.completions.create(
                model=model, messages=messages, **kwargs
            )
        except Exception as e:
            if attempt == MAX_RETRIES - 1:
                print(f"LLM API call failed after {MAX_RETRIES} attempts: {e}")
                raise
            delay = RETRY_BASE_DELAY * (2**attempt)
            print(
                f"LLM API call failed (attempt {attempt + 1}/{MAX_RETRIES}): {e}. "
                f"Retrying in {delay}s..."
            )
            time.sleep(delay)


def build_review_threads(review_comments: PaginatedList[PullRequestComment]) -> str:
    """レビューコメントからスレッド構造を再構築する。

    Bot含む全コメントを取得し、in_reply_to_id でスレッドを組み立てる。
    人間のコメントが1件でも含まれるスレッドはBot発言も保持し、
    Botのみのスレッドは除外する。

    各コメントには diff_hunk(コメント箇所の周辺数行)を付加し、
    AIが「ここ」を正しく理解できるようにする。
    """
    # 全コメントをID→コメントの辞書に格納
    comments_by_id: dict[int, PullRequestComment] = {}
    # スレッド: 親ID → [子コメント, ...]
    threads: dict[int, list[Any]] = {}

    all_comments = list(review_comments)

    for c in all_comments:
        comments_by_id[c.id] = c
        # in_reply_to_id がある場合は返信、なければ親(スレッドルート)
        if c.in_reply_to_id and c.in_reply_to_id in comments_by_id:
            parent_id = c.in_reply_to_id
            threads.setdefault(parent_id, []).append(c)
        else:
            # 自身がスレッドルート
            threads.setdefault(c.id, [])

    # スレッド単位で人間の関与チェック&フォーマット
    formatted_threads: list[str] = []

    for root_id, replies in threads.items():
        root_comment = comments_by_id[root_id]

        thread_comments = [root_comment] + replies
        has_human = any(c.user.type != "Bot" for c in thread_comments)

        if not has_human:
            # Botのみのスレッドは除外
            continue

        lines: list[str] = [f"[Thread #{root_id}]"]
        for c in thread_comments:
            user_type = "Bot" if c.user.type == "Bot" else "Human"
            lines.append(f"  {c.user.login} ({user_type}) on {c.path}:")
            # diff_hunk はコメント箇所の周辺数行のみ。@@ヘッダーに行番号が含まれる。
            if c.diff_hunk:
                lines.append("  --- diff context ---")
                for dl in c.diff_hunk.splitlines():
                    lines.append(f"  {dl}")
                lines.append("  --- comment ---")
            lines.append(f"  {c.body}")
            lines.append("")

        formatted_threads.append("\n".join(lines))

    return "\n\n".join(formatted_threads)


def get_pr_files_summary(pr: PullRequest) -> str:
    """PR変更ファイル一覧と変更行数を取得し、テキストで返す。"""
    files = pr.get_files()
    lines: list[str] = []
    for f in files:
        lines.append(
            f"- {f.filename}: +{f.additions}/-{f.deletions} (status: {f.status})"
        )
    return "\n".join(lines) if lines else "(変更ファイルなし)"


def summarize_comments(client: Any, model: str, text: str) -> str:
    """コメント+diff_hunk のテキストが長すぎる場合、LLMで要約する。

    COMMENT_CHAR_THRESHOLD 以下の場合はそのまま返す。
    """
    if len(text) <= COMMENT_CHAR_THRESHOLD:
        return text

    print(
        f"Comment text exceeds {COMMENT_CHAR_THRESHOLD} chars "
        f"({len(text)} chars). Summarizing with LLM..."
    )

    summary_prompt = (
        "以下のPRレビュー議論を、**合意事項・決定事項・変更依頼の結論**を"
        "中心に要約してください。\n"
        "問題提起→議論→結論の流れが分かるように構造を維持してください。\n"
        "コード例やファイルパスなど、ルール抽出に必要な具体情報は保持してください。\n"
        "要約は日本語で出力してください。"
    )

    response = call_llm_with_retry(
        client,
        model,
        [
            {"role": "system", "content": summary_prompt},
            {"role": "user", "content": text},
        ],
    )
    summarized = response.choices[0].message.content
    print(f"Summarized: {len(text)} -> {len(summarized)} chars")

    # 要約後もまだ長すぎる場合は切り捨て
    if len(summarized) > SUMMARY_MAX_CHARS:
        print(
            f"Summarized text still too long ({len(summarized)} chars). "
            f"Truncating to {SUMMARY_MAX_CHARS} chars."
        )
        summarized = (
            summarized[:SUMMARY_MAX_CHARS]
            + "\n\n(以降省略: 要約後も長すぎるため切り捨て)"
        )

    return summarized


def main():
    """PRレビューから学習してルールファイルを更新するメイン処理。"""
    # --- 1. GitHubデータの取得 ---
    g = Github(auth=github.Auth.Token(os.environ["GITHUB_TOKEN"]))
    repo = g.get_repo(os.environ["GITHUB_REPOSITORY"])
    pr = repo.get_pull(int(os.environ["PR_NUMBER"]))

    # --- 1-a. Issue コメント (PR全体のコメント): 人間のみ ---
    comments = pr.get_issue_comments()
    all_comments_text = "\n".join(
        [f"{c.user.login}: {c.body}" for c in comments if c.user.type != "Bot"]
    )

    # --- 1-b. Review コメント: スレッド構造で再構築 (Bot方針変更) ---
    review_comments = pr.get_review_comments()
    all_reviews_text = build_review_threads(review_comments)

    # --- 1-c. PR変更ファイル情報 ---
    pr_files_summary = get_pr_files_summary(pr)

    # 何も人間の会話がなければ終了
    if not all_comments_text and not all_reviews_text:
        print("No human comments found. Skipping.")
        return

    # 既存レビュールール(更新対象)
    review_frontmatter = ""
    try:
        current_review_rules_raw = repo.get_contents(
            REVIEW_INSTRUCTIONS_FILE
        ).decoded_content.decode()
        # フロントマター分離(AIにはbodyのみ渡す)
        review_frontmatter, current_review_rules = split_frontmatter(
            current_review_rules_raw
        )
    except GithubException:
        current_review_rules = "(まだファイルはありません)"

    # プロジェクトのコーディングルール(更新対象)
    try:
        current_coding_rules = repo.get_contents(
            CODING_RULES_FILE
        ).decoded_content.decode()
    except GithubException:
        current_coding_rules = "(まだファイルはありません)"

    # --- 2. Azure OpenAIへの接続 ---
    client = AzureOpenAI(
        api_key=os.environ["AZURE_OPENAI_API_KEY"],
        api_version=os.environ["AZURE_OPENAI_API_VERSION"],
        azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
    )

    # PRの状態を確認
    is_merged = pr.merged
    state_status = "MERGED (Approved)" if is_merged else "REJECTED (Closed)"

    # --- レビュー承認状態の取得 ---
    reviews = pr.get_reviews()
    review_states: list[str] = []
    for r in reviews:
        if r.state in ("APPROVED", "CHANGES_REQUESTED", "COMMENTED"):
            review_states.append(f"  - {r.user.login}: {r.state}")
    review_state_text = (
        "\n".join(review_states) if review_states else "  (レビューなし)"
    )

    # プロンプトの構築
    system_prompt = f"""
    あなたはチームの2つのルールファイルを管理するAIです。

    【管理対象ファイル】

    1. `review.instructions.md` — GitHub Copilot Code Review が参照するレビュー観点
       - 「何をレビューで指摘すべきか」「どんな観点でチェックするか」を記載する
       - 例: 「例外処理が広すぎないか確認する」「N+1クエリがないか確認する」
       - セクション名はPR固有の表現にせず、汎用的な名前にする(×「このPRの運用で追加しておくべき~」 → ○「プロジェクト固有のレビュー観点」)
       - 個別のレビュー観点にseverityを付けない。severityはレビューコメントの出力時に判断するものであり、観点の定義に含めない
       - フロントマター(YAML header: `---`〜`---`)は出力に含めない。本文のMarkdownのみを出力する

    2. `copilot-instructions.md` — プロジェクト全体のコーディングルール
       - 「コードを書くときに守るべきルール」を記載する
       - 例: 「ベアexceptは使わず具体的な例外クラスを指定する」「関数は50行以内にする」

    【分類の指針】
    - コーディングルール: 「〜すべき」「〜してはいけない」という具体的な書き方の規約
    - レビュー観点: 「〜を確認する」「〜に注意する」というレビュー時のチェック項目
    - 1つの指摘が両方に該当する場合は、両方に適切な形で反映する
    - どちらにも該当しない(単なる議論やPR固有の話題)場合は反映しない
    - `copilot-instructions.md` に既にコーディング規約として記載されている内容は、`review.instructions.md` 側に「〜を確認する」と重複させない

    【分析の指針】
    - PRが MERGED の場合: 推奨パターン(Best Practices)としてルールを抽出する
    - PRが REJECTED の場合:
      - 禁止事項(Anti-Patterns)として「〜してはいけない(DO NOT ...)」形式で記述する
      - 可能なら、なぜダメか(×)と代わりに何をすべきか(○)のパターンを含める
      - 単なる重複や方針変更によるCloseであればルール化しない

    【Botコメントの扱い】
    Botのコメントは対話の文脈として含まれている。Botの指摘に対する人間の応答(「対応しない」「別PRで対応済み」等)にも注目し、チームの方針として反映する。Bot単独の発言(人間の応答がないもの)は除外済み。

    【現在のレビュールール (review.instructions.md) ※フロントマター除去済み】
    {current_review_rules}

    【現在のコーディングルール (copilot-instructions.md)】
    {current_coding_rules}

    【良い設定ファイルの品質基準】
    - 抽象化された原則 + 代表例1つ: 個別のPR事例をそのまま書かず、汎用的な原則に昇華し具体例を1つ添える
    - 機械的に判定・実行可能なルール: 「適切に」「必要に応じて」等の曖昧な表現を避け、誰が読んでも同じ解釈になる明確な指示にする
    - ドメイン・言語・目的別の階層化: 関連するルールをセクションでグループ化する
    - 指示に特化: 冗長な背景説明は省き、「〜する」「〜を確認する」等の指示文のみで構成する
    - 各レビュー観点は1-2文で簡潔に: 必要なら悪い例(×)と良い例(○)のパターンを含める
    - 重複排除: 同じ趣旨のルールが異なる表現で複数存在しないようにする

    【更新ルール】
    1. 既存ルールとの統合: 単なる追記ではなく、既存の項目とマージして簡潔にする
    2. 衝突の解決: 既存ルールと矛盾する場合、新しい議論を優先し、その旨を change_log に明記する
    3. 2ファイル間の重複回避: 同じ内容を両方のファイルに書かない
    4. 変更不要な場合: 該当ファイルの内容が変わらない場合は、現在の内容をそのまま返す

    【出力形式】
    以下のJSON**のみ**を出力する。Markdownコードブロックで囲まない。
    `updated_review_instructions` にはフロントマター(`---`〜`---`)を含めない。
    {{
        "updated_review_instructions": "更新後のreview.instructions.mdの本文(Markdown, フロントマターなし)",
        "updated_coding_rules": "更新後のcopilot-instructions.mdの全内容(Markdown)",
        "review_instructions_changed": true/false,
        "coding_rules_changed": true/false,
        "change_log": "何を変更したか、なぜ変更したか、どちらのファイルに振り分けたかの解説"
    }}
    """

    pr_description = pr.body or "(説明なし)"

    user_prompt = f"""
    PR Context:
    - Title: {pr.title}
    - Description: {pr_description}
    - State: {state_status}
    - Review Approvals:
{review_state_text}

    Changed Files:
    {pr_files_summary}

    Issue Comments (PR全体):
    {all_comments_text}

    Review Comments (インラインコメント・スレッド構造):
    {all_reviews_text}
    """
    # --- コメント量が多すぎる場合の要約 ---
    combined_comments = f"{all_comments_text}\n{all_reviews_text}"
    summarized_comments = summarize_comments(
        client, os.environ["AZURE_OPENAI_DEPLOYMENT"], combined_comments
    )
    if summarized_comments != combined_comments:
        # 要約された場合、ユーザープロンプトを差し替え
        user_prompt = f"""
    PR Context:
    - Title: {pr.title}
    - Description: {pr_description}
    - State: {state_status}
    - Review Approvals:
{review_state_text}

    Changed Files:
    {pr_files_summary}

    Review Discussion (要約済み):
    {summarized_comments}
    """

    # デバッグ
    print("system_prompt:")
    print(system_prompt)
    print("user_prompt:")
    print(user_prompt)

    # --- リトライ付きLLM呼び出し ---
    response = call_llm_with_retry(
        client,
        os.environ["AZURE_OPENAI_DEPLOYMENT"],
        [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt},
        ],
        response_format={"type": "json_object"},
    )

    # JSONパース
    try:
        result = json.loads(response.choices[0].message.content)
    except json.JSONDecodeError:
        print("Failed to parse JSON response.")
        sys.exit(1)

    change_log = result.get("change_log", "No details provided.")
    files_updated = []

    # --- 3. レビュールールの更新 ---
    if result.get("review_instructions_changed", False):
        new_review_content = result.get("updated_review_instructions", "")
        # フロントマター再結合
        full_review_content = join_frontmatter(review_frontmatter, new_review_content)
        # 空コンテンツガード
        if validate_content(full_review_content, REVIEW_INSTRUCTIONS_FILE):
            os.makedirs(os.path.dirname(REVIEW_INSTRUCTIONS_FILE), exist_ok=True)
            with open(REVIEW_INSTRUCTIONS_FILE, "w") as f:
                f.write(full_review_content)
            files_updated.append(REVIEW_INSTRUCTIONS_FILE)
            print(f"Updated {REVIEW_INSTRUCTIONS_FILE}")

    # --- 4. コーディングルールの更新 ---
    if result.get("coding_rules_changed", False):
        new_coding_content = result.get("updated_coding_rules", "")
        # 空コンテンツガード
        if validate_content(new_coding_content, CODING_RULES_FILE):
            os.makedirs(os.path.dirname(CODING_RULES_FILE), exist_ok=True)
            with open(CODING_RULES_FILE, "w") as f:
                f.write(new_coding_content)
            files_updated.append(CODING_RULES_FILE)
            print(f"Updated {CODING_RULES_FILE}")

    if not files_updated:
        print("No rule changes needed from this PR.")
        # 変更なしをGitHub Actions出力に設定
        if "GITHUB_OUTPUT" in os.environ:
            with open(os.environ["GITHUB_OUTPUT"], "a") as f:
                f.write("files_updated=false\n")
        return

    # --- 5. GitHub Actions出力 ---
    if "GITHUB_OUTPUT" in os.environ:
        with open(os.environ["GITHUB_OUTPUT"], "a") as f:
            f.write("files_updated=true\n")
            delimiter = f"EOF_DELIMITER_{int(time.time())}"
            updated_files_summary = "\n".join(f"- `{path}`" for path in files_updated)
            body = f"### 更新されたファイル\n{updated_files_summary}\n\n### 変更内容\n{change_log}"
            f.write(f"pr_body<<{delimiter}\n")
            f.write(body)
            f.write(f"\n{delimiter}\n")

    print("Done.")


if __name__ == "__main__":
    main()

scripts/requirements-action.txt
PyGithub>=2.0,&lt;3.0
openai>=1.0,&lt;2.0

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

0人がこの投稿は役に立ったと言っています。
エンジニア募集中!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です