🐷

GitHub Organizationのレポジトリからllms.txtを作成する

に公開

llms.txtとは?

AIが処理しやすい形にサイトの構造情報をまとめたファイルです。AIクローラーに参照させることでサイトへの負荷を軽減します。
フォーマットは以下のような形です。(そのほか、サポート言語やRate Limitなどの情報を含めることもあります。)

# Title

> Optional description goes here

Optional details go here

## Section name

- [Link title](https://link_url): Optional link details

## Optional

- [Link title](https://link_url)

参考:
https://zenn.dev/watany/articles/0b28a68a2dffc3
https://dev.classmethod.jp/articles/llms-txt-for-ai-crawlers/

サイトの負荷軽減だけではなく、情報検索の効率化にも応用できる可能性があります。
こちらの記事では、RAGとllms.txtを使ったコード生成タスクの性能を比較しています。結果、llms.txtをうまく作ることで、コード生成タスクの性能を向上させられる可能性を示しています。

やりたいこと

GitHub Organizationのレポジトリからllms.txtを作成します。
これによって、社内資産を活用したコード生成を目指します。

準備

GitHub Organizationの設定

今回は個人のレポジトリではなく、Organizationのレポジトリを対象にします。(とはいっても、個人でもほとんど同じです。)
今回は、FreeプランのOrganizationを作成して実験しました。

Personal Access Tokenの作成

GitHubのリソースを編集するため、Personal Access Token(PAT)を作成します。
今回は検証用に多めに権限を付与します。ご利用の環境に応じて、適宜権限を調整してください。
GitHubにアクセスし、右上のアイコンから、Settings -> Developer settings -> Personal access tokens -> Fine-grained tokens -> Generate new tokenを選択します。
権限は以下のように設定しました。
Repository Access: All Repositories
Repository Permissions:

  • Actions: Read and Write
  • Commit statuses: Read and Write
  • Contents: Read and Write
  • Environments: Read-Only
  • Issues: Read and Write
  • Pull requests: Read and Write
  • Secrets: Read-Only
  • Variables: Read-only

llms.txt管理レポジトリの設定

今回はllms.txtを一つのレポジトリに集約させます。そのための管理レポジトリを作成しました。これはPublicでもPrivateでも構いません。

ツールのインストール

今回はTypeScriptを使用します。必要なライブラリをインストールしましょう。

npm install dotenv axios @langchain/core @langchain/openai

実装

以下の処理を実装します。

  1. 指定されたOrganizationの全レポジトリを取得する。
  2. 各レポジトリのファイル一覧を取得する。
  3. 各ファイルの内容(ファイルコンテンツ)を取得する。
  4. ファイルコンテンツをもとにllms.txtを作成する。
  5. llms.txtを指定のレポジトリにpushする。

0. 環境変数の設定

GitHub API、OpenAIのAPIキーおよびOrganization・レポジトリを環境変数で設定します。.envを作成して以下のように設定します。

GITHUB_TOKEN=<GitHubのPersonal Access Tokenを指定>
ORGANIZATION=<GitHub Organization名を指定>
GITHUB_MAIN_REPOSITORY=<手順5で使用する、llms.txt管理レポジトリ名を指定>
OPENAI_API_KEY=<OpenAIのAPIキーを指定>
MODEL_NAME=<使用するOpenAIのモデル名を指定>

1. 指定されたOrganizationの全レポジトリを取得する。

GitHub APIを使用して、指定されたOrganizationの全レポジトリを取得します。

// Organizationのリポジトリ一覧を取得
async function listOrganizationRepositories(organization: string) {
  // GITHUB_TOKEN を環境変数から取得
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    throw new Error('GITHUB_TOKEN 環境変数が設定されていません。');
  }
  try {
    // GET /orgs/{org}/repos エンドポイントを呼び出して organization のリポジトリ一覧を取得する
    const response = await axios.get(`https://api.github.com/orgs/${organization}/repos`, {
      headers: {
        'Authorization': `token ${token}`,
        'Accept': 'application/vnd.github+json'
      }
    });
    return response.data;
  } catch (error) {
    console.error('Organization のリポジトリ一覧の取得に失敗しました:', error);
  }
}

2. 各レポジトリのファイル一覧を取得する。

1で取得したレポジトリ内のファイル一覧を取得します。

async function listRepositoryContentsRecursive(organization: string, repository: string, path: string="", aggregatedFiles: any[]=[]): Promise<any[]> {
  // GITHUB_TOKEN を環境変数から取得
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    throw new Error('GITHUB_TOKEN 環境変数が設定されていません。');
  }
  try {
    const response = await axios.get(`https://api.github.com/repos/${organization}/${repository}/contents/${path}`, {
      headers: {
        'Authorization': `token ${token}`,
        'Accept': 'application/vnd.github+json'
      }
    });
    if (Array.isArray(response.data)) {
      for (const item of response.data) {
        if (item.type === 'file') {
          aggregatedFiles.push(item);
        } else if (item.type === 'dir') {
          // For directories, recursively fetch its contents and filter for files only
          aggregatedFiles = await listRepositoryContentsRecursive(organization, repository, item.path, aggregatedFiles);
        }
      }
    }
    return aggregatedFiles;
  } catch (error) {
    console.error('リポジトリのコンテンツ一覧の取得に失敗しました:', error);
    return aggregatedFiles;
  }
}

