Claude Media
Claude CodeでCODEOWNERSを作る — git logから担当を出しCIで抜けを検知

Claude CodeでCODEOWNERSを作る — git logから担当を出しCIで抜けを検知

git logのディレクトリ別コミット数から担当の候補を出し、Claude CodeにCODEOWNERSを下書きさせる手順。後勝ちで効かなくなった行と担当の抜けは、スクリプトとCIで検知します。

CODEOWNERSは、リポジトリのパスごとにレビュー担当の個人やチームを書くファイルです。PRが担当パスに触れると、担当者に自動でレビューが依頼されます。このファイルは書くより保つほうが難しく、ディレクトリが増えても行が足されず、担当が抜けていきます。

この記事では、git logの変更履歴から担当の候補を出し、Claude Codeに下書きを作らせます。そのうえで、「どの行も効いているか」「担当のないファイルがないか」をスクリプトとCIで毎回確かめる流れを組みます。担当を決めるのは人です。履歴から出せるのは候補までです。

CODEOWNERSが担当を決める仕組み

GitHubのドキュメントによると、CODEOWNERSは各行が「ファイルパターン + 1人以上の担当」の形です。パターンの大半はgitignoreと同じ規則で、最後に一致した行が優先されます。この後勝ちが、行の抜けと事故の両方の原因になります。

押さえておく条件は次のとおりです。

  • 置き場所は.github/、リポジトリ直下、docs/のいずれかで、複数あればこの順で最初に見つかった1つだけが使われる
  • 担当は書き込み権限が必要。チームは可視で、書き込み権限が明示されていなければならない
  • 同じパターンに複数の担当を付けるなら、同じ行に書く。別の行に分けると、最後の1人にしか効かない
  • 不正な構文の行は黙ってスキップされる。存在しないユーザーや権限不足のユーザーを書くと、担当は割り当てられない
  • ファイルサイズは3MB未満。超えると読み込まれず、レビュー依頼も飛ばない
  • レビュー依頼に使われるのは、PRのベースブランチ上のCODEOWNERS

gitignoreと違い、否定の!、#で始まるパターンのエスケープ、[ ]の文字範囲は使えません。

CODEOWNERSで事故になりやすい点

  • 後に書いた汎用パターンが、先に書いた個別の行を上書きする
  • 担当が退職・権限喪失していても、行は残ったまま「担当なし」になる
  • 構文エラーの行は警告なしに無効になる

git logから担当の候補を出す

Claudeに渡す前に、手元のコマンドで集計しておくのが確実です。Claude Codeのドキュメントにもgit log --oneline -20 | claude -p "summarize these recent commits"のように、gitの出力を標準入力で渡す例があります。ここでは集計までをシェルで済ませ、出力だけをClaudeに渡します。

ディレクトリごとに、直近12か月の作者メールを数えます。%aEは.mailmapを反映した作者メールなので、同じ人の別アドレスがまとまります。

for d in src/api src/web docs scripts; do
  echo "## $d"
  git log --since="12 months ago" --format='%aE' -- "$d" \
    | grep -v '\[bot\]' | sort | uniq -c | sort -rn | head -3
done

小さな検証用リポジトリで実行すると、次のように出ます(メールは例示用の値です)。

## src/api
   1 alice@example.com
## src/web
   1 bob@example.com

この表はあくまで候補です。次の3点は、履歴だけでは判断できません。

  • コミット数が多い人が、今もそのチームにいるか
  • 作者のメールがGitHubのログイン名と結びつくか。CODEOWNERSにはメールも書けるが、Enterprise Managed Usersのアカウントにはメールを使えない
  • 1人に決めるか、チームに寄せるか。個人を書くと、その人が休むとレビューが止まる

メールとログイン名の対応、チームへの寄せ方は、人が決めて表に書き足します。組織メンバーの棚卸しで出した名簿があれば、退職者を候補から外す作業が楽になります。

Claude Codeに下書きを作らせる

候補表ができたら、CODEOWNERSの下書きを頼みます。入力は候補表とgit ls-filesのディレクトリ一覧、出力は.github/CODEOWNERSの案です。次のように、規約をCLAUDE.mdに書いておくと、毎回言い直さずに済みます。

## CODEOWNERS を編集するとき
- 一致した最後の行が優先される。汎用パターン(`*`、`*.md`)を先頭に置き、
  個別のパスを後ろに書く
- 担当は candidates.md に載っているものだけ。載っていない人やチームを
  足さない
