😊

プルリク作成や更新時にMarkdown記事のファイル名をLLMで自動リネームするGitHub Actionsを作った

2024/11/05に公開

Azure で ChatGPT / LLM システムの構築を勉強しようと考えています。せっかくなので、その過程を Zenn に記録しようと思いました。そこで、VS Code の拡張機能「Zenn Preview for GitHub.dev」を導入してみました。

この拡張機能では、ファイル名(slug)がランダムな文字列になってしまいます。そのため、VS Code のエクスプローラーでは記事が作成順にソートされません。そこで、タイムスタンプでソートするようにしました。

slugはランダム文字列
ランダム文字列のslugだと記事が作成順にソートされない

日付でソートする
タイムスタンプでソートする

Zenn ではデフォルトブランチに push すると記事が反映されるため、プルリクエスト時に自動でリネームするようにしました。

さらに、ファイル名を考える手間を省くために、LLM を使ってタイトルを生成し、ファイル名をリネームする GitHub Actions を作成しました。

今回は、この GitHub Actions の設定方法についてご紹介します。

概要

  • プルリクエスト時に、LLM を使ってタイトルを生成し、ファイル名をリネームする GitHub Actions を作成します。
  • ファイル名は「YYYYMMDD_{title}.md」とします。
  • プルリクエスト時に Python スクリプトを実行し、LLM でタイトルを生成します。
  • 生成したタイトルを使って、記事のファイル名をリネームします。
  • プルリクエストをマージすると、Zenn へ記事が自動で反映されます。

処理の流れ

  1. プルリクエストが作成または更新されると、GitHub Actions がトリガーされます。
  2. ワークフロー内で Python スクリプトを使用して、articles ディレクトリ内の新規追加または変更された Markdown ファイルを対象にリネームを行います。
  3. 変更されたファイルがある場合のみ、それをコミットし、プルリクエストのブランチにプッシュします。

前提条件

  • 開発には Python の実行環境が必要です。
  • OpenAI の API キーを取得しておく必要があります。API キーは OpenAI プラットフォームのページで取得できます。

必要なファイルとディレクトリ構成

.
├── articles/
│   └── *.md  # 記事のMarkdownファイル
├── .github/
│   └── workflows/
│       ├── rename_articles.yml  # GitHub Actionsのワークフローファイル
│       └── scripts/
│           ├── generate_filename.py  # ファイル名を生成するPythonスクリプト
│           ├── openai_client_factory.py # OpenAIクライアントを作成するクラス
│           └── requirements.txt  # 必要なPythonパッケージを記述したファイル

articles/: 記事の Markdown ファイルを保存するディレクトリ。

.github/workflows/rename_articles.yml: GitHub Actions のワークフローファイル。

.github/workflows/scripts/generate_filename.py: 記事の内容に基づいてファイル名を生成する Python スクリプト。

.github/workflows/scripts/requirements.txt: Python スクリプトの依存関係を定義したファイル。

実装手順

requirements.txtの作成

まず、Python スクリプトで使用するパッケージを定義した .github/workflows/scripts/requirements.txt を作成します。

.github/workflows/scripts/requirements.txt
openai
python-dotenv
  • openai: OpenAI API を利用するためのライブラリ。
  • python-dotenv: 開発時に .env ファイルを読み込むために使用します。

requirements.txt ファイルを作成後、次のコマンドで必要なパッケージをインストールします。

pip install -r .github/workflows/scripts/requirements.txt

OpenAIクライアントを作成するクラスの作成

次に、OpenAI API を使用するためのクラスを作成します。

OpenAI クライアントを作成するクラス
scripts/openai_client_factory.py
import logging
import os

import openai
from dotenv import load_dotenv

# 環境変数をロード
load_dotenv()


# ロギングの設定
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger()


class OpenAIClientFactory:
    @staticmethod
    def create_client():
        # APIキーを取得
        api_key = os.getenv("OPENAI_API_KEY")

        # APIキーが存在しない場合はエラーを出力
        if not api_key:
            logger.error("OpenAI API key is missing.")
            raise ValueError("OpenAI API key is required.")

        # APIキーを設定
        openai.api_key = api_key
        return openai

