ファイルの依存関係を全部プロンプトに入れてGemini 2.5 Proにレビューさせよう

に公開

目的 & 動機

  • コードレビューエージェントが作りたい。
  • ファイルの差分を読み込むのは簡単だが、変更の無いファイルとの依存関係も考慮して欲しい。
  • 変更があったファイルと依存関係のある全てのファイルをプロンプトに入れるコードレビューエージェントを作りたい。
  • 既存のツールはいろいろあるが、自分で微調節もしたい。

Gemini 2.5 Pro

  • 皆さん、Gemini 2.5 Proは試しましたか? 2025年4月20日現在、Gemini 2.5 Proは無料で試すことができるのでぜひ試していただきたいです。
  • 圧倒的なコンテキストウィンドウの広さに加え、コーディング特化のモデルなので、レビューとの相性がいい感じです。

https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/2-5-pro?hl=ja

deno

  • コードレビューを行うためのコードはdenoで書きました。Github Actions上でしか実行しないので実行環境はなんでもよかったのですが、以下のようにコマンドで権限を明示できると安心かなと思いました。
deno run \
  --allow-read \
  --deny-write \
  --allow-env \
  --allow-net=github.com,deno.land,generativelanguage.googleapis.com,api.github.com:443 \
  --config=.github/review/deno.json \
  .github/review/main.ts
  • 上記のコマンドは以下を表します

    • ファイルの読み取りは許す
    • ファイルの書き込みは絶対に許さない
    • 環境変数の読み込みは許す
    • ネットワークアクセスは以下のドメインのみ許す
      • deno.land : denoのモジュールをダウンロードするために必要
      • generativelanguage.googleapis.com : gemini api を呼び出すために必要
      • api.github.com:443 : プルリクストの差分を取得するapiを呼び出すために必要
  • denoのパーミッションに詳しい説明はこちら

https://docs.deno.com/runtime/fundamentals/security/

処理の流れ

  1. Github Actionsを用い、プルリクエストがあった時にmain.tsを起動する。
  2. Github API を使い、プルリクエストにおける差分を取得。
  3. 変更があるファイルに依存するファイルを取得(あとで図を用いて説明します。)
  4. 差分、コーディング規約を用いてプロンプトを作成し、geminiにレビューしてもらう。
  • main.tsを最初に読むと、処理の流れがだいたいわかると思います。

依存関係とは?

  • あるファイル、A.tsに変更があったとき、以下のファイルを依存関係のあるファイルとして扱います。再起的に全部辿るイメージです
    • A.tsをインポートしているファイルとA.tsをインポートしているファイルと依存関係にあるファイル
    • A.tsの中でインポートしているファイルとA.tsの中でインポートしているファイルと依存関係のあるファイル
  • 依存関係を取得する方法として、dependency-cruiserを使います。なので、jsやtsのプロジェクト限定になります。ただ、依存関係を取得する部分とGithub Actionsによる実行の部分は分けているので、ご自身で依存関係を表すファイルを作成すればこのレビューエージェントを使うことができます。
  • 例えば、以下のような画像の依存関係(親が子をインポートしていると思ってください。)で、src/components/sample/F.tsxと依存関係にあるファイルとは以下を表します。
    • src/components/sample/J.tsx
    • src/components/sample/D.tsx
    • src/components/sample/C.tsx
    • src/components/sample/B.tsx
    • src/components/sample/A.tsx

実際にgeminiに投げられるプロンプトの例

  • 長いので畳み込んで表示します。
  • ひとつのプロンプトの中に以下を入れてます。
    • diffの全て
    • diffの存在するファイルと依存関係にある全てのファイルの内容
    • レビュー規則
    • コーディング規約
  • 以下のファイルが変更された想定です。
    • src/components/sample/D.tsx
    • src/components/sample/F.tsx
実際にgeminiに投げられるプロンプトの例

あなたは優秀なコードレビュアーです。以下のルールを守り、レビューを行ってください。