- 担当の欄は空にしない。担当を決められないパスは、その旨を報告する
- 編集のたびに python3 scripts/codeowners_check.py を実行し、
  出力が空になるまで直す

非対話で回すなら、Claude Codeの-pを使います。ドキュメントでは--allowedToolsで使うツールを事前に許可します。Bashを含めると、シェルコマンドを承認なしで実行できます。

claude -p "candidates.md を元に .github/CODEOWNERS の案を作り、
scripts/codeowners_check.py が通るまで直して" \
  --allowedTools "Read,Edit,Bash"

-pの実行はこの許可の範囲で自動で進みます。Bashを許可するぶん、実行するのは信頼できるリポジトリの中だけにし、できた差分は人がレビューします。ここで見るのは行の中身ではなく、担当の割り当てがチームの実態に合っているかです。

後勝ちで効かなくなった行を確かめる

下書きが通ったように見えても、後勝ちで意図が崩れる箇所は目で追いにくいものです。確かめるには、全ファイルについて最後に一致する行を計算します。

次のスクリプトは、gitignore風のパターンを正規表現に変換する近似実装です。担当のないファイルと、どのファイルでも最後に一致しない行(後続の行に隠れた行)を出します。

#!/usr/bin/env python3
import re, subprocess, sys
 
def to_regex(pat):
    anchored = pat.startswith("/") or "/" in pat.rstrip("/")
    body, out, i = pat.strip("/"), "", 0
    while i < len(body):
        if body.startswith("**/", i):
            out += "(?:.*/)?"; i += 3
        elif body[i] == "*":
            out += "[^/]*"; i += 1
        elif body[i] == "?":
            out += "[^/]"; i += 1
        else:
            out += re.escape(body[i]); i += 1
    prefix = "" if anchored else "(?:.*/)?"
    # docs/* は直下だけ。それ以外は配下にも効く
    tail = "" if pat.endswith("/*") else "(?:/.*)?"
    return re.compile("^" + prefix + out + tail + "$")
 
path = sys.argv[1] if len(sys.argv) > 1 else ".github/CODEOWNERS"
rules = []
for n, line in enumerate(open(path, encoding="utf-8"), 1):
    line = line.split(" #")[0].strip()
    if line and not line.startswith("#"):
        pat, *owners = line.split()
        rules.append((n, pat, owners, to_regex(pat)))
 
files = subprocess.run(["git", "ls-files"], capture_output=True,
                       text=True, check=True).stdout.splitlines()
wins = {r[0]: 0 for r in rules}
bad = 0
for f in files:
    hit = [r for r in rules if r[3].match(f)]
    if not hit or not hit[-1][2]:
        print(f"UNOWNED {f}"); bad += 1
    else:
        wins[hit[-1][0]] += 1
for n, pat, _, _ in rules:
    if not wins[n]:
        print(f"DEAD    {path}:{n} {pat}  (どのファイルでも最後に一致しない)")
        bad += 1
sys.exit(1 if bad else 0)

わざと壊したCODEOWNERSで試します。次の内容を、先ほどの検証用リポジトリ(README.md、docs/、scripts/などを含む)に置きました。

