バックログのユーザー名とGitHubのユーザー名を半自動で紐づけた話
GitHub Actionsを作っていて、GitHubからバックログのユーザーを取得したい場面がありました。しかし、バックログは会社管理のアカウントを発行するのに対し、GitHubは個人のアカウントでOrganizationに参加してもらう仕組みになっています。そのため、2つのサービスでユーザー名が一致しません。
今回は時間をかけず対応したかったので、バックログとGitHubのユーザID・ユーザ名を紐づけるJSONを作ることにしました。あまりスマートではないのですが、ちょっとした小ネタとして紹介します。
TL;DR
- バックログのAPIを、curlで叩く
- GitHubのAPIを、GitHub CLIで叩く
- 結果をjqで整形して、Pythonで処理
バックログの全ユーザーを取得
バックログで「ユーザー一覧の取得」APIを使うと、全ユーザーを取得できます。
https://developer.nulab.com/ja/docs/backlog/api/2/get-user-list/
APIの認証にOAuthも使えるそうですが、APIキーで実施するのが手軽です。認証についてもドキュメントがあるので、それを参考にすると
BACKLOG_API_URL=https://xx.backlog.jp/api/v2 BACKLOG_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXX curl "${BACKLOG_API_URL}/users?apiKey=${BACKLOG_API_KEY}" | jq
というように取得します。上2行は見ての通り、環境変数の設定です。APIの出力はJSON形式なので、見やすくするためjqにパイプをつなげています。
さて、このJSONをベースにしてGitHubのユーザー名を追加するようにします。このままでは使えないので、jqを使って
- 社外の招待ユーザーを含まず、弊社ユーザー(名前が “e2info” で始まっている)のみに絞る
- 必要なプロパティのみに絞って、キー名を変える(e.g. “id” → “backlog_id”)
となるようにします。すると、先ほどのコマンドは
curl "${BACKLOG_API_URL}/users?apiKey=${BACKLOG_API_KEY}" | jq 'map(select(.name | startswith("e2info")) | { backlog_id: .id, backlog_name: .name, email: .mailAddress })' > users.json
となります。curlの結果をjqのクエリで処理して、users.jsonに出力しています。
GitHubの全ユーザーを取得
次はGitHubのOrganizationに所属する全ユーザーを取得します。該当するこのAPIを使います。
https://docs.github.com/ja/rest/orgs/members?apiVersion=2022-11-28#list-organization-members
GitHub CLIで実行するサンプルコードがありますが、事前に gh auth login
などで認証していればヘッダを指定しなくてもできました。弊社のGitHub Organizationは “e2info” という名前なので、コマンドはこれだけです
gh api /orgs/e2info/members
ユーザー名だけでなくメールアドレスなどを取得できればバックログと紐付けやすいのですが、それはGitHubの仕様のためか難しいようでした。そこで、GitHubのユーザーリストからはユーザー名だけを使うことにします。jqでユーザー名(login)の配列を取得するには、次のようにします
gh api /orgs/e2info/members | jq -c '[.[] | .login]' > github_users.json
2つのJSONをPythonで処理
以上で、欲しいJSONが2つ手に入りました。バックログのユーザー名、ID、メールアドレスのJSONと、GitHubのユーザ名のJSONです。
バックログに登録されているメールアドレスを元に、GitHubのユーザーから最もそれっぽい名前を取得して、”github_name” として保存すれば完了です。ここから先は目視で10分くらい頑張って作業するのが一番早いのですが、それではつまらないので半自動化します。
だいたいのユーザーがメールアドレス(氏名のローマ字)と似た名前のGitHubアカウント名にしているはずなので、文字列の類似度から判断することにします。Pythonを使って書くと、こんな感じのコードになりました
import json,difflib def score(str1, str2): return difflib.SequenceMatcher(None, str1, str2).ratio() gh = json.load(open("github_users.json")) for u in json.load(open("backlog_users.json")): m = u["email"].replace("@e2info.com", "") g = sorted(gh, key=lambda x: score(m, x))[-1] u["github_name"] = g json.dump(u, open("users.json", "w"), ensure_ascii=False, indent=4)
最初に定義しているscoreが重要で、標準ライブラリのdifflibを使い2つの文字列の”類似度”を測定する関数です。forの中では、
1. メールアドレスからドメイン名を除く前処理
2. GitHubのユーザー名を、メールアドレスとの類似度でソートする
3. 一番類似度の高いユーザー名を github_name
として保存
という処理をしています。細かい点として、メールアドレスを比較するときにドメイン名を除く前処理をしています。これは後述のアルゴリズムの問題で、GitHubのユーザー名に”e2info”を含めていると類似度が高くなってしまう影響があるためです。
実際の結果
せっかくなので、最後に強引にワンライナーにします。
(curl -sSf "${BACKLOG_API_URL}/users?apiKey=${BACKLOG_API_KEY}" | jq -c 'map(select(.name | startswith("e2info")) | { backlog_id: .id, backlog_name: .name, email: .mailAddress })' && gh api /orgs/e2info/members | jq -c '[.[] | .login]') | python -c 'import json,difflib; bl = json.loads(input()); gh = json.loads(input()); print(json.dumps([{**b, "github_name": sorted(gh, key=lambda x: difflib.SequenceMatcher(None, b["email"].replace("@e2info.com", ""), x).ratio())[-1]} for b in bl], ensure_ascii=False))'
バックログとGitHubのユーザーリストをファイルでなく標準出力して、その2つのJSONをパイプでPythonに渡して処理しています。JSONを1行にするためjqに `-c` オプションをつけたり、Pythonでファイルでなく標準入力から読み込んだりの違いだけで、ほとんど上と変わりません。
最終的な実行結果はこのようになりました。
目視で確認すると、全30ユーザーのうち27件のGitHubユーザー名を正しく取得できています。精度は90%です。
アルゴリズムの課題点
上ではPythonの標準ライブラリにある difflib.SequenceMatcher を使って文字列の類似度を計算しました。Pythonのドキュメントによるとゲシュタルトパターンマッチングと言うそうです。
今回の目的「GitHubのユーザーリストから、最もそれっぽい名前を選ぶ」において、当然、最も重要な部分です。
名前のわりにはシンプルで、
(共通文字列の総和)×2÷(文字列1の長さ+文字列2の長さ)
で定義されます。この共通文字列は、文字の順序も一致している必要があります。
例えばメールアドレスが “taro_yamada” で、GitHubの名前が “yamadataro” の場合、上の類似度(スコア)は 0.57 になります。姓名の順番とアンダーバーの有無しか違いがないのに、スコアが低くなってしまいました。これは共通文字列が “yamada” なので “taro” の一致が類似度の含まれないためです。Pythonで確認すると、
>>> str1 = "taro_yamada" >>> str2 = "yamadataro" >>> difflib.SequenceMatcher(None, str1, str2).ratio() 0.5714285714285714 >>> len("yamada")*2/(len(str1)+len(str2)) 0.5714285714285714
こんな風に、上のライブラリでの計算結果と、下の検算が一致しています。
弊社では姓名のどちらかが一致すれば判定できるので9割の精度でしたが、人数が多くなればこの方法だと精度が下がるはずです。そういった場合、レーベンシュタイン距離やハミング距離などの別の指標を使ったり、それらを組み合わせて独自に評価関数を作ったりする必要があります。
最後に
本当はAWSなどの実務に関係する記事を考えていたのですが、しばらくかけていなかったので小ネタでした。ちょっとしたワンライナーネタのつもりでしたが、説明やアルゴリズムについて書いていたら意外とちゃんとした内容になってしまいました。
文字列の類似度を計算する場面はあまり多くないかもしれませんが、jqを使ってJSONを加工するのはよくある操作です。AWS CloudShellやGitHub Actionsのランナーには標準で入っていますし、APIの結果を加工するのに便利なのでぜひお試しください。