このクラスは、OpenAI API を使用するためのクライアントを作成します。環境変数から API キーを取得し、クライアントを作成します。

今後、OpenAI を利用したスクリプトを作成することで、重複処理を避けることを目的としています。

LLMでファイル名を生成するPythonスクリプトの作成

次に、記事の内容から適切なファイル名を生成する Python スクリプトを作成します。

LLM でファイル名を生成する Python スクリプト
scripts/generate_filename.py
import datetime
import os
import re
from dotenv import load_dotenv
from openai import OpenAI
from openai.types.chat import ChatCompletion
from openai_client_factory import OpenAIClientFactory  # 上で定義したクラスをインポート

# 環境変数をロード
load_dotenv()

def generate_filename(client: OpenAI, article_content, model="gpt-4o-mini"):
    # 記事内容と現在のファイル名をもとに、必要なら新しいファイル名を生成
    prompt = (
        "以下のMarkdown記事の内容と現在のファイル名に基づいて、適切なファイル名を生成してください。"
        "ルール:\n"
        "1) 半角英小文字(a-z)、半角数字(0-9)、ハイフン(-)、アンダースコア(_)のみを使用すること。\n"
        "2) 長さは12〜40文字。\n"
        "3) ファイル名は記事の主題やテーマを反映するものにする。\n\n"
        "4) 出力はファイル名のみとし、拡張子は含めないでください。\n\n"
        f"記事内容:\n{article_content}\n\nファイル名:"
    )
    messages = [{"role": "user", "content": prompt}]
    response: ChatCompletion = client.chat.completions.create(
        model=model,
        messages=messages,
        max_tokens=10,
        temperature=0.0,
    )
    filename = response.choices[0].message.content.strip()
    return filename

if __name__ == "__main__":
    import sys

    # ファイルパスをコマンドライン引数から取得
    if len(sys.argv) < 2:
        print("Usage: python generate_filename.py <file_path>")
        sys.exit(1)

    file_path = sys.argv[1]

    # ファイルの存在確認
    if not os.path.isfile(file_path):
        print(f"Error: File '{file_path}' not found.")
        sys.exit(1)

    # OpenAIクライアントを作成
    client: OpenAI = OpenAIClientFactory.create_client()

    # ファイル内容を読み込む
    with open(file_path, "r", encoding="utf-8") as f:
        article_content = f.read()

    # 新しいファイル名を生成
    date_prefix = datetime.datetime.now().strftime("%Y%m%d")
    new_filename_base = generate_filename(client, article_content)

    new_filename = f"{date_prefix}_{new_filename_base}.md"

    # 新しいファイル名を出力
    print(new_filename, end="")

この Python スクリプトは OpenAI の API を使用しており、次のルールに基づいてファイル名を生成します:

  • 使用文字: 半角英小文字(a-z)、半角数字(0-9)、ハイフン(-)、アンダースコア(_)のみを使用。

  • 長さ: ファイル名は 12〜40 文字に制限します。本来はZennのslugは50文字までですが、日付のプレフィックスを含めるために短くしています。

  • テーマの反映: 記事の主題やテーマに基づいた、わかりやすいファイル名を生成。

このルールは、Zenn 公式によるガイドラインに従っています。

GitHub Actionsのワークフロー設定

GitHub Actions Secretsの設定

今回のワークフローでは、OpenAI API キーを GitHub Actions Secrets に保存して使用します。

  1. GitHub リポジトリの「Settings」を開き、「Secrets and Variables」セクションの「Actions」を選択します。
  2. 「New repository secret」をクリックして、新しいシークレット「OPENAI_API_KEY」を追加します。

OpenAI APIキーをGitHub Actions Secretsに保存
OpenAI APIキーをGitHub Actions Secretsに保存

GitHub Actionsワークフローの作成

次に、GitHub Actions のワークフローを作成します。

.github/workflows/rename_articles.yml を作成し、次の内容を記述します。

GitHub Actions ワークフローの設定ファイル
.github/workflows/rename_articles.yml
name: Rename Articles on Pull Request