レビュー規約

  • レビュー対象のコードは、必ず明示すること
  • 具体的なレビューをすること
  • コードに問題がある場合、修正案を具体的なコードを提示すること
  • ギャル語で応答して。絵文字をたくさん使うこと
  • コードレビューを受けた人間の成長につながるようなレビューを心がけること

コーディング規約

  • 変数と関数名には camelCaseを使います

変更内容

File: src/components/sample/D.tsx

diff --git a/src/components/sample/D.tsx b/src/components/sample/D.tsx
index 91081ca..cf0b30c 100644
--- a/src/components/sample/D.tsx
+++ b/src/components/sample/D.tsx
@@ -4,3 +4,4 @@ import G from "./G";

+console.log("Dでの変更");
\ No newline at end of file

Dependencies:

  • src/components/sample/E.tsx
  • src/components/sample/H.tsx
  • src/components/sample/I.tsx
  • src/components/sample/F.tsx
  • src/components/sample/J.tsx
  • src/components/sample/G.tsx

Reversed Dependencies:

  • src/components/sample/B.tsx
  • src/components/sample/A.tsx
  • src/components/sample/C.tsx

File: src/components/sample/F.tsx

diff --git a/src/components/sample/F.tsx b/src/components/sample/F.tsx
index 37bb787..f274fd9 100644
--- a/src/components/sample/F.tsx
+++ b/src/components/sample/F.tsx
@@ -1 +1,2 @@
import J from "./J";
+console.log("Fでの変更");
\ No newline at end of file

Dependencies:

  • src/components/sample/J.tsx

Reversed Dependencies:

  • src/components/sample/D.tsx
  • src/components/sample/B.tsx
  • src/components/sample/A.tsx
  • src/components/sample/C.tsx

All Dependency Files:

src/components/sample/E.tsx

import H from "./H";
import I from "./I";

,

src/components/sample/H.tsx

,

src/components/sample/I.tsx

,

src/components/sample/F.tsx

import J from "./J";
console.log("Fでの変更");

,

src/components/sample/J.tsx

,

src/components/sample/G.tsx

,

src/components/sample/B.tsx

import D from "./D";
export const B = () => {
  return <div>B</div>;
};

,

src/components/sample/A.tsx

import { B } from "./B";

,

src/components/sample/C.tsx

import D from "./D";

,

src/components/sample/D.tsx

import E from "./E";
import F from "./F";
import G from "./G";
console.log("Dでの変更");

ギャル語プロンプト

.github/review/prompts/prompt.mdのプロンプトの中には、「ギャル語で応答して。」という文があります。これは決してふざけているわけではありません。

  • レビューにおいて、以下が大事だと考えています。
    • 悪い実装は指摘するのは当然だが、良い実装も誉めるべき。文章量が多くなり、ノイズになるという考えもあると思いますが、以下の点を考慮した結果です
      • 誉めることによって発生するやる気はパフォーマンスに影響を与える
      • 悪いところばかり指摘されるやる気をなくしてしまう
      • 文章に圧が存在しないこと。これは主観ですが、敬語ばかりの文章よりも砕けた文章の気が楽です。
  • ↑↑ の効果を「ギャル語で応答して。」というプロンプトひとつで表現することができます。実際の「ギャル語」の定義は触れませんが、LLMはギャル語をそう認識しているっぽいです。

実装

  • 以下のような構造になります
  • この実装のセクションをそのままAIエディターに入力すれば実装してくれると思います
.github
├── review
│   ├── deno.json
│   ├── deno.lock
│   ├── main.ts
│   ├── print-files.ts
│   ├── prompts
│   │   ├── code-style.md
│   │   └── prompt.md
│   └── utils
│       ├── comment.ts
│       ├── gemini.ts
│       ├── get-all-dependencies.ts
│       ├── get-diff-text.ts
│       ├── get-prompt.ts
│       ├── graph.ts
│       ├── load-file.ts
│       ├── split-diff-by-file.ts
│       └── types.ts
└── workflows
    └── ai-review.yml

.github/review/deno.json