*            @org/platform
/src/api/    @alice
/src/web/    @bob
docs/*       @carol
/docs/       @dave
*.md         @org/docs
/scripts/
python3 scripts/codeowners_check.py
UNOWNED scripts/build.sh
DEAD    .github/CODEOWNERS:4 docs/*  (どのファイルでも最後に一致しない)
DEAD    .github/CODEOWNERS:5 /docs/  (どのファイルでも最後に一致しない)
DEAD    .github/CODEOWNERS:7 /scripts/  (どのファイルでも最後に一致しない)

出力にスクリプト自身が出ないのは、検証用リポジトリではscripts/codeowners_check.pyをgitに追加していなかったためです。追跡させると、/scripts/の担当欄が空なので、このファイルにもUNOWNEDが付きます。

読み取れることは次の3つです。

  • /scripts/は担当の欄が空で、GitHubのドキュメントでは「担当なし(書き込み権限のある誰でも承認できる)」の指定です。意図したものでなければ抜けです。勝っても担当を生まないので、DEADにも出ます
  • docs/*と/docs/は、最後の*.mdがdocs配下のMarkdownをすべて奪うため、一度も勝ちません。docsの担当を決めたつもりで、実際は@org/docsが全部を受けています
  • *.mdの行をdocsの行より上に動かせば、直ります。汎用パターンを先に、個別を後に、という並びです

パターンの書き分けも、後勝ちと同じくらい事故のもとです。GitHubのドキュメントの例では、docs/*はdocs/getting-started.mdには一致しますが、docs/build-app/troubleshooting.mdのような入れ子のファイルには一致しません。/docs/なら配下すべてに一致します。ディレクトリ全体を持たせたいときは、先頭と末尾にスラッシュを付けます。Claudeに頼んだ下書きでも、この書き分けは取り違えやすいので、スクリプトのDEADで確かめます。

前と後

行の順序を入れ替えて、個別の担当を効かせる

BEFORE

汎用パターンが後ろ

/docs/       @dave
*.md         @org/docs
AFTER

汎用パターンを前へ

*.md         @org/docs
/docs/       @dave

CIで抜けと構文エラーを検知する

PRのたびに2つを調べます。1つ目は上のスクリプトで、新しく追加されたファイルに担当がないことを拾います。2つ目はGitHubのREST APIで、構文エラーの一覧を取ります。

GET /repos/{owner}/{repo}/codeowners/errorsは、CODEOWNERSの構文エラーを返します。クエリのrefに、使うCODEOWNERSのブランチ・タグ・コミットを指定できます。応答はerrors配列で、各要素にline・column・kind・message・pathなどが入ります。

name: codeowners
on: pull_request
permissions:
  contents: read
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: 担当の抜けと効かない行
        run: python3 scripts/codeowners_check.py
      - name: 構文エラー
        env:
          GH_TOKEN: ${{ github.token }}
          REPO: ${{ github.repository }}
          SHA: ${{ github.event.pull_request.head.sha }}
        run: |
          n=$(gh api "repos/$REPO/codeowners/errors?ref=$SHA" \
            --jq '.errors | length')
          test "$n" = "0"

2つの検査は役割が違います。自作スクリプトはパターンの解釈を近似しているので、GitHub本体の判定とずれる可能性があります。構文エラー側はGitHubが返すので、行が無効になっているかどうかはこちらが正です。両方を通して初めて「効く行がそろっている」と言えます。

上のYAMLではpermissionsにcontents: readを明示しています。APIが403や404を返すときは、まずこのトークンの権限を見直します。

Claudeに任せる範囲と任せない範囲

CIは決まった入力に決まった答えを返すので、Claudeを呼ぶ必要はありません。Claudeが向くのは、CIが落ちたあとの直しです。落ちたログを渡して「UNOWNEDのパスに、既存の担当から近いものを提案して」と頼み、案を人が採否します。

運用で踏みやすい落とし穴

落とし穴

CODEOWNERS自体の変更が、担当なしで通る

担当に.github/CODEOWNERS自体が入っていないと、ファイルを書き換えるPRのレビューを要求できません。GitHubのドキュメントでは、完全に保護するには.github/CODEOWNERSか.github/にリポジトリ所有者を担当として置くよう案内しています。

  • 担当の承認を必須にするには、ブランチ保護か、ルールセットで「Require review from Code Owners」を有効にする。無効のままだと、レビュー依頼が飛ぶだけで、マージは止まらない
  • 担当が複数いても、承認はいずれか1人で足りる。全員の承認が要るわけではない
  • ドラフトのPRでは、担当へのレビュー依頼は自動で飛ばない。レビュー可能にした時点で通知される
  • パスの大文字小文字は区別される。macOSで書いたまま大文字小文字を間違えると、一致しない
  • フォークのPRは、ベースブランチ側のCODEOWNERSを使う

履歴から出した候補は、退職や異動のたびに古くなります。担当の見直しは、四半期ごとにgit logの集計を取り直し、表と見比べる運用にすると回ります。組織の全リポジトリにレビューを入れる設定と合わせるなら、担当の抜けを先に潰しておくと、レビューの宛先がぶれません。

まとめ

CODEOWNERSは、書き方より更新の仕組みで差がつくファイルです。履歴の集計を候補にし、Claudeに下書きを任せ、スクリプトとCIで抜けを機械的に拾う流れなら、担当の最終判断だけを人に残せます。

先に着手する価値が高いのは、後勝ちで一度も勝たない行(DEAD)の洗い出しです。行を足した覚えのない担当ほど、実際には別の行に奪われていることがあります。変更履歴から引いたコマンドの出し方は、git logからパッチノートを起こす手順でも同じ形で使えます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. docs.github.com/en/repositories/managing-your-repositorys-settings-and-fea…
  2. docs.github.com/en/rest/repos/repos
  3. code.claude.com/docs/en/common-workflows
  4. code.claude.com/docs/en/headless
この記事を共有:XはてブLinkedIn