はじめに
本記事はSupership株式会社 Advent Calendar 2016の19日目のエントリーになります。
おはようございます。こんにちは。こんばんは。Supership株式会社の @itosho と申します。
平日は主にサーバーサイドエンジニアとして、自社サービスである「nanapi」や「Poptalk」の開発を頑張っています。ちなみに、休日は主にオタクとして、アイドルの応援を頑張っています。
go2slackとは?
いきなりですが、皆さんはSlack純正のGithub連携が使い辛いと思ったことはありませんか?僕はあります。
具体的に何が辛いかと言うと…
- メンションやハイライトが効かない
- そもそもGithubとSlackでIDが違う場合はメンション出来ない
あたりです。
そして、それを解決するのが本日ご紹介する go2slack
(僕が社内で勝手に呼んでいる)です。
※このあたりの解決方法って実はちょっとググると色々知見やツールが出てくるのですが、ちょうどGo言語を勉強していた頃でGoで何かつくってみたかったので、勉強がてら自分でつくってみた次第であります。
連携イメージ
そんな大したことはしておらず、HerokuにGithubと連携した独自のWebhook用のAPIを用意して Github->Heroku->Slack
という流れで連携しているだけです。
go2slackのつくりかた
HerokuでのGolang環境構築方法
最新の構築方法は公式サイトを読むのが一番確実ですが、ざっくりこんな流れで出来ると思います。
①ローカルのGOPATH内にリポジトリをつくる
GithubへのCloneやPush等は適宜行ってください。併せて、事前にこんな感じでProcfileを作成します。
$ echo 'web: go2slack' > Procfile
②godepで依存関係を保存する
最近のGolangはglideを使うことが多いと思いますが、Herokuではgodepを利用します。
$ go get github.com/kr/godep
$ godep save
③Buildpackを追加する
Heroku上でアプリをビルドするためのスクリプトを追加します。
$ heroku create -b https://github.com/kr/heroku-buildpack-go.git
④HerokuへPush
あとはいつものようにHerokuへPushしておしまいです。
$ git push heroku master
ちなみに、Appは無料であるfree dynosを使っています。
APIの中身
では、APIのコードをみていきましょう。
(実はこのgo2slack、Github以外にもいくつか連携しているサービスがあるので、ここに掲載するコードは抜粋かつ多少改変しております。)
設定ファイル
まずは以下のような設定ファイルを用意します。
{
"accounts": {
"@itosho525": "@itosho",
"@supersushio": "@sushio"
},
"repositories": {
"hogehoge-ios": "/abc123/a123b456c789",
"hogehoge-android": "/xyz987/x987y654z321",
}
}
accounts
では、GithubのユーザーIDとSlackのユーザーIDを紐付けます。
keyがGithubのIDで、valueがSlackのIDになります。
repositories
では、Githubのリポジトリと通知したいSlackのチャンネルを紐付けます。
keyがGithubのリポジトリ名で、valueがSlackのIncoming Webhookで生成されるURLのパスになります。
メインパッケージ:main関数編
メインパッケージは関数単位で説明していきます。
最初に main
関数ではGithubからのアクセスを受けるAPIのエンドポイントを作成します。
package main
import (
"go2slack/gh"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"math/rand"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/ant0ine/go-json-rest/rest"
"github.com/google/go-github/github"
)
func main() {
port := os.Getenv("PORT")
api := rest.NewApi()
api.Use(rest.DefaultDevStack...)
router, err := rest.MakeRouter(
rest.Post("/github/events", PostGithubEvents),
)
if err != nil {
log.Fatal(err)
}
api.SetApp(router)
log.Fatal(http.ListenAndServe(":"+port, api.MakeHandler()))
}
よくある感じですが、Herokuの場合、ポートが動的に変わるので注意してください。
また、Google先生がGithubAPIを扱いやすくしてくれるGoのライブラリを公開してくださっているので、そいつを使っています。
あと、そのライブラリにない、もしくは使いづらい署名検証等の処理をまとめた独自の go2slack/gh
パッケージをimportしています。
以上より、Github側で設定するWebhookのURLは、
https://hogehoge.herokuapp.com/github/events
という感じになります。
Gihub連携をよしなにしてくれるパッケージ
上述の独自のパッケージです。
package gh
import (
"crypto/hmac"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"io/ioutil"
"regexp"
"strings"
"github.com/ant0ine/go-json-rest/rest"
)
// HookContext はGithubから受け取るJSONデータを格納する構造体
type HookContext struct {
Signature string
Event string
ID string
Payload []byte
}
// Config はGithubとSlackの連携情報を格納する構造体
type Config struct {
Accounts map[string]string `json:"accounts"`
Repositories map[string]string `json:"repositories"`
}
const key = "********" // よしなに変更してください!
var r = regexp.MustCompile(`@[a-zA-Z0-9_\-]+`)
// error定義まとめ
var (
ErrNotSignature = errors.New("No signature!")
ErrNotEvent = errors.New("No event!")
ErrNotEventID = errors.New("No event id")
ErrInvalidSignature = errors.New("Invalid signature")
)
// ParseHook はGithubのリクエストをパースする関数
func (hc *HookContext) ParseHook(req *rest.Request) error {
secret := []byte(key)
if hc.Signature = req.Header.Get("x-hub-signature"); len(hc.Signature) == 0 {
return ErrNotSignature
}
if hc.Event = req.Header.Get("x-github-event"); len(hc.Event) == 0 {
return ErrNotEvent
}
if hc.ID = req.Header.Get("x-github-delivery"); len(hc.ID) == 0 {
return ErrNotEventID
}
body, err := ioutil.ReadAll(req.Body)
if err != nil {
return err
}
defer req.Body.Close()
if !verifySignature(secret, hc.Signature, body) {
return ErrInvalidSignature
}
hc.Payload = body
return nil
}
// ParseFile は設定ファイルをパースする関数
func ParseFile(filename string) (*Config, error) {
c := Config{}
jsonString, err := ioutil.ReadFile(filename)
if err != nil {
return nil, err
}
err = json.Unmarshal(jsonString, &c)
if err != nil {
return nil, err
}
return &c, nil
}
// ReplaceComment はコメント内のアカウント情報を置き換える関数
func ReplaceComment(comment string, conf *Config) string {
matches := r.FindAllStringSubmatch(comment, -1)
for _, val := range matches {
slackName, _ := conf.Accounts[val[0]]
comment = strings.Replace(comment, val[0], slackName, -1)
}
return comment
}
// GetEndPoint はSlack投稿先を取得する関数
func GetEndPoint(repositoryName string, conf *Config) string {
endPoint, _ := conf.Repositories[repositoryName]
return endPoint
}
func signBody(secret, body []byte) []byte {
computed := hmac.New(sha1.New, secret)
computed.Write(body)
return []byte(computed.Sum(nil))
}
func verifySignature(secret []byte, signature string, body []byte) bool {
const signaturePrefix = "sha1="
const signatureLength = 45 // len(SignaturePrefix) + len(hex(sha1))
if len(signature) != signatureLength || !strings.HasPrefix(signature, signaturePrefix) {
return false
}
actual := make([]byte, 20)
hex.Decode(actual, []byte(signature[len(signaturePrefix):]))
return hmac.Equal(signBody(secret, body), actual)
}
ユーザーIDの置換や通知先チャンネルの取得もこの関数で行っています。
メインパッケージ:PostGithubEvents関数編
main関数から呼び出される PostGithubEvents
関数の中身です。
とってもナイーブな実装です…。
// PostGithubEvents はGithubイベント連携関数
func PostGithubEvents(w rest.ResponseWriter, r *rest.Request) {
hc := gh.HookContext{}
err := hc.ParseHook(r)
if err != nil {
rest.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if len(hc.Payload) == 0 {
rest.Error(w, "Payload Size is 0.", http.StatusInternalServerError)
return
}
conf, err := gh.ParseFile("./config.json")
if err != nil {
rest.Error(w, err.Error(), http.StatusInternalServerError)
return
}
text := ""
repositoryName := ""
switch hc.Event {
case "issues":
evt := github.IssuesEvent{}
if err := json.Unmarshal(hc.Payload, &evt); err != nil {
rest.Error(w, err.Error(), http.StatusInternalServerError)
}
repositoryName = *evt.Repo.Name
if *evt.Action == "opened" {
text = fmt.Sprintf("%v :github-status-red: *【%v】%v* :github-status-red: \n", text, repositoryName, *evt.Issue.Title)
text = fmt.Sprintf("%v%v\n", text, *evt.Issue.HTMLURL)
text = fmt.Sprintf("%v>Issue opened by: %v\n", text, *evt.Issue.User.Login)
comment := gh.ReplaceComment(*evt.Issue.Body, conf)
text = fmt.Sprintf("%v\n%v\n", text, comment)
} else if *evt.Action == "closed" {
text = fmt.Sprintf("%v :github-status-green: *【%v】%v* :github-status-green: \n", text, repositoryName, *evt.Issue.Title)
text = fmt.Sprintf("%v%v\n", text, *evt.Issue.HTMLURL)
text = fmt.Sprintf("%v>Issue closed by: %v\n", text, *evt.Sender.Login)
}
case "pull_request":
evt := github.PullRequestEvent{}
if err := json.Unmarshal(hc.Payload, &evt); err != nil {
rest.Error(w, err.Error(), http.StatusInternalServerError)
}
repositoryName = *evt.Repo.Name
if *evt.Action == "opened" {
text = fmt.Sprintf("%v :github-status-red: *【%v】%v* :github-status-red: \n", text, repositoryName, *evt.PullRequest.Title)
text = fmt.Sprintf("%v%v\n", text, *evt.PullRequest.HTMLURL)
text = fmt.Sprintf("%v>PullRequest opened by: %v\n", text, *evt.PullRequest.User.Login)
comment := gh.ReplaceComment(*evt.PullRequest.Body, conf)
text = fmt.Sprintf("%v\n%v\n", text, comment)
} else if *evt.Action == "closed" {
text = fmt.Sprintf("%v :github-status-green: *【%v】%v* :github-status-green: \n", text, repositoryName, *evt.PullRequest.Title)
text = fmt.Sprintf("%v%v\n", text, *evt.PullRequest.HTMLURL)
text = fmt.Sprintf("%v>PullRequest closed by: %v\n", text, *evt.Sender.Login)
}
case "issue_comment":
evt := github.IssueCommentEvent{}
if err := json.Unmarshal(hc.Payload, &evt); err != nil {
rest.Error(w, err.Error(), http.StatusInternalServerError)
}
repositoryName = *evt.Repo.Name
if *evt.Action == "created" {
text = fmt.Sprintf("%v :github-status-orange: *【%v】%v* :github-status-orange: \n", text, repositoryName, *evt.Issue.Title)
text = fmt.Sprintf("%v%v\n", text, *evt.Comment.HTMLURL)
text = fmt.Sprintf("%v>Comment created by: %v\n", text, *evt.Comment.User.Login)
comment := gh.ReplaceComment(*evt.Comment.Body, conf)
text = fmt.Sprintf("%v\n%v\n", text, comment)
}
case "pull_request_review_comment":
evt := github.PullRequestReviewCommentEvent{}
if err := json.Unmarshal(hc.Payload, &evt); err != nil {
rest.Error(w, err.Error(), http.StatusInternalServerError)
}
repositoryName = *evt.Repo.Name
if *evt.Action == "created" {
text = fmt.Sprintf("%v :github-status-orange: *[%v]%v* :github-status-orange: \n", text, repositoryName, *evt.PullRequest.Title)
text = fmt.Sprintf("%v%v\n", text, *evt.Comment.HTMLURL)
text = fmt.Sprintf("%v>ReviewComment created by: %v\n", text, *evt.Comment.User.Login)
comment := gh.ReplaceComment(*evt.Comment.Body, conf)
text = fmt.Sprintf("%v\n%v\n", text, comment)
}
default:
}
if text != "" && repositoryName != "" {
endPoint := gh.GetEndPoint(repositoryName, conf)
res, err := sendToSlack(fmt.Sprintf("/services/T0BN2TZV4%v", endPoint), text)
if err != nil {
w.WriteJson(fmt.Sprintf(`{"res": "%v", "error": "%v"}`, res, err))
} else {
w.WriteJson(fmt.Sprintf(`{"res": "%v"}`, res))
}
} else {
w.WriteJson(`{"res": "text or repository's name not exist"}`)
}
}
メンションが必要なGithubのイベントは、
- issues
- pull_request
- issue_comment
- pull_request_review_comment
だけなので、これらのイベントのコメントをいい感じにフォーマットして、Slackに送りつけています。
(Github側の設定でどのイベントをフックさせるか選択出来るので、予め上記のイベントだけをフックさせる設定にしておくことをオススメします。)
また :github-status-hoge:
は弊社のSlackに登録されている絵文字なので適宜変更してください。
メインパッケージ:sendToSlack関数編
最後は sendToSlack
関数です。文字通りSlack送信するための関数です。
func sendToSlack(path string, text string) (string, error) {
slackURL := "https://hooks.slack.com"
slackPath := path
u, _ := url.ParseRequestURI(slackURL)
u.Path = slackPath
urlStr := fmt.Sprintf("%v", u)
data := url.Values{}
data.Set("payload", "{\"text\": \""+text+"\", \"link_names\": 1}")
client := &http.Client{}
req, _ := http.NewRequest("POST", urlStr, strings.NewReader(data.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, err := client.Do(req)
defer res.Body.Close()
b, _ := ioutil.ReadAll(res.Body)
if err != nil {
return string(b), err
}
return string(b), nil
}
特に難しいところはないと思いますが、注意点としてはちゃんとメンションさせるためには link_names
を 1
に設定する必要があります。
これでAPIは完成です!
動作イメージ
こんな感じでGithub上でコメントすると、
こんな感じでSlackに通知されます。
いい感じですね!(モザイクかけすぎた)
完成後
運用方法
基本的に設定ファイルを更新すればいい感じになっているので、エンジニア以外でもメンバーやリポジトリ/チャネルを追加出来るようになっています。
また、設定ファイルの更新もPull Requestベースでやっていて、GithubのmasterにマージされたタイミングでHerokuにも自動でデプロイされるようにしております。
改善点
使い始めて半年以上経過し、そこそこいい感じに運用出来ていると思うのですが、いくつか改善点もあります。
- 設定ファイルの更新が暖かみのある手運用
- 頻繁にメンバーやチャンネルが増減することはないのでそこまで負担ではないがそれでも少し面倒。
- Webhookのパスワードがソースコード上にべた書き
- 社内ツールだからと甘えていただけなのでこれを機にさっさとなおす。
- たまに通知されない時がある
- これが一番の問題でログみてもよく分からなくて困っている。
- Herokuで無料のやつを使っているのが原因かなと思ったが、そもそもGithubのイベントがフックされないこともある。
- 数ヶ月前にGithubのReview機能が強化されたタイミングで通知されないことが増えた。
もうちょっといい感じにして、来年は公開出来るレベルに持っていきたいなと思う今日このごろ。
おわりに
弊社はテックカンパニーを目指しているので、これからも技術で解決出来ることはどんどん解決していきたいし、あったらいいなと思うものはどんどん自分でつくっていきたいなと思っています。
明日(20日目)のSupership株式会社 Advent Calendar 2016は @yunico-jp さんです。お楽しみに!
参考サイト
たくさんお世話になりました。
SlackのGitHub連携をやめGitHub->AmazonSNS->Lambda->Slackで連携する
Getting Started on Heroku with Go
Golangで作ったWebアプリをHerokuにデプロイする。
GoアプリをHerokuにデプロイする
Go library for accessing the GitHub API
Handle Github webhooks with golang
Webhooks | GitHub Developer Guide