{
  "compilerOptions": {
    "lib": ["deno.ns", "dom"]
  }
}

.github/review/main.ts

import { join } from "https://deno.land/std@0.220.1/path/mod.ts";

import { comment } from "./utils/comment.ts";
import { generateContent } from "./utils/gemini.ts";
import { getALLDependencies } from "./utils/get-all-dependencies.ts";
import { getDiffTextFromGitHub } from "./utils/get-diff-text.ts";
import { getFilePrompt } from "./utils/get-prompt.ts";
import { loadFile } from "./utils/load-file.ts";
import { parseDiff } from "./utils/split-diff-by-file.ts";

const GEMINI_API_KEY = Deno.env.get("GEMINI_API_KEY");
const GITHUB_TOKEN = Deno.env.get("GITHUB_TOKEN");
const GITHUB_REPOSITORY = Deno.env.get("GITHUB_REPOSITORY");
const GITHUB_EVENT_PATH = Deno.env.get("GITHUB_EVENT_PATH");
const GITHUB_SHA = Deno.env.get("GITHUB_SHA");

const PROJECT_ROOT = new URL(".", import.meta.url).pathname;
const PROMPT_TEMPLATE_PATH = join(PROJECT_ROOT, "prompts", "prompt.md");
const CODING_STANDARD_PATH = join(PROJECT_ROOT, "prompts", "code-style.md");

async function main() {
  // 環境変数が設定されていない場合終わり
  if (
    !GEMINI_API_KEY ||
    !GITHUB_TOKEN ||
    !GITHUB_REPOSITORY ||
    !GITHUB_EVENT_PATH ||
    !GITHUB_SHA
  ) {
    return;
  }

  // プルリクのイベント内容取得
  const eventContent = await Deno.readTextFile(GITHUB_EVENT_PATH);
  const payload = JSON.parse(eventContent);
  if (!payload.pull_request) {
    console.log("プルリクのイベントではないよ");
    return;
  }
  const prNumber = payload.pull_request.number;
  if (!prNumber) {
    console.log("プルリクの番号がないよ");
    return;
  }

  const baseBranch = payload.pull_request.base.ref;
  if (!baseBranch) {
    console.log("プルリクのベースブランチがないよ");
    return;
  }

  // 差分を取得
  const diffText = await getDiffTextFromGitHub(
    GITHUB_TOKEN,
    GITHUB_REPOSITORY,
    GITHUB_SHA,
    baseBranch,
  );

  // 差分をファイルごとに分割
  const fileDiffRecords = parseDiff(diffText);

  // プロンプトテンプレートの取得
  const promptTemplate = await loadFile(PROMPT_TEMPLATE_PATH);
  if (!promptTemplate) {
    console.log("プロンプトテンプレートがないよ");
    return;
  }

  // 依存関係を取得
  const { dependencies, reversedDependencies } = await getALLDependencies(
    "./dependencies.json",
  );

  // ファイル関連のプロンプト取得
  const filePrompt = await getFilePrompt(
    fileDiffRecords,
    dependencies,
    reversedDependencies,
  );

  // コーディング規約関連のプロンプト取得。存在しない場合でもレビューはする
  const codingStandardPrompt = await loadFile(CODING_STANDARD_PATH);

  // プロンプトを生成
  const prompt = promptTemplate
    .replace("{diff_text}", filePrompt)
    .replace("{code_style}", codingStandardPrompt ?? "");

  // レビューを生成
  const reviewContent = (await generateContent(prompt, GEMINI_API_KEY)) ?? "";

  // レビューをコメントする
  await comment(GITHUB_REPOSITORY, prNumber, reviewContent, GITHUB_TOKEN);
}

if (import.meta.main) {
  main();
}

.github/review/prompts/code-style.md

- 変数と関数名には camelCaseを使います

.github/review/prompts/prompt.md