3. 各ファイルの内容(ファイルコンテンツ)を取得する。

2で取得したファイル一覧から、ファイルコンテンツを取得します。

async function getFileContent(organization: string, repository: string, path: string) {
  // GITHUB_TOKEN を環境変数から取得
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    throw new Error('GITHUB_TOKEN 環境変数が設定されていません。');
  }
  try {
    const response = await axios.get(`https://api.github.com/repos/${organization}/${repository}/contents/${path}`, {
      headers: {
        'Authorization': `token ${token}`,
        'Accept': 'application/vnd.github+json'
      }
    });
    return response.data;
  } catch (error) {
    console.error('ファイルの内容の取得に失敗しました:', error);
    return null;
  }
}

4. ファイルコンテンツをもとにllms.txtを作成する。

生成AIを用いて、ファイルコンテンツからllms.txtを作成します。

async function createLLMsTxt(llm: BaseChatModel, repository_name: string, repository_url: string, file_contents: string): Promise<string> {
    const prompt = new PromptTemplate({
        template: PROMPT_TEMPLATE,
        inputVariables: ['repository_name', 'repository_url', 'file_contents'],
    });
    const chain = prompt.pipe(llm);
    const result = await chain.invoke({
        repository_name: repository_name,
        repository_url: repository_url,
        file_contents: file_contents,
    });
    const result_str = result.content as string;
    const result_match = result_str.match(/<output>\n*([\s\S]*?)\n*<\/output>/);
    if (result_match) {
        return result_match[1];
    } else {
        return result_str;
    }
}

5. llms.txtを指定のレポジトリにpushする。

最後に、4で作成したllms.txtを指定のレポジトリにpushします。
レポジトリへのpushは以下の手順で行います。

  1. 現在のコミット情報を取得
  2. 新しいブランチを作成
  3. 作成したllms.txtを<repository_name>/llms.txtにpush
  4. プルリクエストを作成

5.1 現在のコミット情報を取得

async function getLatestCommitSha(organization: string, repository: string, branch: string): Promise<string> {
  // 環境変数からトークンを取得
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    throw new Error('GITHUB_TOKEN 環境変数が設定されていません。');
  }
  try {
    const response = await axios.get(`https://api.github.com/repos/${organization}/${repository}/git/ref/heads/${branch}`, {
      headers: {
        'Authorization': `token ${token}`,
        'Accept': 'application/vnd.github+json'
      }
    });
    return response.data.object.sha;
  } catch (error: any) {
    console.error('An error occurred:', error.response?.data || error.message);
    throw error;
  }
}

5.2 新しいブランチを作成

async function createBranch(organization: string, repository: string, newBranch: string, sha: string): Promise<void> {
  // 環境変数からトークンを取得
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    throw new Error('GITHUB_TOKEN 環境変数が設定されていません。');
  }
  try {
    await axios.post(`https://api.github.com/repos/${organization}/${repository}/git/refs`, {
      ref: `refs/heads/${newBranch}`,
      sha,
    }, {
      headers: {
        'Authorization': `token ${token}`,
        'Accept': 'application/vnd.github+json'
      }
    });
  } catch (error: any) {
    console.error('An error occurred:', error.response?.data || error.message);
    throw error;
  }
}

