はじめに
1日324回DevOpsのことを考えてしまう変態子持ちflutterエンジニアです。
妻からは育児中にいつもDevOpsのことばかり考えるなスマホばかりいじるなと言われ、悪戦苦闘の毎日です。。
...そんなことはどうでも良いんです、ハイ。本題に行きましょう。
私はモバイルアプリのチーム開発をしておりますが、バージョン管理はGitHubが使われてます。
チーム開発において、レビュワーからコメントがあったものは、レビュワーがresolveを判断すべきだと思ってますが、私を含めてチーム内で、resolveのし忘れがたまにあります。
そこで勝手に、Discordでresolveすべき人に毎朝通知したいと思い、実装してみました。
「resolveし忘れリマインダーの構築」プロジェクトが始まりました。
(結局このリマインダーは実際の業務で不採用となりましたが、まぁ遊び半分で実装しただけなので...)
GitHub GraphQL APIとGoを活用した、resolveし忘れリマインダーの構築
システムの流れ
まずは大まかな処理を説明します。
毎朝GitHub ActionsからGo言語のスクリプトを実行します。
(2から6が同スクリプトで実行されます)
- 毎朝定時にGitHub Actionsを実行
- 未解決のPRのスレッドを取得
- 各スレッドごとに、通知対象のアカウントを決める
- 最初のコメントをGemini APIで要約(関西弁で)
- DiscordのBotから通知対象のアカウントにダイレクトメッセージを送信
ダイレクトメッセージの中身がこんな感じになります。
順番に詳細を見ていきましょう。
Goの環境構築
ここは割愛で...
毎朝定時にGitHub Actionsを実行
開発に使用するレポジトリの.github/workflows
フォルダ配下にyamlファイルを以下のように実装します。
GITHUB_TOKEN
はGitHub Secrets1を使います。
name: Send github unresolved comment to discord
on:
workflow_dispatch:
schedule:
# 月〜金の午前8時に処理を実行する。(UTC指定)
- cron: "0 23 * * 0-5"
jobs:
run-go:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.21.1"
- name: Install dependencies
run: go mod tidy
working-directory: .github/workflows/script
- name: Generate PR list and send Discord notification
run: go run send_github_unresolved_comment.go
working-directory: .github/workflows/script
env:
GITHUB_TOKEN: ${{ secrets.PERSONAL_GITHUB_TOKEN }}
未解決のPRのスレッドを取得
まず何はともあれresolveし忘れコメント(未解決のPRのスレッド)を抽出せねば何も始まりません。
GitHubでは他SNSアプリのように、コメントの集合体をスレッドと呼びます。
resolvedなのかunresolvedなのかは、そのスレッド単位になります。
コメント抽出にはgo-githubのパッケージが使えそうだったのですが、どうやらGitHub API v3を使っているためか、「UnresolvedのPRのスレッド」という条件で絞れないようでした。
そこでGraphQL API v4を使う必要があるのですが、同レポジトリでも勧められているshurcooL/githubv4を使う必要があります。
ただ、UnresolvedのPRのスレッドの抽出のためには、恐らくレポジトリのownerとレポジトリ名、PRの番号を指定する必要がありそうです。なので厄介ですが、go-githubで、あるOrganizationの全てのレポジトリからOpenのPRを抽出し、その後shurcooL/githubv4で、その中からUnresolvedのスレッドを抽出するという合わせ技を使います。
あるOrganizationの全てのレポジトリからOpenのPRを抽出する
まずはgo-githubの出番です。以下のようにGoの実装をします。ファイル名は、上記yamlファイルで指定したものです。処理順としては、あるOrganizationの全てのレポジトリ一覧を取得し、各レポジトリのStateがOpenのプルリクエストの一覧を取得する感じです。
package main
import (
"context"
"log"
"os"
"github.com/google/go-github/v41/github"
"golang.org/x/oauth2"
)
func main() {
ctx := context.Background()
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: os.Getenv("GITHUB_TOKEN")},
)
tc := oauth2.NewClient(ctx, ts)
client := github.NewClient(tc)
owner := "demo-owner"
// レポジトリのリストを取得する
opt := &github.RepositoryListByOrgOptions{
ListOptions: github.ListOptions{PerPage: 100},
}
repositories, _, err := client.Repositories.ListByOrg(ctx, owner, opt)
if err != nil {
log.Fatal("Error getting repositories:", err.Error())
}
for _, repository := range repositories {
// 該当レポジトリの、StateがOpenのプルリクエストの一覧を取得する
pulls, _, err := client.PullRequests.List(ctx, owner, *repository.Name, &github.PullRequestListOptions{
State: "open",
})
if err != nil {
log.Fatal("Error fetching pull requests:", err.Error())
}
for _, pull := range pulls {
if !*pull.Draft { // DraftのPRは対象外
// TODO: ここにshurcooL/githubv4の処理を実装する
}
}
}
}
各PRの未解決スレッド一覧を取得する
次はshurcooL/githubv4を使います。
このパッケージではGitHubのGraphQL APIが使用されてますが、このAPIを使用して、未解決のPRスレッドの情報を取得するクエリを作成します。ちなみに、GraphQL APIはGitHub APIを含むREST APIに比べて柔軟性が高く、必要なデータのみを指定して取得できるため、効率的なデータ取得が可能なようです。
上記のスクリプトを以下のように追記します。
for _, pull := range pulls {
内に全て実装するのではなく、
今後、機能拡張するのを見越して、実装を分けています。
package main
import (
"context"
"log"
"os"
"github.com/google/go-github/v41/github"
"github.com/shurcooL/githubv4"
"golang.org/x/oauth2"
)
type Query struct {
Repository struct {
PullRequest struct {
ReviewThreads struct {
Edges []struct {
Node struct {
IsResolved bool
IsOutdated bool
IsCollapsed bool
Comments struct {
Nodes []struct {
Author struct {
Login string
}
Body string
Url string
}
} `graphql:"comments(first: 100)"`
}
}
} `graphql:"reviewThreads(first: 100)"`
} `graphql:"pullRequest(number: $prNumber)"`
} `graphql:"repository(owner: $repoOwner, name: $repoName)"`
}
type PullRequestAndQuery struct {
PullRequest github.PullRequest
Query Query
}
func main() {
ctx := context.Background()
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: os.Getenv("GITHUB_TOKEN")},
)
tc := oauth2.NewClient(ctx, ts)
client := github.NewClient(tc)
owner := "demo-owner"
// レポジトリのリストを取得する
opt := &github.RepositoryListByOrgOptions{
ListOptions: github.ListOptions{PerPage: 100},
}
repositories, _, err := client.Repositories.ListByOrg(ctx, owner, opt)
if err != nil {
log.Fatal("Error getting repositories:", err.Error())
}
var pullRequests []PullRequestAndQuery
for _, repository := range repositories {
// 該当レポジトリの、StateがOpenのプルリクエストの一覧を取得して格納しておく
pulls, _, err := client.PullRequests.List(ctx, owner, *repository.Name, &github.PullRequestListOptions{
State: "open",
})
if err != nil {
log.Fatal("Error fetching pull requests:", err.Error())
}
for _, pull := range pulls {
if !*pull.Draft { // DraftのPRは対象外
v4Client := githubv4.NewClient(tc)
variables := map[string]interface{}{
"prNumber": githubv4.Int(*pull.Number),
"repoOwner": githubv4.String(owner),
"repoName": githubv4.String(*pull.Head.Repo.Name),
}
var query Query
err := v4Client.Query(ctx, &query, variables)
if err != nil {
log.Fatal("v4Client.Query error:", err)
}
prAndQuery := PullRequestAndQuery{
PullRequest: *pull,
Query: query,
}
pullRequests = append(pullRequests, prAndQuery)
}
}
}
githubUserNameAndDiscordUserIdMap := map[string]string{
"github_demo_user_1": "4123421342135342343",
"github_demo_user_2": "4512451235213131432",
}
for githubUserName, discordUserId := range githubUserNameAndDiscordUserIdMap {
var messagesPerSection []string
lastRepoName := ""
// 同アカウントがPRに対して最初にコメントしたものがResolveされてないプルリクエストを抽出
messagesPerSection = []string{}
lastRepoName = ""
for _, pull := range pullRequests {
query := pull.Query
pull := pull.PullRequest
threadEdges := query.Repository.PullRequest.ReviewThreads.Edges
if len(threadEdges) > 0 {
for _, threadEdge := range threadEdges {
if threadEdge.Node.IsResolved {
// 解決済のスレッドはスキップ
continue
}
commentNodes := threadEdge.Node.Comments.Nodes
firstCommenter := commentNodes[0].Author.Login
lastCommenter := commentNodes[len(commentNodes)-1].Author.Login
lastCommentUrl := commentNodes[len(commentNodes)-1].Url
// TODO: 通知先と通知内容を編集する
}
}
}
// TODO: 今後、機能拡張はここに実装(例:各開発メンバーがレビュアーかつ未レビューのプルリクエストを抽出する)
}
}
各スレッドごとに、通知対象のアカウントを決める
さて、次に、上記コードのTODOコメント「通知先と通知内容を編集する」の部分を実装します。
通知先は単純に最初にコメント(≒ 指摘)したアカウントと思うかもしれませんが、以下のように色々なケースが考えられます。
- レビューイがその指摘事項の修正をし忘れていた場合
- 修正したけど、最初のコメント者がメンション付きで他の人に確認を依頼していた場合
- 修正後に最初のコメント者が確認したけど単純にResolveするのを忘れていた場合
などなど....
試行錯誤しましたが、以下のロジックに落ち着きました。
-
そのスレッドの最後のコメントがメンション付きの場合
→ メンションされたアカウントのみに通知する
理由:同アカウントに確認依頼を求めている可能性が高いため -
最初のコメント者がレビューイ本人の場合
→ レビューイのみに通知する
理由:レビューイ本人がResolveしていないだけの可能性が高い -
最初のコメント者と最後のコメント者が同じ場合
-
スレッド内にレビューイのコメントが含まれている場合
→ 最初のコメント者とレビューイに通知する
理由:コメント者が単純にResolveし忘れの可能性やレビューイが指摘事項修正していない可能性が高い -
スレッド内にレビューイのコメントが含まれていない場合
→ レビューイのみに通知する
理由:レビューイが単に指摘事項修正していない可能性が高いため
-
スレッド内にレビューイのコメントが含まれている場合
-
最初のコメント者と最後のコメント者が異なる場合
→ 最初のコメント者とレビューイに通知する
理由:レビューイが修正した内容を最初のコメント者が確認してない可能性や最後のコメントがレビューイで「〜修正します」などのコメントを残している可能性が高いため
上記のように通知先は複数の可能性がありますが、通知された人が「これもう私は確認したわよ」と思って、他の人に確認を委ねたいケースがあるかと思います。
そんな時のために、他に誰に通知が飛んだのかが分かれば、わざわざ確認依頼を投げなくても良いかと思い、上記メッセージ例に(+田/渡)
のように記載した通り、他の通知先をスラッシュ区切りで通知メッセージに追加してみました。(GitHubユーザー名を表示するとメッセージが長くなってしまうので、略名を使ってみました)
以上のロジックを実装するにあたり、各アカウントの略名を使う必要があったり、ロジックが若干複雑だったりするので、まずは各アカウントの情報をモデル化(Developer
)しました。
DiscordUserIdは後々の通知処理に使います。
import (
...(略)
)
type Developer struct {
// GitHubユーザー名
Name string
// ユーザー名の略語
NameAbbr string
// DiscordのユーザーID
DiscordUserId string
}
type Query struct {
...(略)
通知メッセージを作成する処理の実装は次のステップになりますが、そのTODOコメントを残し、以下のように実装します。
package main
import (
"context"
"fmt"
"log"
"os"
"slices"
"strings"
"github.com/google/go-github/v37/github"
"github.com/shurcooL/githubv4"
"github.com/thoas/go-funk"
"golang.org/x/oauth2"
)
type Developer struct {
// GitHubユーザー名
Name string
// ユーザー名の略語
NameAbbr string
// DiscordのユーザーID
DiscordUserId string
}
type ThreadEdge struct {
Node struct {
IsResolved bool
IsOutdated bool
IsCollapsed bool
Comments struct {
Nodes []struct {
Author struct {
Login string
}
Body string
Url string
DatabaseId int
}
} `graphql:"comments(first: 100)"`
}
}
type Query struct {
Repository struct {
PullRequest struct {
ReviewThreads struct {
Edges []ThreadEdge
} `graphql:"reviewThreads(first: 100)"`
} `graphql:"pullRequest(number: $prNumber)"`
} `graphql:"repository(owner: $repoOwner, name: $repoName)"`
}
type PrReviewThread struct {
ThreadEdge ThreadEdge
Summary string
}
type PullRequestAndQuery struct {
PullRequest github.PullRequest
PrReviewThreads []PrReviewThread
}
func main() {
ctx := context.Background()
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: os.Getenv("GITHUB_TOKEN")},
)
tc := oauth2.NewClient(ctx, ts)
client := github.NewClient(tc)
owner := "demo-owner"
// レポジトリのリストを取得する
opt := &github.RepositoryListByOrgOptions{
ListOptions: github.ListOptions{PerPage: 100},
}
repositories, _, err := client.Repositories.ListByOrg(ctx, owner, opt)
if err != nil {
log.Fatal("Error getting repositories:", err.Error())
}
var pullRequests []PullRequestAndQuery
for _, repository := range repositories {
// 該当レポジトリの、StateがOpenのプルリクエストの一覧を取得して格納しておく
pulls, _, err := client.PullRequests.List(ctx, owner, *repository.Name, &github.PullRequestListOptions{
State: "open",
})
if err != nil {
log.Fatal("Error fetching pull requests:", err.Error())
}
for _, pull := range pulls {
if !*pull.Draft { // DraftのPRは対象外
v4Client := githubv4.NewClient(tc)
variables := map[string]interface{}{
"prNumber": githubv4.Int(*pull.Number),
"repoOwner": githubv4.String(owner),
"repoName": githubv4.String(*pull.Head.Repo.Name),
}
var query Query
err := v4Client.Query(ctx, &query, variables)
if err != nil {
log.Fatal("v4Client.Query error:", err)
}
var unresolvedReviewThreads []PrReviewThread
for _, edge := range query.Repository.PullRequest.ReviewThreads.Edges {
if !edge.Node.IsResolved {
firstComment := edge.Node.Comments.Nodes[0]
// TODO: 最初のコメントを要約する
summary := firstComment.Body
prReviewThread := PrReviewThread{
ThreadEdge: edge,
Summary: summary,
}
unresolvedReviewThreads = append(unresolvedReviewThreads, prReviewThread)
}
}
prAndQuery := PullRequestAndQuery{
PullRequest: *pull,
PrReviewThreads: unresolvedReviewThreads,
}
pullRequests = append(pullRequests, prAndQuery)
}
}
}
developers := []Developer{
{Name: "github_demo_user_1", NameAbbr: "鈴", DiscordUserId: "4123421342135342343"},
{Name: "github_demo_user_2", NameAbbr: "渡", DiscordUserId: "4512451235213131432"},
{Name: "github_demo_user_3", NameAbbr: "田", DiscordUserId: "4474564574765677877"},
}
for _, developer := range developers {
var resultMessages []string
var messagesPerSection []string
lastRepoName := ""
// 同メンバーがPRに対して最初にコメントしたものがResolveされてないプルリクエストを抽出
messagesPerSection = []string{}
lastRepoName = ""
lastPullRequestTitle := ""
developerNames := funk.Map(developers, func(dev Developer) string {
return dev.Name
}).([]string)
for _, pull := range pullRequests {
threadEdges := pull.PrReviewThreads
pull := pull.PullRequest
// レビューコメント
for _, threadEdge := range threadEdges {
reviewThread := threadEdge.ThreadEdge
commentNodes := reviewThread.Node.Comments.Nodes
firstCommenter := commentNodes[0].Author.Login
lastCommentNode := commentNodes[len(commentNodes)-1]
lastCommenter := lastCommentNode.Author.Login
lastCommentUrl := lastCommentNode.Url
lastComment := lastCommentNode.Body
reviewee := *pull.User.Login
var destinations []string // 通知の宛先
for _, name := range developerNames {
if strings.Contains(lastComment, name) {
// 最後のコメントのメンションを通知の宛先に追加する
destinations = append(destinations, name)
}
}
containsMention := len(destinations) > 0
// 最後のコメントがメンション付きだったら、その宛先のみに通知する
if !containsMention {
if reviewee == firstCommenter {
// 最初のコメント者がレビューイ本人の場合は、本人がResolveしていないだけの可能性が高い
destinations = append(destinations, reviewee)
} else {
if firstCommenter == lastCommenter {
// 最初のコメント者と最後のコメント者が同じ場合
// レビューイには必ず通知する(レビューイが指摘事項修正していない可能性が高い)
// TODO: 上記のreviewee == firstCommenterのロジックとまとめられるかも
destinations = append(destinations, reviewee)
// 最初のコメント者への通知有無チェック
// TODO:このロジックは要再検討かも
shouldNotifyToFirstCommenter := false
for _, comment := range commentNodes {
if comment.Author.Login == reviewee {
// スレッド内にrevieweeのコメントが含まれている場合は最初のコメント者にも通知する(コメント者が単純にResolveし忘れの可能性が高い)
shouldNotifyToFirstCommenter = true
}
}
if shouldNotifyToFirstCommenter {
destinations = append(destinations, firstCommenter)
}
} else {
// 最初のコメント者と最後のコメント者が異なる場合は、最初のコメント者とレビューイ両方に通知する(レビューイが修正した内容を最初のコメント者が確認してない場合や最後のコメントがレビューイで「〜修正します」などのコメントの場合があるので両者に通知が必要)
destinations = append(destinations, reviewee) // TODO: 上記のreviewee == firstCommenterのロジックとまとめられるかも
destinations = append(destinations, firstCommenter)
}
}
}
if slices.Contains(destinations, developer.Name) {
// TODO: 通知メッセージを作成する
}
}
}
// TODO: 今後、機能拡張はここに実装(例:各開発メンバーがレビュアーかつ未レビューのプルリクエストを抽出する)
}
}
長くなるので、続きはまた次回で...