変態DevOpserが構築するGitHub PRのresolveし忘れリマインダー①（コメントはGemini APIで関西弁で要約）

## はじめに

1日324回DevOpsのことを考えてしまう変態子持ちflutterエンジニアです。
妻からは育児中にいつも~~DevOpsのことばかり考えるな~~スマホばかりいじるなと言われ、悪戦苦闘の毎日です。。
...そんなことはどうでも良いんです、ハイ。本題に行きましょう。

私はモバイルアプリのチーム開発をしておりますが、バージョン管理はGitHubが使われてます。
チーム開発において、レビュワーからコメントがあったものは、レビュワーが[resolve](https://docs.github.com/ja/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/commenting-on-a-pull-request#%E4%BC%9A%E8%A9%B1%E3%82%92%E8%A7%A3%E6%B1%BA%E3%81%99%E3%82%8B)を判断すべきだと思ってますが、私を含めてチーム内で、resolveのし忘れがたまにあります。
そこで勝手に、Discordでresolveすべき人に毎朝通知したいと思い、実装してみました。
「resolveし忘れリマインダーの構築」プロジェクトが始まりました。
（結局このリマインダーは実際の業務で不採用となりましたが、まぁ遊び半分で実装しただけなので...）

## GitHub GraphQL APIとGoを活用した、resolveし忘れリマインダーの構築

### システムの流れ
まずは大まかな処理を説明します。
毎朝GitHub ActionsからGo言語のスクリプトを実行します。
（2から6が同スクリプトで実行されます）

1. 毎朝定時にGitHub Actionsを実行
2. 未解決のPRのスレッドを取得
3. 各スレッドごとに、通知対象のアカウントを決める
4. 最初のコメントをGemini APIで要約（関西弁で）
5. DiscordのBotから通知対象のアカウントにダイレクトメッセージを送信

ダイレクトメッセージの中身がこんな感じになります。

<メッセージ例>
![スクリーンショット 2024-01-07 10.10.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/0f935f8e-6785-6eb6-d3c4-1a2f685a19b6.png)

順番に詳細を見ていきましょう。

### Goの環境構築
ここは割愛で...

### 毎朝定時にGitHub Actionsを実行
開発に使用するレポジトリの`.github/workflows`フォルダ配下にyamlファイルを以下のように実装します。
`GITHUB_TOKEN`はGitHub Secrets[^1]を使います。
```yaml:send_github_unresolved_comment.yaml
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](https://github.com/google/go-github)のパッケージが使えそうだったのですが、どうやらGitHub API v3を使っているためか、「UnresolvedのPRのスレッド」という条件で絞れないようでした。
そこでGraphQL API v4を使う必要があるのですが、同レポジトリでも勧められている[shurcooL/githubv4](https://github.com/shurcooL/githubv4)を使う必要があります。

ただ、UnresolvedのPRのスレッドの抽出のためには、恐らくレポジトリのownerとレポジトリ名、PRの番号を指定する必要がありそうです。なので厄介ですが、go-githubで、あるOrganizationの全てのレポジトリからOpenのPRを抽出し、その後shurcooL/githubv4で、その中からUnresolvedのスレッドを抽出するという合わせ技を使います。

#### あるOrganizationの全てのレポジトリからOpenのPRを抽出する
まずはgo-githubの出番です。以下のようにGoの実装をします。ファイル名は、上記yamlファイルで指定したものです。処理順としては、あるOrganizationの全てのレポジトリ一覧を取得し、各レポジトリのStateがOpenのプルリクエストの一覧を取得する感じです。

```golang:send_github_unresolved_comment.go
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 {`内に全て実装するのではなく、
今後、機能拡張するのを見越して、実装を分けています。

```golang:send_github_unresolved_comment.go
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するのを忘れていた場合
などなど....

試行錯誤しましたが、以下のロジックに落ち着きました。

- **そのスレッドの最後のコメントがメンション付きの場合**
  → <INS>メンションされたアカウントのみに通知する</INS>
    理由：同アカウントに確認依頼を求めている可能性が高いため
- **最初のコメント者がレビューイ本人の場合**
  → <INS>レビューイのみに通知する</INS>
    理由：レビューイ本人がResolveしていないだけの可能性が高い
- **最初のコメント者と最後のコメント者が同じ場合**
  - **スレッド内にレビューイのコメントが含まれている場合**
    → <INS>最初のコメント者とレビューイに通知する</INS>
      理由：コメント者が単純にResolveし忘れの可能性やレビューイが指摘事項修正していない可能性が高い
  - **スレッド内にレビューイのコメントが含まれていない場合**
    → <INS>レビューイのみに通知する</INS>
      理由：レビューイが単に指摘事項修正していない可能性が高いため
- **最初のコメント者と最後のコメント者が異なる場合**
  → <INS>最初のコメント者とレビューイに通知する</INS>
    理由：レビューイが修正した内容を最初のコメント者が確認してない可能性や最後のコメントがレビューイで「〜修正します」などのコメントを残している可能性が高いため
    
上記のように通知先は複数の可能性がありますが、通知された人が「これもう私は確認したわよ」と思って、他の人に確認を委ねたいケースがあるかと思います。
そんな時のために、他に誰に通知が飛んだのかが分かれば、わざわざ確認依頼を投げなくても良いかと思い、上記メッセージ例に`(+田/渡)`のように記載した通り、他の通知先をスラッシュ区切りで通知メッセージに追加してみました。（GitHubユーザー名を表示するとメッセージが長くなってしまうので、略名を使ってみました）

以上のロジックを実装するにあたり、各アカウントの略名を使う必要があったり、ロジックが若干複雑だったりするので、まずは各アカウントの情報をモデル化（`Developer`）しました。
DiscordUserIdは後々の通知処理に使います。

```golang:send_github_unresolved_comment.go
import (
...(略)
)

type Developer struct {
	// GitHubユーザー名
	Name string

	// ユーザー名の略語
	NameAbbr string
 
	// DiscordのユーザーID
	DiscordUserId string
}

type Query struct {
...(略)
```

通知メッセージを作成する処理の実装は次のステップになりますが、そのTODOコメントを残し、以下のように実装します。

```golang:send_github_unresolved_comment.go
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: 今後、機能拡張はここに実装（例：各開発メンバーがレビュアーかつ未レビューのプルリクエストを抽出する）
	}
}
```

長くなるので、続きはまた次回で...

[^1]: 利用方法は[公式ページ](https://docs.github.com/ja/actions/security-guides/using-secrets-in-github-actions)を参照