5.3 作成したllms.txtを<repository_name>/llms.txtにpush

async function createFile(organization: string, repository: string, branch: string, filePath: string, content: string, message: string): Promise<void> {
  // 環境変数からトークンを取得
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    throw new Error('GITHUB_TOKEN 環境変数が設定されていません。');
  }
  try {
    const base64Content = Buffer.from(content).toString('base64');
    await axios.put(`https://api.github.com/repos/${organization}/${repository}/contents/${filePath}`, {
      message,
      content: base64Content,
      branch,
    }, {
      headers: {
        'Authorization': `token ${token}`,
        'Accept': 'application/vnd.github+json'
      }
    });
  } catch (error: any) {
    console.error('An error occurred:', error.response?.data || error.message);
    throw error;
  }
}

5.4 プルリクエストを作成

async function createPullRequest(organization: string, repository: string, title: string, body: string, head: string, base: string): Promise<void> {
  // 環境変数からトークンを取得
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    throw new Error('GITHUB_TOKEN 環境変数が設定されていません。');
  }
  try {
    await axios.post(`https://api.github.com/repos/${organization}/${repository}/pulls`, {
      title,
      body,
      head,
      base,
    }, {
      headers: {
        'Authorization': `token ${token}`,
        'Accept': 'application/vnd.github+json'
      }
    });
  } catch (error: any) {
    console.error('An error occurred:', error.response?.data || error.message);
    throw error;
  }
}

実行

最後に作成したファイルを実行します。(コード全文は最後に掲載します。)

npx tsx create_llms_txt.ts

結果、llms.txt管理用レポジトリに、各レポジトリ名のフォルダが作成され、その中に該当レポジトリのllms.txtが作成されます。

まとめ

今回は、GitHub Organizationのレポジトリからllms.txtを作成する方法を紹介しました。
生成AIを利用して作成するため、RAGよりも検索データの作成が容易です。
今後、llms.txtを更新する方法や、コード検索に使用する方法も調査していきたいと思います。
また今回は通常のフォーマットでllms.txtを作成しましたが、llmstxt_architectを使うとより精度の高いllms.txtを作成できるかもしれません。(Vibe Codeの記事では、llmstxt_architectを使ってllms.txtを作成しているようです。)

コード全文

import "dotenv/config";
import axios from 'axios';
import { ChatOpenAI } from '@langchain/openai';
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { PromptTemplate } from '@langchain/core/prompts';


const PROMPT_TEMPLATE = `レポジトリ情報をもとに以下の形式でllms.txtを出力してください。

レポジトリ情報:
\`\`\`\`
レポジトリ名: {repository_name}
レポジトリURL: {repository_url}

レポジトリ内のファイル内容:
{file_contents}
\`\`\`\`

llms.txtの出力形式:
以下のように<output>タグ内に必要な情報を記載してください。
<output>
# レポジトリ名[レポジトリURL]

> プロジェクト概要説明
 
プロジェクトの詳細説明(500文字以内で記載)

## ファイル一覧
- ファイル名1[ファイルパス1]: ファイル1の概要説明(300文字以内で記載)
- ファイル名2[ファイルパス2]: ファイル2の概要説明(300文字以内で記載)
...
</output>

それではタスクを開始してください。
`;

// Organizationのリポジトリ一覧を取得
async function listOrganizationRepositories(organization: string) {
  // GITHUB_TOKEN を環境変数から取得
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    throw new Error('GITHUB_TOKEN 環境変数が設定されていません。');
  }
  try {
    const response = await axios.get(`https://api.github.com/orgs/${organization}/repos`, {
      headers: {
        'Authorization': `token ${token}`,
        'Accept': 'application/vnd.github+json'
      }
    });
    return response.data;
  } catch (error) {
    console.error('Organization のリポジトリ一覧の取得に失敗しました:', error);
  }
}