あなたは優秀なコードレビュアーです。以下のルールを守り、レビューを行ってください。
## レビュー規約
- レビュー対象のコードは、必ず明示すること
- 具体的なレビューをすること
- コードに問題がある場合、修正案を具体的なコードを提示すること
- ギャル語で応答して。絵文字をたくさん使うこと
- コードレビューを受けた人間の成長につながるようなレビューを心がけること
## コーディング規約
{code_style}
## 変更内容
{diff_text}

.github/review/utils/comment.ts

export async function comment(
  repo: string,
  prNumber: number,
  body: string,
  token: string,
) {
  const url = `https://api.github.com/repos/${repo}/issues/${prNumber}/comments`;
  const response = await fetch(url, {
    method: "POST",
    headers: {
      Authorization: `token ${token}`,
      Accept: "application/vnd.github.v3+json",
    },
    body: JSON.stringify({ body }),
  });

  if (response.status === 201) {
    console.log("Comment posted successfully.");
  } else {
    console.error(
      `Failed to post comment: ${response.status} - ${await response.text()}`,
    );
  }
}

.github/review/utils/gemini.ts

async function generateContent(
  prompt: string,
  geminiKey: string,
): Promise<string> {
  const response = await fetch(
    `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro-preview-03-25:generateContent?key=${geminiKey}`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        contents: [{ role: "user", parts: [{ text: prompt }] }],
      }),
    },
  );

  if (!response.ok) {
    console.error(response);
    if (response.status === 429) {
      return "APIリクエストの上限に達しました。";
    }
    return "コードレビューに失敗しました。";
  }

  const data = await response.json();
  return data.candidates[0].content.parts[0].text;
}

export { generateContent };

.github/review/utils/get-all-dependencies.ts

import { loadFile } from "./load-file.ts";
import { rowJsonSchema } from "./types.ts";
import { graph } from "./graph.ts";

export async function getALLDependencies(dependencyFilePath: string) {
  const content = await loadFile(dependencyFilePath);
  if (!content) {
    console.error(`File not found: ${dependencyFilePath}`);
    return { dependencies: {}, reversedDependencies: {} };
  }

  const json = rowJsonSchema.safeParse(JSON.parse(content));
  if (!json.success) {
    console.error(`Error parsing JSON: ${json.error}`);
    return { dependencies: {}, reversedDependencies: {} };
  }
  const nodesSet = new Set<string>();
  const edges: Array<{ source: string; target: string }> = [];

  json.data.modules.forEach((module) => {
    const sourceFile = module.source;
    nodesSet.add(sourceFile);

    module.dependencies.forEach((dep) => {
      const targetFile = dep.resolved;
      nodesSet.add(targetFile);
      edges.push({ source: sourceFile, target: targetFile });
    });
  });

  nodesSet.forEach((node) => {
    graph.addNode(node);
  });

  edges.forEach((edge) => {
    graph.addEdge(edge.source, edge.target);
  });

  const dependencies: Record<string, Set<string>> = {};
  const reversedDependencies: Record<string, Set<string>> = {};

  nodesSet.forEach((node) => {
    dependencies[node] = new Set(graph.getAllDescendants(node));
    reversedDependencies[node] = new Set(graph.getAllAncestors(node));
  });

  return { dependencies, reversedDependencies };
}

.github/review/utils/get-diff-text.ts

export async function getDiffTextFromGitHub(
  token: string,
  repo: string,
  head: string,
  baseBranch: string,
): Promise<string> {
  if (!token || !repo || !head) {
    throw new Error("Required environment variables are missing.");
  }

  const compareUrl = `https://api.github.com/repos/${repo}/compare/${baseBranch}...${head}`;
  const res = await fetch(compareUrl, {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "application/vnd.github.v3.diff",
    },
  });

  if (!res.ok) {
    const text = await res.text();
    throw new Error(`GitHub API error: ${res.status} - ${text}`);
  }

  const diffText = await res.text();
  return diffText.trim();
}

.github/review/utils/get-prompt.ts

import { loadFile } from "./load-file.ts";

