LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

LINEのアプリ開発を支えるコードオーナー管理

LINEヤフー Advent Calendar 2024の記事です。

はじめに

こんにちは、iOSアプリエンジニアの羽柴です。本記事では、LINEの膨大なソースコードを扱う上で欠かせない「コードオーナー」の設定率を向上させるため、GitHub Actionsとして実装したチェッカーツールについてご紹介します。

プロジェクトの背景

LINEは、2011年のリリース以来急成長を遂げて来たアプリです。その巨大なコードベースにおいて、特に古くからある機能などに、どのチームがオーナーシップを持っているのかわからないことがあります。これを解決するのがコードオーナーです。ファイルにコードオーナーが設定されていれば、そのコードに修正を加える際に迅速な議論や開発をすることができます。したがって、LINEプロジェクトにおけるコードオーナーの設定率向上を目指す"CODEOWNER Improvements"というプロジェクトが発足しました。このプロジェクトの取り組みの一つが、今回紹介する"Codemap Checker"です。

LINEのコードオーナー管理

LINEアプリでは、codemap.ymlというファイルでコードオーナーを管理しています。ファイルは次のような形式をしています:

codemap.yml

- owners:
    - "@hashiba-satsuki"
    - "@LINE-Client/official-account-dev-ios"
  path_patterns:
    - "PATH/TO/FILE/OfficialAccountViewController.swift"
    - "ANOTHER/PATH/TO/FILE/OfficialAccount/"
  // ...その他メタ情報
  • owners: オーナーシップを持つGitHubアカウントやユーザーグループ。横断的にオーナーシップを持つこともあるため、配列の形をとっています。
  • path_patterns: 該当のファイルを示すパス。ディレクトリ単位での指定も可能です。

GitHub標準のCODEOWNERファイルではなくcodemap.ymlにコードオーナーを書いている理由は以下の通りです:

  • YAMLフォーマットで記述しやすい
  • オーナー以外のメタ情報も同時に管理できる
  • その他社内ツールとのインテグレーションのため

なお、レビュワーの指定やGitHub上での確認を 可能にするため、CODEOWNERファイルはcodemap.ymlから自動的に生成しています。

このcodemap.ymlは各開発者が適宜更新しなければならず、更新を忘れてしまうという課題がありました。

既存のCodemap Checker

この更新漏れを防ぐために、Codemap Checkerという社内ツールが存在しています。これはGitHub webhookを使ったもので、プルリクエストで変更されたファイルのうちオーナーが設定されていないものをコメントしてくれるbotです。このチェッカーには次のような問題点がありました:

  • 結果が見づらい: 既存のチェッカーでは、オーナーの存在しないファイル名がただ羅列されるだけになっています。また、5つまでしかファイル名が表示されず、あとは省略される仕様でした。
  • 設定漏れ以外の警告がない: コードオーナーはディレクトリ単位でも指定できる一方で、ファイル追加やリネームの際に意図せず異なるコードオーナーが設定されてしまうケースもありました。現状のチェッカーでは、これに気付くことができません。
  • メンテナンス性の低さ: GitHub webhookを用いているためデバッグが煩雑であったり、プルリクエストのその他チェッカーと複合的に実装されているために変更が加えづらかったりして、メンテナンスが難しくなっていました。

既存のCodemap Checker

新しいCodemap Checker

こうした背景から、GitHub Actionsを用いて新しいCodemap Checkerを実装する取り組みが開始されました。

なぜGitHub Actionsを選んだのか

GitHub Webhookを利用するには、外部サーバーを用意する必要があり、これに伴うサーバーの管理やメンテナンスコストが発生します。さらに、イベントの受信ロジックを記述したり、エラーハンドリングを考慮したりする必要があるため、実装がより複雑になります。

一方で、GitHub ActionsはGitHubプラットフォームに統合されており、YAMLファイルを使って簡単に設定できます。また、アクションの実行状態は自動的に追跡され、UIで確認することもできます。こうした環境により、開発者はアクション自体の実装に集中することができます。

これらの理由から、私たちはGitHub Actionsを選択しました。

Codemap Checkerの要件

プルリクエストの作成時/更新時に、そのプルリクエスト上で変更されたファイルを取得し、それらに対してcodemap.ymlでコードオーナーが設定されているかを確認します。設定されていない場合にはエラーを出し、更新が必要な可能性がある場合には警告を出します。「更新が必要な可能性があるファイル」とは、「該当のプルリクエストにて追加もしくはリネームされたが、コードオーナーが変更されていないファイル」と定義しました。

実行結果はGitHub ActionsのJob Summariesに表示します。プルリクエストへのコメントも検討しましたが、現在GitHubからの通知が社内のメールストレージを圧迫しているという問題が発生しているため、Job Summariesに出力するのみとしました。

実行結果のサンプル

実装の概要