async function listRepositoryContentsRecursive(organization: string, repository: string, path: string="", aggregatedFiles: any[]=[]): Promise<any[]> {
  // GITHUB_TOKEN を環境変数から取得
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    throw new Error('GITHUB_TOKEN 環境変数が設定されていません。');
  }
  try {
    const response = await axios.get(`https://api.github.com/repos/${organization}/${repository}/contents/${path}`, {
      headers: {
        'Authorization': `token ${token}`,
        'Accept': 'application/vnd.github+json'
      }
    });
    if (Array.isArray(response.data)) {
      for (const item of response.data) {
        if (item.type === 'file') {
          aggregatedFiles.push(item);
        } else if (item.type === 'dir') {
          // For directories, recursively fetch its contents and filter for files only
          aggregatedFiles = await listRepositoryContentsRecursive(organization, repository, item.path, aggregatedFiles);
        }
      }
    }
    return aggregatedFiles;
  } catch (error) {
    console.error('リポジトリのコンテンツ一覧の取得に失敗しました:', error);
    return aggregatedFiles;
  }
}

async function getFileContent(organization: string, repository: string, path: string) {
  // GITHUB_TOKEN を環境変数から取得
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    throw new Error('GITHUB_TOKEN 環境変数が設定されていません。');
  }
  try {
    const response = await axios.get(`https://api.github.com/repos/${organization}/${repository}/contents/${path}`, {
      headers: {
        'Authorization': `token ${token}`,
        'Accept': 'application/vnd.github+json'
      }
    });
    return response.data;
  } catch (error) {
    console.error('ファイルの内容の取得に失敗しました:', error);
    return null;
  }
}

async function encodingFileContent(content: any) {
  return Buffer.from(content.content, content.encoding).toString('utf-8');
}

async function createLLMsTxt(llm: BaseChatModel, repository_name: string, repository_url: string, file_contents: string): Promise<string> {
    const prompt = new PromptTemplate({
        template: PROMPT_TEMPLATE,
        inputVariables: ['repository_name', 'repository_url', 'file_contents'],
    });
    const chain = prompt.pipe(llm);
    const result = await chain.invoke({
        repository_name: repository_name,
        repository_url: repository_url,
        file_contents: file_contents,
    });
    const result_str = result.content as string;
    const result_match = result_str.match(/<output>\n*([\s\S]*?)\n*<\/output>/);
    if (result_match) {
        return result_match[1];
    } else {
        return result_str;
    }
}

async function getLatestCommitSha(organization: string, repository: string, branch: string): Promise<string> {
  // 環境変数からトークンを取得
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    throw new Error('GITHUB_TOKEN 環境変数が設定されていません。');
  }
  try {
    const response = await axios.get(`https://api.github.com/repos/${organization}/${repository}/git/ref/heads/${branch}`, {
      headers: {
        'Authorization': `token ${token}`,
        'Accept': 'application/vnd.github+json'
      }
    });
    return response.data.object.sha;
  } catch (error: any) {
    console.error('An error occurred:', error.response?.data || error.message);
    throw error;
  }
}

async function createBranch(organization: string, repository: string, newBranch: string, sha: string): Promise<void> {
  // 環境変数からトークンを取得
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    throw new Error('GITHUB_TOKEN 環境変数が設定されていません。');
  }
  try {
    await axios.post(`https://api.github.com/repos/${organization}/${repository}/git/refs`, {
      ref: `refs/heads/${newBranch}`,
      sha,
    }, {
      headers: {
        'Authorization': `token ${token}`,
        'Accept': 'application/vnd.github+json'
      }
    });
  } catch (error: any) {
    console.error('An error occurred:', error.response?.data || error.message);
    throw error;
  }
}

async function createFile(organization: string, repository: string, branch: string, filePath: string, content: string, message: string): Promise<void> {
  // 環境変数からトークンを取得
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    throw new Error('GITHUB_TOKEN 環境変数が設定されていません。');
  }
  try {
    const base64Content = Buffer.from(content).toString('base64');
    await axios.put(`https://api.github.com/repos/${organization}/${repository}/contents/${filePath}`, {
      message,
      content: base64Content,
      branch,
    }, {
      headers: {
        'Authorization': `token ${token}`,
        'Accept': 'application/vnd.github+json'
      }
    });
  } catch (error: any) {
    console.error('An error occurred:', error.response?.data || error.message);
    throw error;
  }
}