export async function getFilePrompt(
  fileDiffMap: Record<string, string>,
  dependencies: Record<string, Set<string>>,
  reversedDependencies: Record<string, Set<string>>,
) {
  const dependencyObject = Object.fromEntries(
    Object.entries(dependencies).map(([key, value]) => [
      key,
      Array.from(value),
    ]),
  );
  const reversedDependencyObject = Object.fromEntries(
    Object.entries(reversedDependencies).map(([key, value]) => [
      key,
      Array.from(value),
    ]),
  );

  let result = "";
  let dependencyFilesPaths: string[] = [];

  // 全てのファイルについて処理
  Object.keys(fileDiffMap).forEach((fileName) => {
    const deps = dependencyObject[fileName] || [];
    const revDeps = reversedDependencyObject[fileName] || [];
    dependencyFilesPaths = [...dependencyFilesPaths, ...deps, ...revDeps];

    result += `
## File: ${fileName}
${fileDiffMap[fileName]}

### Dependencies:
${deps.map((dep) => `- ${dep}`).join("\n")}

### Reversed Dependencies:
${revDeps.map((dep) => `- ${dep}`).join("\n")}
`;
  });

  // 重複を除去して一意なファイルパスのリストを作成
  const uniqueDependencyFiles = [...new Set(dependencyFilesPaths)];

  const file = async (filePath: string) => `
### ${filePath}
\`\`\`
${await loadFile(filePath)}
\`\`\`
`


  // 結果の末尾に依存関係ファイルのリストを追加
  result +="\n## All Dependency Files:\n"
  result += await Promise.all(
    uniqueDependencyFiles.map(
      async (filePath) => await file(filePath)
    )
  );

  return result;
}

.github/review/utils/graph.ts

// graph.ts
export class DirectedGraph {
    private children: Map<string, Set<string>> = new Map();
    private parents: Map<string, Set<string>> = new Map();
  
    addNode(node: string): void {
      this.children.set(node, this.children.get(node) || new Set());
      this.parents.set(node, this.parents.get(node) || new Set());
    }
  
    addEdge(from: string, to: string): void {
      this.addNode(from);
      this.addNode(to);
      this.children.get(from)!.add(to);
      this.parents.get(to)!.add(from);
    }
  
    getChildren(node: string): string[] {
      return Array.from(this.children.get(node) || []);
    }
  
    getParents(node: string): string[] {
      return Array.from(this.parents.get(node) || []);
    }
  
    getAllDescendants(node: string): string[] {
      const visited = new Set<string>();
      const dfs = (n: string) => {
        for (const child of this.getChildren(n)) {
          if (!visited.has(child)) {
            visited.add(child);
            dfs(child);
          }
        }
      };
      dfs(node);
      return Array.from(visited);
    }
  
    getAllAncestors(node: string): string[] {
      const visited = new Set<string>();
      const dfs = (n: string) => {
        for (const parent of this.getParents(n)) {
          if (!visited.has(parent)) {
            visited.add(parent);
            dfs(parent);
          }
        }
      };
      dfs(node);
      return Array.from(visited);
    }
  }

const graph = new DirectedGraph();
export { graph}

.github/review/utils/load-file.ts

export async function loadFile(filePath: string): Promise<string | null> {
  try {
    return await Deno.readTextFile(filePath);
  } catch (error) {
    if (error instanceof Deno.errors.NotFound) {
      console.log(`ファイルが見つからないよ: ${filePath}`);
    } else {
      console.error(`ファイルを読めないよ: ${error}`);
    }
    return null;
  }
}

.github/review/utils/split-diff-by-file.ts

/* 入力例
diff --git a/README.md b/README.md
index e69de29..a3c3f4d 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,2 @@
+# Sample Project
+This is a sample project.

diff --git a/src/main.ts b/src/main.ts
index 1234567..89abcde 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,4 +1,5 @@
 console.log("Hello");
+console.log("World");
*/

