GithubのメンションをSlackでもメンションしてくれるgo2slackご紹介

  • 19
    Like
  • 0
    Comment

はじめに

本記事はSupership株式会社 Advent Calendar 2016の19日目のエントリーになります。

おはようございます。こんにちは。こんばんは。Supership株式会社の @itosho と申します。
平日は主にサーバーサイドエンジニアとして、自社サービスである「nanapi」や「Poptalk」の開発を頑張っています。ちなみに、休日は主にオタクとして、アイドルの応援を頑張っています。

go2slackとは?

いきなりですが、皆さんはSlack純正のGithub連携が使い辛いと思ったことはありませんか?僕はあります。

具体的に何が辛いかと言うと…

  • メンションやハイライトが効かない
  • そもそもGithubとSlackでIDが違う場合はメンション出来ない

あたりです。

そして、それを解決するのが本日ご紹介する go2slack (僕が社内で勝手に呼んでいる)です。

※このあたりの解決方法って実はちょっとググると色々知見やツールが出てくるのですが、ちょうどGo言語を勉強していた頃でGoで何かつくってみたかったので、勉強がてら自分でつくってみた次第であります。

連携イメージ

image

そんな大したことはしておらず、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以外にもいくつか連携しているサービスがあるので、ここに掲載するコードは抜粋かつ多少改変しております。)

設定ファイル

まずは以下のような設定ファイルを用意します。

config.json

{
  "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のエンドポイントを作成します。

main.go
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連携をよしなにしてくれるパッケージ

上述の独自のパッケージです。

gh/gh.go

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 関数の中身です。
とってもナイーブな実装です…。

main.go

// 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送信するための関数です。

main.go
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_names1 に設定する必要があります。

これでAPIは完成です!

動作イメージ

こんな感じでGithub上でコメントすると、

image

こんな感じでSlackに通知されます。

image

いい感じですね!(モザイクかけすぎた)

完成後

運用方法

基本的に設定ファイルを更新すればいい感じになっているので、エンジニア以外でもメンバーやリポジトリ/チャネルを追加出来るようになっています。
また、設定ファイルの更新も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