使用言語には、LINEアプリ向けツールチェーンの言語統一のためPythonを採用しました。Codemap Checkerは以下の図のような構成になっています:

Codemap Checkerの概要

主要なクラスは以下の通りです:

  • GitHubClient: GitHub APIとやりとりするためのクライアント。
  • CodemapProvider: codemap.ymlを読み込み、解析するプロバイダー。
  • CodeOwnerChecker: 変更されたファイルに対してコードオーナーの問題を検出するチェッカー。
  • CommentBuilder: チェック結果をGitHub Actionsのサマリーとして出力するためのビルダー。

また、以下が使用した外部モジュールです:

  • PyGithub: GitHub REST APIにアクセスできるPythonライブラリ。このモジュールを使用することで、リポジトリやプルリクエストの情報を簡単に取得できます。
  • PyYAML: YAMLファイルをPythonのデータ構造に変換するためのライブラリ。codemap.ymlの解析に使用しています。
  • pathspec: ファイルパスのパターンマッチングを行うためのライブラリ。codemap.ymlで指定されたパスパターンに基づいて、ファイルのコードオーナーを解決する際に使用します。

Tips: ChatGPTの活用

GitHub Actionsの開発もPythonの利用も今回がほぼ初めてですが、ChatGPTを使うことで効率的に開発を進めることができました。ChatGPTに尋ねた質問には以下のようなものです:

  • GitHub ActionsをDockerコンテナとして提供する場合、ディレクトリ構成はどのようにすればいいですか?
  • Pythonにおける単体テストの記述方法を教えてください。
  • Swiftのクロージャのように、Pythonで遅延評価をするには?

実装の詳細

1. 環境変数の取得

GitHub Actionsから提供される環境変数を取得します。これには、リポジトリ名、プルリクエスト番号、トークンなどが含まれます。

main.py

token = os.getenv("GITHUB_TOKEN")
repo_name = os.getenv("GITHUB_REPOSITORY")
pull_number = int(os.getenv("GITHUB_PR_NUMBER"))
ref = os.getenv("GITHUB_REF")
base_ref = os.getenv("GITHUB_BASE_REF")

tokenpull_numberはデフォルトの引数にないので、アクションの入力として受け取っています。後に記載するaction.ymlの定義を参照してください。

2. GitHubから情報を取得

GitHubClientクラスを使用して、プルリクエストで変更されたファイルとcodemap.ymlの内容を取得します。ブランチ間でのコードオーナーの差分を考慮するため、ベースブランチのcodemap.ymlも同様に取得します。

main.py

github = GitHubClient(config.HOSTNAME, token, repo_name, pull_number)
changed_files = github.get_changed_files()
codemap_provider = CodemapProvider(
    raw_codemap=github.get_content(config.CODEMAP_FILE_PATH, ref),
    raw_base_codemap=github.get_content(config.CODEMAP_FILE_PATH, base_ref)
)

3. codemap.ymlの解析

CodemapProvider内で、StringのYAMLデータを辞書型の配列に変換します。これは、PyYAMLのsafe_load関数を用いて行われます。

codemap_provider.py

class CodemapProvider:
    def build_codemap(self) -> list[dict]:
        return yaml.safe_load(self.raw_codemap)

変換された配列は以下のような形になります:

[
    [
        "owners": ["@xxx", "@yyy"],
        "path_patterns": ["PATH/TO/SOURCE/CODE.txt", "ANOTHER/PATH/"],
    ],
    // ...
]

4. コードオーナー情報の解決

続いてCodeOwnerResolverでコードオーナー情報を解決します。CodemapProviderから得られるpath_patternsは文字列のため、そのままではパターンマッチが煩雑になります。これを解決するために、CodeOwnerResolver内でpath_patternsからPathSpec型を生成します:

code_owner_resolver.py

@dataclass
class PathSpecCodemapEntry:
    owners: list[str]
    path_spec: PathSpec
    
class CodeOwnerResolver:
    @staticmethod
    def compile_path_spec_codemap(codemap: list[dict]) -> list[PathSpecCodemapEntry]:
        path_spec_codemap = []
        for entry in codemap:
            if "owners" not in entry or "path_patterns" not in entry:
                continue
            path_spec = PathSpec.from_lines(patterns.GitWildMatchPattern, entry["path_patterns"])
            path_spec_codemap.append(PathSpecCodemapEntry(entry["owners"], path_spec))
        return path_spec_codemap

コードオーナーを解決するロジックは以下の通りです:

code_owner_resolver.py

class CodeOwnerResolver:
    @staticmethod
    def resolve(
        files: list[ChangedFile],
        path_spec_codemap: list[PathSpecCodemapEntry],
        should_ignore: Callable[[ChangedFile], bool]
    ) -> list[CodeOwnerInfo]:
        code_owner_infos = []

        for file in files:
            if should_ignore(file):
                continue

            owners = []
            for entry in path_spec_codemap:
                if entry.path_spec.match_file(file.filename):
                    owners = entry.owners
                    break

            code_owner_infos.append(CodeOwnerInfo(file=file, owners=owners))

        return code_owner_infos