/* 出力例
{
  "README.md": `diff --git a/README.md b/README.md
index e69de29..a3c3f4d 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,2 @@
+# Sample Project
+This is a sample project.`,

  "src/main.ts": `diff --git a/src/main.ts b/src/main.ts
index 1234567..89abcde 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,4 +1,5 @@
 console.log("Hello");
+console.log("World");`
}
*/

export function parseDiff(diffText: string): Record<string, string> {
  const fileDiffs: Record<string, string> = {};
  const diffFileMarker = "diff --git a/";
  const markerLength = diffFileMarker.length;

  // diffテキストから効率的にファイル境界を検出
  let startPos = 0;
  let currentFile: string | null = null;
  let nextDiffPos = diffText.indexOf(diffFileMarker);

  while (nextDiffPos !== -1) {
    // 前のファイルの内容を保存
    if (currentFile !== null) {
      fileDiffs[currentFile] = diffText.substring(startPos, nextDiffPos);
    }

    // 現在の行の終わりを探して、ファイル名を抽出
    const lineEndPos = diffText.indexOf("\n", nextDiffPos);
    if (lineEndPos === -1) break; // 行の終わりが見つからない場合

    const fileLine = diffText.substring(nextDiffPos, lineEndPos);
    const bPos = fileLine.indexOf(" b/");

    if (bPos !== -1) {
      currentFile = fileLine.substring(markerLength, bPos);
      startPos = nextDiffPos;
    } else {
      currentFile = null;
    }

    // 次のdiffマーカーを検索
    nextDiffPos = diffText.indexOf(diffFileMarker, lineEndPos);
  }

  // 最後のファイルの内容を保存
  if (currentFile !== null) {
    fileDiffs[currentFile] = diffText.substring(startPos);
  }

  return fileDiffs;
}

.github/review/utils/types.ts

import { z } from "npm:zod";

const dependencySchema = z.object({
  resolved: z.string(),
});

const moduleDataSchema = z.object({
  source: z.string(),
  dependencies: z.array(dependencySchema),
});

export const rowJsonSchema = z.object({
  modules: z.array(moduleDataSchema),
});

.github/workflows/ai-review.yml

name: AI Review

on:
  pull_request:
    types: [opened, synchronize]

permissions:
  contents: write
  pull-requests: write

jobs:
  ai-review:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup Deno
        uses: denoland/setup-deno@v1
        with:
          deno-version: v2.2.6

      - name: Run AI Review
        env:
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          deno run --allow-read --deny-write --allow-env --deny-run --config=.github/review/deno.json --allow-net=github.com,deno.land,generativelanguage.googleapis.com,api.github.com:443 .github/review/main.ts

使い方

Dependency cruiserのインストール

  • 依存関係を表すファイルの取得には、Dependency cruiserを使います。
  • 以下のようにセットアップします。
npm install --save-dev dependency-cruiser
npx depcruise --init

コミット

  • コミットの前に以下のコマンドを実行します。(huskeyで自動実行することをお勧めします)
npx depcruise --config .dependency-cruiser.js . --output-type json --include-only "^src" > dependencies.json
  • ↑↑により、dependencies.jsonに依存関係を表すファイルが保存されます。最後にpushし、プルリクを出せばレビューしてくれます。

他の活用方法

  • コアのアイディアは、依存関係を全てプロンプトに入れるということです。(geminiのコンテキストウィンドウに感謝)これを活用すると、以下のような使い方もできるかもしれません。
    • 上のコードで取得したプロンプトにおいて、依存関係の部分のみを切り出し、AIエディタに入力すると、全体的な流れを考慮してコードを書いてくれるかも? denoで書いているので、cliツールへの転用はわりとすぐできると思います。
    • コードレビューをさせるのではなく、変更内容の要約をさせる。人間のレビュワーが変更内容をざっと把握することができます。
    • 対話機能。現状はプルリクエストイベントのみをトリガーしていますが、botを作成するのもおもしろいかもしれません。
    • 最近、.mdcファイルを使ってコーディング規約を規定する例が増えてきていますが、ちゃんと規約が適用されているか調べることもできるかもしれません。

Discussion