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

# はじめに
この記事は[変態DevOpserが構築するGitHub PRのresolveし忘れリマインダー①（コメントはGemini APIで関西弁で要約）](https://qiita.com/u_ki0630/private/bf824659a8de290c2acd)の続きです。
一言にまとめると、GitHubのresolveし忘れリマインダーの構築をした話です。

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

### 最初のコメントをGemini APIで要約（関西弁で）
前回の記事で実装したロジックに基づいて、指定したアカウントに対して、スレッドの最初のコメントを要約し、マークダウン形式で（毎朝定時に）通知します。

通知メッセージは以下のような形式です。
```
未マージの各PRでResolveされてないコメント:
- レポジトリ名_1
  - PRのタイトル_1_1
    - [スレッドの最初のコメントの要約_1_1_1](スレッドの最後のコメントのURL_1_1_1)
    - [スレッドの最初のコメントの要約_1_1_2](スレッドの最後のコメントのURL_1_1_2)（+略名1/略名2）
  - PRのタイトル_1_2
    - [スレッドの最初のコメントの要約_1_2_1](スレッドの最後のコメントのURL_1_2_1)
- レポジトリ名_2
  - PRのタイトル_2_1
    - [スレッドの最初のコメントの要約_2_1_1](スレッドの最後のコメントのURL_2_1_1)（+略名2）
```
※ 略名1/略名2には、あらかじめ定義しておいた各GitHubアカウント名の略名が入ります。

<メッセージ例>
![スクリーンショット 2024-01-07 10.10.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/6591ac1d-90c6-ec49-5b87-67090ae19a3e.png)

### Gemini APIを使用したコメントの要約

Gemini APIを使用して、PRの各スレッドの最初のコメントを要約します。
API KEYは[この手順](https://ai.google.dev/tutorials/go_quickstart?hl=ja#set-up-api-key)で取得できます。
料金は2024/1/7時点では無料で、1分あたり最大60クエリが利用可能です。この無料期間は2024年初頭までで、それ以降は課金が必要になるようですが、料金もChatGPT APIと比べて非常に安いようです。[^1]
ただ、無料期間中は学習データとして使われてしまうので要注意です。

![スクリーンショット 2024-01-07 9.30.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/cfa2838f-f9d7-a497-3974-2991215f3e6e.png)

実装はこんな感じです。

```go
package main

import (
	"context"
	"fmt"
	"log"
	"os"

	"github.com/google/generative-ai-go/genai"
	"google.golang.org/api/option"
)

/*
 * コメントをGemini APIで関西弁で要約
 */
func summarizeComment(ctx context.Context, comment string) string {
	apiKey := os.Getenv("GEMINI_API_KEY")
	client, err := genai.NewClient(ctx, option.WithAPIKey(apiKey))
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()
	model := client.GenerativeModel("models/gemini-pro")
	prompt := fmt.Sprintf("あなたは Geminiによって訓練された言語モデルです。あなたの目的は、非常に経験豊富なソフトウェアエンジニアとして機能し、GitHub上に投稿されたコメントを要約することで、開発者の生産性を高めることです。以下文章がそのコメントですが、20字以内の関西弁の日本語に要約してください。ただ、URLは無視してください（閲覧権限がない場合もあるため）\n\n%s", comment)
	resp, err := model.GenerateContent(ctx, genai.Text(prompt))
	if err != nil {
		log.Printf("gemini-pro error:%s", err)
	} else {
		// Gemini APIの回答をキャストして取得する
		textPart, ok := resp.Candidates[0].Content.Parts[0].(genai.Text)
		if ok {
			return string(textPart)
		}
	}
	return comment
}
```

ちなみに、上記プロンプトに記載の通り、リンクが含まれたらそのURLは要約には含めないようにしています。
＜最初のコメント＞
ここは[foundation](https://pub.dev/packages/foundation)のパッケージの方がいいと思います。
＜要約＞
foundationのパッケージの方がええで〜

`GEMINI_API_KEY`はGitHub Secrets[^2]から取得しますが、yamlファイルに以下のように設定します。
```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 }}
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
```

ただし、同じPRに対する通知を連日送る場合は、全く同じ要約テキストにしたいし、Gemini APIのトークン消費を抑えるため、要約テキストをcsvファイルに保存し、同じPRに対する通知の場合は、Gemini APIを使うのではなく、以下のようにcsvファイルから取得するようにします。（省略部分は[前回の記事](https://qiita.com/u_ki0630/private/bf824659a8de290c2acd#各スレッドごとに通知対象のアカウントを決める)の実装を参照ください）

```golang:send_github_unresolved_comment.go
package main

import (
	"context"
	"encoding/csv"
	"fmt"
	"io"
	"log"
	"os"
	"slices"
	"strconv"
	"strings"
 
	"github.com/google/generative-ai-go/genai"
	"github.com/google/go-github/v37/github"
	"github.com/shurcooL/githubv4"
	"github.com/thoas/go-funk"
	"golang.org/x/oauth2"
	"google.golang.org/api/option"
)

const generatedSummaryFile = "generated_summary_file.csv"

...(省略)

type SummaryData struct {
	DatabaseId int
	Summary    string
}

func main() {
	summariesOfYesterday := []SummaryData{}
	summariesOfToday := []SummaryData{}

	// csvファイルから、PRスレッド毎の要約されたコメントを取得する（昨日のバッチでの作成分）
	if _, err := os.Stat(generatedSummaryFile); err == nil {
		file, err := os.Open(generatedSummaryFile)
		if err != nil {
			log.Fatal(err)
		}
		defer file.Close()

		reader := csv.NewReader(file)
		for {
			record, err := reader.Read()
			if err == io.EOF {
				break
			}
			if err != nil {
				log.Fatal(err)
			}

			databaseId, err := strconv.Atoi(record[0])
			if err != nil {
				fmt.Println("databaseId conversion error:", err)
			} else {
				summariesOfYesterday = append(summariesOfYesterday, SummaryData{DatabaseId: databaseId, Summary: record[1]})
			}
		}
	}

    ...(省略)
    for _, repository := range repositories {
        ...(省略)
        for _, pull := range pulls {
            ...(省略)
            if !*pull.Draft { // DraftのPRは対象外
                ...(省略)
				for _, edge := range query.Repository.PullRequest.ReviewThreads.Edges {
					if !edge.Node.IsResolved {
						firstComment := edge.Node.Comments.Nodes[0]
						summary := firstComment.Body
						// 昨日のバッチでの作成分のファイルに要約テキストが存在したら、それをそのまま使う
						found := false
						for _, d := range summariesOfYesterday {
							if d.DatabaseId == firstComment.DatabaseId {
								found = true
								summariesOfToday = append(summariesOfToday, SummaryData{DatabaseId: firstComment.DatabaseId, Summary: d.Summary})
								summary = d.Summary
								log.Printf("already firstComment.DatabaseId:%d summary:%s", firstComment.DatabaseId, summary)
								break
							}
						}

						if !found {
							// 要約したコメントがcsvファイルに存在しなければ、最初のコメントをGemini APIで要約
							summary = summarizeComment(ctx, summary)

							summariesOfToday = append(summariesOfToday, SummaryData{DatabaseId: firstComment.DatabaseId, Summary: summary})
							log.Printf("firstComment.DatabaseId:%d summary:%s", firstComment.DatabaseId, summary)
						}

						...(省略)
					}
				}
    
                ...(省略)
			}
		}
	}

    // 使用した要約コメントをcsvファイルに書き込む（上書き）
	file, err := os.Create(generatedSummaryFile)
	if err != nil {
		log.Fatal(err)
	}
	defer file.Close()
	writer := csv.NewWriter(file)
	defer writer.Flush()
	for _, d := range summariesOfToday {
		writer.Write([]string{strconv.Itoa(d.DatabaseId), d.Summary})
	}
	writer.Flush()
    
    ...(省略)
}

func summarizeComment(ctx context.Context, comment string) string {
    ...(省略)
}
```

csvファイルは以下のように生成されます。
![スクリーンショット 2024-01-07 10.15.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/bc76bb98-00d6-2f06-2cea-adc42b55ba14.png)
要約が気に入らない時は、手動でその行を削除したら、再度要約し直してくれます。

ただ、このままではGitHub Workflowが完了した時に同ファイルが消失してしまいます。[Workflowの成果物（artifact）](https://docs.github.com/ja/actions/using-workflows/storing-workflow-data-as-artifacts)として保存しても同一Workflow内でしか再利用できないようです。もっといい方法があるかもしれませんが、同じレポジトリ内にコミットすることで解決しました。

まず、Organization全体のSettings > Actions > General > Workflow permissionsを以下のように`Read and write permissions`に設定します。そして該当レポジトリも同様に設定します。（前者なしでは後者は設定不可なようです）Organization全体のデフォルトのpermissiomが変わってしまうので、留意が必要です。

![スクリーンショット 2024-01-08 21.56.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/fb8f6f9f-c2b3-ee87-b6c8-34810b3ee795.png)

そして、yamlファイルを以下のように修正します。（`workflows`フォルダ内にコミットするのはWorkflowを更新することになり、よろしくないみたいなので`.github`フォルダにしました）

```yaml:send_github_unresolved_comment.yaml
name: Send github unresolved comment to discord

on:
  workflow_dispatch:
  schedule:
    # 月〜金の午前8時に処理を実行する。(UTC指定)
    - cron: "0 21 * * 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: test
        run: mv .github/generated_summary_file.csv .github/workflows/script/generated_summary_file.csv

      - name: Fetch GitHub unresolved comments and send Discord notification
        run: go run send_github_unresolved_comment.go
        working-directory: .github/workflows/script
        env:
          DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
          GITHUB_TOKEN: ${{ secrets.PERSONAL_GITHUB_TOKEN }}
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
          HOURS_TO_CHECK: ${{ vars.HOURS_TO_CHECK_UNUPDATED_PULL_REQUEST }}

      - name: Commit summarized comments file
        run: |
          mv .github/workflows/script/generated_summary_file.csv .github/generated_summary_file.csv
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git config user.name "github-actions[bot]"
          git add .github/generated_summary_file.csv
          if ! git diff --cached --quiet; then
            git commit -m "update generated_summary_file"
            git push
          fi
```

上記の`user.email`と`user.name`を指定することで、以下のように`github-actions[bot]`というBotのコミット扱いにしてくれます。
![スクリーンショット 2024-01-08 21.54.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/554bf3b8-191f-9410-15fd-1cb474b34a5c.png)

### DiscordのBotから通知対象のアカウントにダイレクトメッセージを送信
あとは上記のマークダウン形式のメッセージを作成し、通知対象のアカウントに通知するだけです。今回は特定のチャンネルに通知するのではなく、対象のアカウント宛に直接届くダイレクトメッセージにしました。
[discordgo](https://github.com/bwmarrin/discordgo)というパッケージを用いますが、導入手順は[この記事](https://zenn.dev/saldra/articles/4b4dbca7b8c230)を参照ください...

以下のように実装しました。
（省略部分は[前回の記事](https://qiita.com/u_ki0630/private/bf824659a8de290c2acd#各スレッドごとに通知対象のアカウントを決める)の実装を参照ください）

```golang:send_github_unresolved_comment.go
package main

import (
	"context"
	"encoding/csv"
	"fmt"
	"io"
	"log"
	"os"
	"slices"
	"strconv"
	"strings"

	"github.com/bwmarrin/discordgo"
	"github.com/google/generative-ai-go/genai"
	"github.com/google/go-github/v37/github"
	"github.com/shurcooL/githubv4"
	"github.com/thoas/go-funk"
	"golang.org/x/oauth2"
	"google.golang.org/api/option"
)

...(省略)

func main() {
	...(省略)
	for _, repository := range repositories {
		...(省略)
	}

	...(省略)

	for _, developer := range developers {
		...(省略)
		for _, pull := range pullRequests {
			threadEdges := pull.PrReviewThreads
			pull := pull.PullRequest
			// レビューコメント
			for _, threadEdge := range threadEdges {
				...(省略)

				if slices.Contains(destinations, developer.Name) {
					otherDstDevelopers := funk.Filter(developers, func(dev Developer) bool {
						return slices.Contains(destinations, dev.Name) && dev.Name != developer.Name
					}).([]Developer)
					otherDstDeveloperAbbrNames := funk.Map(otherDstDevelopers, func(dev Developer) string {
						return dev.NameAbbr
					}).([]string)
					if *pull.Head.Repo.Name != lastRepoName {
						messagesPerSection = append(messagesPerSection, fmt.Sprintf("- %s", *pull.Head.Repo.Name))
					}
					if *pull.Title != lastPullRequestTitle {
						messagesPerSection = append(messagesPerSection, fmt.Sprintf("  - %s", *pull.Title))
					}
					// 他に通知されたメンバーが誰なのかもメッセージに含める（例：鈴木宛のメッセージの場合、「・〜（+田/渡）」）
					otherDestinationsStr := ""
					if len(otherDstDeveloperAbbrNames) > 0 {
						otherDestinationsStr = "（+" + strings.Join(otherDstDeveloperAbbrNames, "/") + "）"
					}
					messagesPerSection = append(messagesPerSection, fmt.Sprintf("    - [%s](%s)%s", threadEdge.Summary, lastCommentUrl, otherDestinationsStr))
					lastRepoName = *pull.Head.Repo.Name
					lastPullRequestTitle = *pull.Title
				}
			}
		}

		if len(messagesPerSection) > 0 {
			resultMessages = append(resultMessages, fmt.Sprintf("**未マージの各PRでResolveされてないコメント:**\n%s", strings.Join(messagesPerSection, "\n")))
		} else {
			log.Printf("未マージの各PRでResolveされてないコメントはありませんでした（GitHubユーザー:%s、DiscordユーザーID:%s）", developer.Name, developer.DiscordUserId)
		}

		// TODO: 今後、機能拡張はここに実装（例：各開発メンバーがレビュアーかつ未レビューのプルリクエストを抽出する）

		if len(resultMessages) > 0 {
			// Discordのダイレクトメッセージを送信する
			message := strings.Join(resultMessages, "\n")
			err = postToDiscord(message, developer.DiscordUserId, developer.Name)
			if err != nil {
				log.Fatal("Error posting message to Discord:", err.Error())
			}
		}
	}
}

/*
 * コメントをGemini APIで関西弁で要約
 */
func summarizeComment(ctx context.Context, comment string) string {
	...(省略)
}

func postToDiscord(message string, discordUserId string, githubUserName string) error {
	// Discord の Bot トークンを設定する
	token := os.Getenv("DISCORD_BOT_TOKEN")

	// Discord クライアントを作成
	dg, err := discordgo.New("Bot " + token)
	if err != nil {
		return err
	}

	// メッセージを送信する
	channel, err := dg.UserChannelCreate(discordUserId)
	if err != nil {
		return err
	}
	msg := discordgo.Message{
		Content: message,
	}
	_, err = dg.ChannelMessageSend(channel.ID, msg.Content)
	if err != nil {
		return err
	}

	log.Printf("正常にDiscordに投稿されました（GitHubユーザー:%s、DiscordユーザーID:%s）\nメッセージ:%s", githubUserName, discordUserId, message)

	return nil
}
```

上記の`DISCORD_BOT_TOKEN`は上記の`send_github_unresolved_comment.yaml`のコードに記載しています。

前回の記事の実装と合わせて、完成形はこんな感じです。
```golang:
package main

import (
	"context"
	"encoding/csv"
	"fmt"
	"io"
	"log"
	"os"
	"slices"
	"strconv"
	"strings"

	"github.com/bwmarrin/discordgo"
	"github.com/google/generative-ai-go/genai"
	"github.com/google/go-github/v37/github"
	"github.com/shurcooL/githubv4"
	"github.com/thoas/go-funk"
	"golang.org/x/oauth2"
	"google.golang.org/api/option"
)

const generatedSummaryFile = "generated_summary_file.csv"

type Developer struct {
	// GitHubfユーザー名
	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
}

type SummaryData struct {
	DatabaseId int
	Summary    string
}

func main() {
	summariesOfYesterday := []SummaryData{}
	summariesOfToday := []SummaryData{}

	// csvファイルから、PRスレッド毎の要約されたコメントを取得する（昨日のバッチでの作成分）
	if _, err := os.Stat(generatedSummaryFile); err == nil {
		file, err := os.Open(generatedSummaryFile)
		if err != nil {
			log.Fatal(err)
		}
		defer file.Close()

		reader := csv.NewReader(file)
		for {
			record, err := reader.Read()
			if err == io.EOF {
				break
			}
			if err != nil {
				log.Fatal(err)
			}

			databaseId, err := strconv.Atoi(record[0])
			if err != nil {
				fmt.Println("databaseId conversion error:", err)
			} else {
				summariesOfYesterday = append(summariesOfYesterday, SummaryData{DatabaseId: databaseId, Summary: record[1]})
			}
		}
	}

	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]
						summary := firstComment.Body
						// 昨日のバッチでの作成分のcsvファイルに要約テキストが存在したら、それをそのまま使う
						found := false
						for _, d := range summariesOfYesterday {
							if d.DatabaseId == firstComment.DatabaseId {
								found = true
								summariesOfToday = append(summariesOfToday, SummaryData{DatabaseId: firstComment.DatabaseId, Summary: d.Summary})
								summary = d.Summary
								log.Printf("already firstComment.DatabaseId:%d summary:%s", firstComment.DatabaseId, summary)
								break
							}
						}

						if !found {
							// 要約したコメントがcsvファイルに存在しなければ、最初のコメントをGemini APIで要約
							summary = summarizeComment(ctx, summary)

							summariesOfToday = append(summariesOfToday, SummaryData{DatabaseId: firstComment.DatabaseId, Summary: summary})
							log.Printf("firstComment.DatabaseId:%d summary:%s", firstComment.DatabaseId, summary)
						}

						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"},
	}

	// 使用した要約コメントをcsvファイルに書き込む（上書き）
	file, err := os.Create(generatedSummaryFile)
	if err != nil {
		log.Fatal(err)
	}
	defer file.Close()
	writer := csv.NewWriter(file)
	defer writer.Flush()
	for _, d := range summariesOfToday {
		writer.Write([]string{strconv.Itoa(d.DatabaseId), d.Summary})
	}
	writer.Flush()

	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) {
					otherDstDevelopers := funk.Filter(developers, func(dev Developer) bool {
						return slices.Contains(destinations, dev.Name) && dev.Name != developer.Name
					}).([]Developer)
					otherDstDeveloperAbbrNames := funk.Map(otherDstDevelopers, func(dev Developer) string {
						return dev.NameAbbr
					}).([]string)
					if *pull.Head.Repo.Name != lastRepoName {
						messagesPerSection = append(messagesPerSection, fmt.Sprintf("- %s", *pull.Head.Repo.Name))
					}
					if *pull.Title != lastPullRequestTitle {
						messagesPerSection = append(messagesPerSection, fmt.Sprintf("  - %s", *pull.Title))
					}
					// 他に通知されたメンバーが誰なのかもメッセージに含める（例：鈴木宛のメッセージの場合、「・〜（+田/渡）」）
					otherDestinationsStr := ""
					if len(otherDstDeveloperAbbrNames) > 0 {
						otherDestinationsStr = "（+" + strings.Join(otherDstDeveloperAbbrNames, "/") + "）"
					}
					messagesPerSection = append(messagesPerSection, fmt.Sprintf("    - [%s](%s)%s", threadEdge.Summary, lastCommentUrl, otherDestinationsStr))
					lastRepoName = *pull.Head.Repo.Name
					lastPullRequestTitle = *pull.Title
				}
			}
		}

		if len(messagesPerSection) > 0 {
			resultMessages = append(resultMessages, fmt.Sprintf("**未マージの各PRでResolveされてないコメント:**\n%s", strings.Join(messagesPerSection, "\n")))
		} else {
			log.Printf("未マージの各PRでResolveされてないコメントはありませんでした（GitHubユーザー:%s、DiscordユーザーID:%s）", developer.Name, developer.DiscordUserId)
		}

		// TODO: 今後、機能拡張はここに実装（例：各開発メンバーがレビュアーかつ未レビューのプルリクエストを抽出する）

		if len(resultMessages) > 0 {
			// Discordのダイレクトメッセージを送信する
			message := strings.Join(resultMessages, "\n")
			err = postToDiscord(message, developer.DiscordUserId, developer.Name)
			if err != nil {
				log.Fatal("Error posting message to Discord:", err.Error())
			}
		}
	}
}

/*
 * コメントをGemini APIで関西弁で要約
 */
func summarizeComment(ctx context.Context, comment string) string {
	apiKey := os.Getenv("GEMINI_API_KEY")
	client, err := genai.NewClient(ctx, option.WithAPIKey(apiKey))
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()
	model := client.GenerativeModel("models/gemini-pro")
	prompt := fmt.Sprintf("あなたは Geminiによって訓練された言語モデルです。あなたの目的は、非常に経験豊富なソフトウェアエンジニアとして機能し、GitHub上に投稿されたコメントを要約することで、開発者の生産性を高めることです。以下文章がそのコメントですが、20字以内の関西弁の日本語に要約してください。ただ、URLは無視してください（閲覧権限がない場合もあるため）\n\n%s", comment)
	resp, err := model.GenerateContent(ctx, genai.Text(prompt))
	if err != nil {
		log.Printf("gemini-pro error:%s", err)
	} else {
		// Gemini APIの回答をキャストして取得する
		textPart, ok := resp.Candidates[0].Content.Parts[0].(genai.Text)
		if ok {
			return string(textPart)
		}
	}
	return comment
}

func postToDiscord(message string, discordUserId string, githubUserName string) error {
	// Discord の Bot トークンを設定する
	token := os.Getenv("DISCORD_BOT_TOKEN")

	// Discord クライアントを作成
	dg, err := discordgo.New("Bot " + token)
	if err != nil {
		return err
	}

	// メッセージを送信する
	channel, err := dg.UserChannelCreate(discordUserId)
	if err != nil {
		return err
	}
	msg := discordgo.Message{
		Content: message,
	}
	_, err = dg.ChannelMessageSend(channel.ID, msg.Content)
	if err != nil {
		return err
	}

	log.Printf("正常にDiscordに投稿されました（GitHubユーザー:%s、DiscordユーザーID:%s）\nメッセージ:%s", githubUserName, discordUserId, message)

	return nil
}
```

これでresolveし忘れリマインダーの完成です。

# 最後に
この記事では、GitHubのGraphQL APIとGoを使用して、未解決のPRスレッドをDiscordで通知するシステムの構築方法を紹介しました。開発チームのコミュニケーションを効率化し、PRの解決を促進することに少しでも貢献できれば幸いです。

[^1]: https://weel.co.jp/media/gemini-pro-api#index_id2
[^2]: 利用方法は[公式ページ](https://docs.github.com/ja/actions/security-guides/using-secrets-in-github-actions)を参照