on:
  pull_request:
    types: [opened, synchronize]

permissions:
  contents: write
  pull-requests: write

# 実行中のジョブをキャンセルする
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  rename-articles:
    runs-on: ubuntu-latest

    steps:
    - name: リポジトリのチェックアウト
      uses: actions/checkout@v4
      with:
        fetch-depth: 0  # 全ての履歴を取得

    - name: Pythonのセットアップ
      uses: actions/setup-python@v5
      with:
        python-version: '3.x'
        cache: 'pip'

    - name: 依存関係のインストール
      run: |
        python -m pip install --upgrade pip
        pip install -r .github/workflows/scripts/requirements.txt

    - name: 新規追加された.mdファイルのリネーム
      id: rename_files  # ステップのID
      env:
        OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
      run: |
        # 現在のブランチで新規追加されたファイルを取得
        files=$(git diff --diff-filter=A --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} | grep '^articles/.*\.md$' || true)
        echo "新規追加または変更されたファイル:"
        echo "$files"
        
        # ファイル名変更があったかを判定するフラグ
        CHANGES_MADE=false
        
        # 各新規ファイルに対してリネームを実行
        IFS=$'\n'
        for file in $files; do
          if [ -n "$file" ]; then
            echo "処理中のファイル: '$file'"
            # ファイル名生成スクリプトを実行して新しい名前を取得
            new_name=$(python .github/workflows/scripts/generate_filename.py "$file" | tr -d '\n\r' | xargs)
            
            # 元のファイル名と異なる場合のみリネームを実行
            if [ "$file" != "articles/$new_name" ]; then
              echo "ファイル '$file' を 'articles/$new_name' にリネームします"
              mv "$file" "articles/$new_name"
              CHANGES_MADE=true  # 変更があったことを記録
            else
              echo "ファイル名変更なし: '$file'"
            fi
          fi
        done
        
        # 変更があったかどうかを環境変数として設定
        echo "CHANGES_MADE=$CHANGES_MADE" >> $GITHUB_OUTPUT

    - name: 変更があった場合のみコミットとプッシュを実行
      if: steps.rename_files.outputs.CHANGES_MADE == 'true'
      run: |
        git config --global user.name 'github-actions[bot]'
        git config --global user.email 'github-actions[bot]@users.noreply.github.com'
        git add -A articles
        git commit -m "Rename articles on pull request"
        git push origin HEAD:${{ github.head_ref }}

  action-timeline:
    if: always()
    name: ワークフローの実行時間を表示
    needs: rename-articles
    runs-on: ubuntu-latest
    permissions:
      actions: read
    steps:
      - uses: Kesin11/actions-timeline@v2

ワークフロー全体の流れ

この GitHub Actions ワークフローは、次の手順で動作します:

