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.pyUNOWNED 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で確かめます。
行の順序を入れ替えて、個別の担当を効かせる
汎用パターンが後ろ
/docs/ @dave
*.md @org/docs汎用パターンを前へ
*.md @org/docs
/docs/ @daveCIで抜けと構文エラーを検知する
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からパッチノートを起こす手順でも同じ形で使えます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。