async function createPullRequest(organization: string, repository: string, title: string, body: string, head: string, base: string): Promise<void> {
  // 環境変数からトークンを取得
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    throw new Error('GITHUB_TOKEN 環境変数が設定されていません。');
  }
  try {
    await axios.post(`https://api.github.com/repos/${organization}/${repository}/pulls`, {
      title,
      body,
      head,
      base,
    }, {
      headers: {
        'Authorization': `token ${token}`,
        'Accept': 'application/vnd.github+json'
      }
    });
  } catch (error: any) {
    console.error('An error occurred:', error.response?.data || error.message);
    throw error;
  }
}

async function main() {
    // Organization 名を指定(必要に応じて変更してください)
    const organization = process.env.GITHUB_ORGANIZATION;
    if (!organization) {
        throw new Error('GITHUB_ORGANIZATION 環境変数が設定されていません。');
    }
    try {
      const repos = await listOrganizationRepositories(organization);
      // 結果確認
      console.log(`Organization "${organization}" のリポジトリ一覧:`);
      repos.forEach((repo: any) => {
        console.log(`${repo.name}(${repo.full_name}) - ${repo.html_url}`);
      });
      console.log("--------------------------------");

      const llmsTxtsMap = new Map<string, string>();
      for (const repo of repos) {
        const contents = await listRepositoryContentsRecursive(organization, repo.name);
        // 結果確認
        contents.forEach((content: any) => {
            console.log(`[${content.type}]${content.name}(${content.path}) - ${content.html_url}`);
        });
        console.log("--------------------------------");

        const repository_name = repo.name;
        const repository_url = repo.html_url;
        let file_contents = "";
        for (const file of contents) {
            const content = await getFileContent(organization, repo.name, file.path);
            const content_str = await encodingFileContent(content);
            file_contents += (
                `ファイル名: ${file.name}\n`
                + `ファイルパス: ${file.path}\n`
                + `ファイル内容: \n\`\`\`\n${content_str}\n\`\`\`\n`
                + `\n`
            );
        }
        const llm = new ChatOpenAI({
            apiKey: process.env.OPENAI_API_KEY,
            model: process.env.MODEL_NAME,
        });
        const llmsTxt = await createLLMsTxt(llm, repository_name, repository_url, file_contents);
        console.log(llmsTxt);
        console.log("--------------------------------");
        llmsTxtsMap.set(repository_name, llmsTxt);
      }
      console.log(llmsTxtsMap);

      // ベースブランチ(main)の最新コミットSHAを取得
      const MAIN_REPOSITORY = process.env.GITHUB_MAIN_REPOSITORY;
      const now = new Date();
      const BASE_BRANCH = "main";
      const formattedDate = `${now.getFullYear()}${(now.getMonth() + 1).toString().padStart(2, '0')}${now.getDate().toString().padStart(2, '0')}-${now.getHours().toString().padStart(2, '0')}${now.getMinutes().toString().padStart(2, '0')}${now.getSeconds().toString().padStart(2, '0')}`;
      const NEW_BRANCH = `llms-txt-${formattedDate}`;
      if (!MAIN_REPOSITORY) {
        throw new Error('GITHUB_MAIN_REPOSITORY 環境変数が設定されていません。');
      }
      const baseSha = await getLatestCommitSha(organization, MAIN_REPOSITORY, BASE_BRANCH);
      console.log(`baseSha: ${baseSha}`);

      // 新しいブランチを作成
      await createBranch(organization, MAIN_REPOSITORY, NEW_BRANCH, baseSha);
      console.log(`newBranch: ${NEW_BRANCH}`);

      // 新しいフォルダ内にファイルを作成
      for (const [repository_name, llmsTxt] of llmsTxtsMap) {
        const filePath = `${repository_name}/llms.txt`;
        const COMMIT_MESSAGE = `Update llms.txt`;
        await createFile(organization, MAIN_REPOSITORY, NEW_BRANCH, filePath, llmsTxt, COMMIT_MESSAGE);
        console.log(`filePath: ${filePath}`);
      }

      // プルリクエストを作成
      const PR_TITLE = `Create first llms.txt`;
      const PR_BODY = `Create first llms.txt`;
      await createPullRequest(organization, MAIN_REPOSITORY, PR_TITLE, PR_BODY, NEW_BRANCH, BASE_BRANCH);
      console.log(`PR_TITLE: ${PR_TITLE}`);

      console.log('Pull request created successfully.');
    } catch (error: any) {
      console.error('An error occurred:', error.response?.data || error.message);
    }
}

// 実行
main();

Discussion