はじめに
この記事は変態DevOpserが構築するGitHub PRのresolveし忘れリマインダー①(コメントはGemini APIで関西弁で要約)の続きです。
一言にまとめると、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アカウント名の略名が入ります。
Gemini APIを使用したコメントの要約
Gemini APIを使用して、PRの各スレッドの最初のコメントを要約します。
API KEYはこの手順で取得できます。
料金は2024/1/7時点では無料で、1分あたり最大60クエリが利用可能です。この無料期間は2024年初頭までで、それ以降は課金が必要になるようですが、料金もChatGPT APIと比べて非常に安いようです。1
ただ、無料期間中は学習データとして使われてしまうので要注意です。
実装はこんな感じです。
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のパッケージの方がいいと思います。
<要約>
foundationのパッケージの方がええで〜
GEMINI_API_KEY
はGitHub Secrets2から取得しますが、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ファイルから取得するようにします。(省略部分は前回の記事の実装を参照ください)
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ファイルは以下のように生成されます。
要約が気に入らない時は、手動でその行を削除したら、再度要約し直してくれます。
ただ、このままではGitHub Workflowが完了した時に同ファイルが消失してしまいます。Workflowの成果物(artifact)として保存しても同一Workflow内でしか再利用できないようです。もっといい方法があるかもしれませんが、同じレポジトリ内にコミットすることで解決しました。
まず、Organization全体のSettings > Actions > General > Workflow permissionsを以下のようにRead and write permissions
に設定します。そして該当レポジトリも同様に設定します。(前者なしでは後者は設定不可なようです)Organization全体のデフォルトのpermissiomが変わってしまうので、留意が必要です。
そして、yamlファイルを以下のように修正します。(workflows
フォルダ内にコミットするのはWorkflowを更新することになり、よろしくないみたいなので.github
フォルダにしました)
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のコミット扱いにしてくれます。
DiscordのBotから通知対象のアカウントにダイレクトメッセージを送信
あとは上記のマークダウン形式のメッセージを作成し、通知対象のアカウントに通知するだけです。今回は特定のチャンネルに通知するのではなく、対象のアカウント宛に直接届くダイレクトメッセージにしました。
discordgoというパッケージを用いますが、導入手順はこの記事を参照ください...
以下のように実装しました。
(省略部分は前回の記事の実装を参照ください)
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
のコードに記載しています。
前回の記事の実装と合わせて、完成形はこんな感じです。
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の解決を促進することに少しでも貢献できれば幸いです。