should_ignoreには、コードオーナーのチェックをスキップする条件を指定することができます。このCodemap Checkerでは、以下のような条件を設けています:

  • ファイルのステータスがREMOVEDの場合
  • ファイルの拡張子が画像や動画などリソースを表すものの場合

最後に、該当のプルリクエストでcodemap.ymlの変更があった場合は、変更前のcodemap.yml上でのコードオーナーを取得し、現在のものと異なっている場合にCodeOwnerInfois_updatedtrueにします。

5. 結果の生成・出力

CodeOwnerResolverが作ったCodeOwnerInfoの配列を元に、CodeOwnerChecker内でCodeOwnerIssues型としてチェックの結果を生成します。CodeOwnerIssuesが持つプロパティは次の2つです:

  • no_code_owners: 変更されたファイルの うち、コードオーナーが存在しないファイルのリスト
  • outdated_code_owners: 変更されたファイルのうち、追加・リネームされたもので、かつそれらのコードオーナーが該当プルリクエスト上で変更されていないファイルのリスト

code_owner_checker.py

return CodeOwnerIssues(
    no_code_owners=[info for info in code_owner_infos if not info.owners],
    outdated_code_owners=[
        info for info in code_owner_infos
        if info.file.status in {FileStatus.ADDED, FileStatus.RENAMED} and info.owners and not info.is_updated
    ]
)

CodeOwnerIssuesを元にCommentBuilderでMarkdown形式のコメントを生成し、GitHub Actionsのワークフローコマンドを用いて結果を出力します。コードオーナーがないファイルがある場合はアクションの実行結果を失敗とし、それ以外の場合は成功とします。また、同じ内容をGitHub ActionsのJob Summariesにも書き込みます。

main.py

summary_body = CommentBuilder.build_summary_body(code_owner_issues)
with open(os.getenv("GITHUB_STEP_SUMMARY"), "a") as summary_file:
    summary_file.write(summary_body)

if code_owner_issues.no_code_owners:
    print(f"::error:: {summary_body}")
    // アクションの実行結果を失敗にする
    sys.exit(1)
elif code_owner_issues.outdated_code_owners:
    print(f"::warning:: {summary_body}")
else:
    print(f"::notice:: {summary_body}")

Dockerコンテナアクションとして提供

Codemap CheckerはDockerコンテナアクションとして提供されています。依存関係の管理が容易であると同時に、どの環境でも一貫した動作を保証します。

Dockerfile

FROM python:3.9-alpine

WORKDIR /action/workspace
COPY requirements.txt *.py /action/workspace/

RUN pip install --no-cache-dir -r requirements.txt

ENTRYPOINT ["/action/workspace/main.py"]

また、action.ymlは以下のように定義されています:

action.yml

name: 'Check codemap'
description: 'Check if codemap is set for changed files in a PR'
inputs:
  GITHUB_TOKEN:
    description: "GitHub token used to fetch changed files from Github's API."
    required: false
    default: ${{ github.token }}
  GITHUB_PR_NUMBER:
    description: "GitHub PR number where action was executed."
    required: false
    default: ${{ github.event.number }}

runs:
  using: 'docker'
  image: 'Dockerfile'
  env:
    GITHUB_TOKEN: ${{ inputs.GITHUB_TOKEN }}
    GITHUB_PR_NUMBER: ${{ inputs.GITHUB_PR_NUMBER }}

テスト

pytestを使用し、CodeOwnerResolverおよびCodeOwnerCheckerのテストを記述しています。ビルドおよびテストの実行も、GitHub Actionsを用いて設定しています。

利用方法

Codemap Checkerは以下のようなYAMLファイルを.github/workflows配下に作成することで利用できます:

check-codemap.yml

name: Check Codemap

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

jobs:
  check-codemap:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4
      - uses: LINE-Client/codemap-checker/check-changed-files@v1

今後の展望

GitHub Actionsのステータスには成功・失敗しかなく、成功として出力される警告は確実に見逃されると予想しています。プルリクエストにコメントを残せないか担当チームに掛け合うとともに、それ以外の方法も模索していきます。また、無効なユーザ(退職など)がコードオーナーに設定されている場合もあり、これを検知できるようにしたいと考えています。

おわりに

Codemap Checkerは現在LINEのiOSレポジトリに導入済みで、しばらく様子を見た後にそのままAndroidレポジトリにも適用される予定です。プルリクエストのステータスにエラーを表示することでcodemap.ymlの更新忘れを防ぎ、コードオーナー設定率の維持および向上に寄与することを期待しています。