はじめに
先日異動が決まり、「異動前の部署でもらったレビューコメントを振り返ってまとめたいな。でも数あるPRのページを一個一個開いて復習するのはだるい。。一覧でざっと振り返れないかな、、」と思い、とりあえずGithubAPIで全レビューコメントを抜き出してみました!
使ったもの
- GithubAPI
- Node.js
- Octkit (Githubが用意してくれているAPI叩きやすくするためのJSライブラリ)
方法
実行方法
https://github.com/ironkicka/GithubAPIUtils
↑の自分のリポジトリをクローンして、npm i
して.envファイルを用意したら,以下のコマンドを打てば取得できます!
一応コード全体も記事末尾に載せてます。
node get_all_review_comments.js
※既存実装ですと期間指定してないので人によってはファイルサイズがバカデカくなるので要注意です。
自分はプロダクトがgithubに移行して一年くらいだったおかげか、
2.4MBくらいのjsonで済みました。
処理の流れ
- 自分の全PRの番号を抜き出す
- PRごとにReviewを抜き出す(PRの数だけloop)
- Reviewごとにコメントを抜き出す(Reviewの数だけloop)
これをみたときに、**[え,2と3って何が違うの??]**って思われる方もいると思いますが、
実は2で取得できるのは誰がコメントしたのか、そもそもCommentなのかApproveなのか、などのメタデータ(説明情報)のみであって、コメント文自体がレスポンスに含まれていません。
なので2でそのidを取得して、それをもとに3でコメント文を取得しにいくことになります。
解説
1. 自分の全PRの番号を抜き出す
PRの番号というのは、PRのリクエストの末尾の部分のあれです。
https://github.com/{owner}/{repo}/pull/**{ここ}**
取得には、GithubAPIのうち /search/issues
を使います。
公式ドキュメント: Search issues and pull requests
octokitで書くとこんな感じ
await octokit.request('GET /search/issues', {
q: 'q'
})
ただ、このままだとissueすべてを抜き出してしまうので、
PRであること、closeされていること、assigneeが自分であること、とレポジトリの指定の条件を加えます
- PRであること:
is:pr
- closeされていること:
state:closed
- assigneeが自分であること:
assignee:${自分のID}
- レポジトリを指定:
repo:${owner}/${repo}
以下のようにqパラメータに文字列として+で連結して渡してあげればOKです!
await octokit.request('GET /search/issues', {
q: `is:pr+state:closed+assignee:${assignee}+repo:${owner}/${repo}`,
})
これで上の条件に当てはまるPRを全て抜き取れるのですが、実はここに有名な?落とし穴があります!
それがページネーションです。
ページネーションについて
実はGithubAPIで取得してくるデータは、配列で返してくるタイプの場合、デフォルトだと30件までしか取得できません!
じゃぁ、取得したいものが30件以上のときはどうやって取得したらいいのかというと、実は、レスポンスヘッダーに次の30件を取得するためのURLが記載されているんです。
そのURLにリクエストを飛ばすと、レスポンスにはそのまた次の30件のリンクが載っています。
なので今回は以下のようなループを回しています。
この部分の実装はこちらを参考にさせていただきました。
(async ()=> {
let resultArr =new Array();
let next_url = "";
await octokit.request('GET /search/issues', {
q: `is:pr+state:closed+assignee:${assignee}+repo:${owner}/${repo}`,
}).then((res) => {
resultArr.push(res.data)
// headerから次の30件のためのurlを取得
const matches = /\<([^<>]+)\>; rel\="next"/.exec(res.headers.link);
if (matches != null) {
console.log("次のURL発見!!")
next_url = matches[1]
} else {
console.log("次のURLがありません")
next_url = null;
return;
}
}).catch((err) => {
console.log(err)
})
// next_urlが見つからなくなるまでループ
while (next_url !== null) {
await octokit.request(`${next_url}`, {}).then((res) => {
resultArr.push(res.data)
// headerから次の30件のためのurlを取得
const matches = /\<([^<>]+)\>; rel\="next"/.exec(res.headers.link);
if (matches != null) {
console.log("次のURL発見!!")
next_url = matches[1]
} else {
console.log("次のURLがありません")
next_url = null;
}
}).catch((err) => {
console.log(err)
})
}
}();
2. PRごとにReviewを抜き出す(PRの数だけloop)
GithubAPIのうち /repos/{owner}/{repo}/pulls/{pull_number}/reviews
を使います。
公式ドキュメント: List reviews for a pull request
octokitで書くとこんな感じ
await octokit.request(`GET /repos/${owner}/${repo}/pulls/${pull_number}/reviews`, {
owner: owner,
repo: repo,
pull_number: pull_number
})
今回は1の結果からでpull_numberの配列を抽出できるので、
それをforループで回して上記コードに埋め込んで使います。
3. Reviewごとのコメントを抜き出す(Reviewの数だけloop)
GithubAPIのうち /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments
を使います。
公式ドキュメント: List reviews for a pull request
octokitで書くとこんな感じ
await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments', {
owner: owner,
repo: repo,
pull_number: pull_number
})
今回は2の結果からでreview_idの配列を抽出できるので、
それをforループで回して上記コードに埋め込んで使います。
以上で大枠の解説は終わりです。
うまくいくと以下のようなjsonの配列が得られます。
[
{
"url": "https://api.github.com/repos/octocat/Hello-World/pulls/comments/1",
"pull_request_review_id": 42,
"id": 10,
"node_id": "MDI0OlB1bGxSZXF1ZXN0UmV2aWV3Q29tbWVudDEw",
"diff_hunk": "@@ -16,33 +16,40 @@ public class Connection : IConnection...",
"path": "file1.txt",
"position": 1,
"original_position": 4,
"commit_id": "6dcb09b5b57875f334f61aebed695e2e4193db5e",
"original_commit_id": "9c48853fa3dc5c1c3d6f1f1cd1f2743e72652840",
"in_reply_to_id": 8,
"user": {
"login": "octocat",
"id": 1,
"node_id": "MDQ6VXNlcjE=",
"avatar_url": "https://github.com/images/error/octocat_happy.gif",
"gravatar_id": "",
"url": "https://api.github.com/users/octocat",
"html_url": "https://github.com/octocat",
"followers_url": "https://api.github.com/users/octocat/followers",
"following_url": "https://api.github.com/users/octocat/following{/other_user}",
"gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
"starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
"organizations_url": "https://api.github.com/users/octocat/orgs",
"repos_url": "https://api.github.com/users/octocat/repos",
"events_url": "https://api.github.com/users/octocat/events{/privacy}",
"received_events_url": "https://api.github.com/users/octocat/received_events",
"type": "User",
"site_admin": false
},
"body": "ここがコメント!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!",
"created_at": "2011-04-14T16:00:49Z",
"updated_at": "2011-04-14T16:00:49Z",
"html_url": "https://github.com/octocat/Hello-World/pull/1#discussion-diff-1",
"pull_request_url": "https://api.github.com/repos/octocat/Hello-World/pulls/1",
"author_association": "NONE",
"_links": {
"self": {
"href": "https://api.github.com/repos/octocat/Hello-World/pulls/comments/1"
},
"html": {
"href": "https://github.com/octocat/Hello-World/pull/1#discussion-diff-1"
},
"pull_request": {
"href": "https://api.github.com/repos/octocat/Hello-World/pulls/1"
}
}
}
]
以下に今回対応漏れした部分があるので使う際は注意してください
対応漏れ・未実装
- 取得するPRの期間指定
- review一覧を取得するときのページネーション対応(PR取得と同じことするだけなのですが漏れてました)
終わりに
今回は、Jsonに吐き出しただけですが、これを自作Viewerに読ませて見やすくしたり、ワードクラウド化してよくもらう指摘を単語レベルで可視化したりしても面白いなぁなど妄想は膨らみます。また時間があれば試して記事書こうと思います。
参考
コード全容
require('dotenv').config();
const { Octokit } = require("@octokit/core");
const token = process.env.API_KEY;
const owner = process.env.OWNER;
const repo = process.env.REPO;
const assignee = process.env.ASSIGNEE;
const octokit = new Octokit({ auth: token });
const fs = require("fs");
(async ()=> {
let resultArr =new Array();
let next_url = "";
let api_limit_remaining;
await octokit.request('GET /search/issues', {
q: `is:pr+state:closed+assignee:${assignee}+repo:${owner}/${repo}`,
}).then((res) => {
resultArr.push(res.data)
api_limit_remaining = res.headers["x-ratelimit-remaining"]
const matches = /\<([^<>]+)\>; rel\="next"/.exec(res.headers.link);
if (matches != null) {
console.log("次のURL発見!!")
next_url = matches[1]
} else {
console.log("次のURLがありません")
next_url = null;
return;
}
}).catch((err) => {
console.log(err)
})
while (next_url !== null) {
await octokit.request(`${next_url}`, {}).then((res) => {
resultArr.push(res.data)
api_limit_remaining = res.headers["x-ratelimit-remaining"];
const matches = /\<([^<>]+)\>; rel\="next"/.exec(res.headers.link);
if (matches != null) {
console.log("次のURL発見!!")
next_url = matches[1]
} else {
console.log("次のURLがありません")
next_url = null;
}
}).catch((err) => {
console.log(err)
})
}
console.log(resultArr)
console.log(resultArr.length)
const pulls = resultArr.map(data =>data.items)
const result = [].concat.apply([],pulls);
console.log(result.length);
console.log("x-ratelimit-remaining",api_limit_remaining)
const pull_numbers = result.map(data => data.number)
console.log(pull_numbers)
console.log("PRの数",pull_numbers.length)
let comments_raw =[]
const pull_num_length = pull_numbers.length
let count = 1;
for(let pull_number of pull_numbers){
console.log(`PR: ${count}/${pull_num_length}`)
count ++;
let review_ids = []
await octokit.request(`GET /repos/${owner}/${repo}/pulls/${pull_number}/reviews`, {
owner: owner,
repo: repo,
pull_number: pull_number
}).then((res)=>{
review_ids = res.data.map(data => data.id)
console.log("review_ids 取得",review_ids)
}).catch((err) => {
console.log(err)
})
for(let review_id of review_ids){
await octokit.request(`GET /repos/${owner}/${repo}/pulls/${pull_number}/reviews/${review_id}/comments`, {
owner: owner,
repo: repo,
pull_number: pull_number
}).then((res)=>{
if(res.data.length !==0){
console.log(res.data[0].body)
comments_raw.push(res.data)
}
}).catch((err) => {
console.log(err)
})
}
}
const comments = [].concat.apply([],comments_raw);
try {
fs.writeFileSync("./dst/comments.json",JSON.stringify(comments));
console.log('write end');
}catch(e){
console.log(e);
}
})();