このワークフローは、Markdown 記事のファイル名をプルリクエスト時に LLM を使って自動リネームするためのものです。具体的な動作は次のようになります:

  1. プルリクエストイベントのトリガー

    • プルリクエストが作成された時 (opened)、または更新された時 (synchronize) にワークフローが実行されます。
    • イベントによりワークフローがトリガーされ、GitHub Actions が起動します。
  2. ジョブ設定とリポジトリのチェックアウト

    • rename-articles というジョブ名で、ubuntu-latest という環境で実行されます。
    • 最初に actions/checkout@v4 を利用して、Git リポジトリ全体をチェックアウトします。これにより、ワークフローはローカルでリポジトリの内容にアクセスできるようになります。
    • fetch-depth0 に設定して全てのコミット履歴を取得するようにしています。これは差分を正しく取得するためです。
  3. Pythonのセットアップと依存関係のインストール

    • actions/setup-python@v5 を使って Python 3.x をセットアップします。cache: 'pip' を指定することで、依存関係のキャッシュを利用し、インストール時間を短縮します。

    • その後、.github/workflows/scripts/requirements.txt に記述された依存パッケージをインストールします。

    • 具体的には、次のコマンドを使っています:

      python -m pip install --upgrade pip
      pip install -r .github/workflows/scripts/requirements.txt
      
    • pip を最新バージョンにアップグレードすることで、依存関係の管理をスムーズに行います。

  4. Markdownファイルのリネーム処理

    • このステップでは、新しく追加された、または変更された .md ファイルを検索し、それらのファイルに対してリネームを行います。

    • git diff コマンドを使用して、プルリクエストのベースブランチと現在のブランチの差分を取得し、変更された Markdown ファイルをリストアップします。

    • 具体的には次のコマンドを使っています:

      files=$(git diff --diff-filter=ACM --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} | grep '^articles/.*\.md$' || true)
      
    • --diff-filter=ACM オプションで、追加 (A)されたファイルのみを対象としています。

    • 各ファイルに対して、generate_filename.py スクリプトを実行し、新しいファイル名を生成します。この際、ファイル名に変更があった場合のみ mv コマンドでリネームします。

  5. コミットとプッシュ

    • リネームによりファイル名に変更があった場合のみ、変更内容をコミットしてプルリクエストのブランチにプッシュします。

    • コミットメッセージは "Rename articles on pull request" としており、自動的に変更が行われたことを明確に示します。

    • 具体的なコマンドは次の通りです:

      git config --global user.name 'github-actions[bot]'
      git config --global user.email 'github-actions[bot]@users.noreply.github.com'
      git add -A articles
      git commit -m "Rename articles on pull request"
      git push origin HEAD:${{ github.head_ref }}
      
    • git config でコミットのユーザー名とメールアドレスを設定し、GitHub Actions のボットアカウントとしてコミットを行います。

  6. ワークフローの実行時間の表示

    • 最後に今回の処理には直接関係はありませんが、Kesin11/actions-timeline (GitHub) を使って、ワークフローの実行時間を表示しています。

特徴的なポイント

  • Concurreny設定: concurrency セクションにより、同じブランチで複数のジョブが同時に実行されないようにしています。これにより、同じブランチに対して変更が衝突するリスクを低減します。
  • 条件付きコミット: 変更があった場合のみコミットを行うことで、無駄なコミットを防いでいます。
  • ベースコミットの取得: git fetch によりベースブランチの情報を取得することで、差分を正確に計算し、プルリクエスト内での新規・変更ファイルを的確にリネーム対象としています。

ワークフローのポイントまとめ

  • プルリクエストが作成・更新されるたびにトリガーされ、ファイル名を自動でリネーム。
  • OpenAI の API を使ってファイル名を生成する Python スクリプトを実行。
  • 変更があった場合のみ、コミットしてブランチにプッシュすることで、リポジトリを整理。

このワークフローにより、プルリクエストの際にファイル名を自動で整えることができ、効率的かつ一貫性のあるファイル管理が可能になります。特に、ファイル名に人為的なミスが生じることを防ぎ、手作業でのリネームの手間を省くことができます。

リネームの際、すでに投稿済みの slug を変更すると、別の記事として認識されてしまうため注意が必要です。

ワークフローの実行

これで、GitHub Actions のワークフローが設定されました。プルリクエストを作成または更新すると、ワークフローがトリガーされ、記事のファイル名が自動でリネームされます。

プルリクエストを作成して、ワークフローが正常に動作するか確認してみましょう。

以下は、この記事に対してワークフローを実行した際のログです。適切にファイル名が生成され、リネームされていることが確認できます。

新規追加または変更されたファイル:
articles/ca3e404c87192e.md
処理中のファイル: 'articles/ca3e404c87192e.md'
INFO:httpx:HTTP Request: POST https://api.openai.com/v1/chat/completions "HTTP/1.1 200 OK"
ファイル 'articles/ca3e404c87192e.md' を 'articles/20241028_pr-rename-articles-llm.md' にリネームします

まとめ

今回は、プルリクエスト時に Markdown 記事のファイル名を LLM で自動でリネームする GitHub Actions を作成しました。

今回のファイル名変更程度であれば先に自分で考えて変更する方が早いかもしれませんが、プルリクエスト時の LLM による生成は様々な応用の可能性があると思います。

Discussion