LoginSignup
16
12

More than 3 years have passed since last update.

Go で GitHub App (Bot) を作る

Last updated at Posted at 2020-03-16

シンプルな GitHub App (Bot) を Go 言語で作ってみます。
順を追って作り方を解説していくので「サンプルコードだけ見たいよ」という方は「まとめ」に記載されているものをご参照ください。

作るもの

下図のように Issue を作成すると hello, ${username} と反応してくれる Bot を作ります。

githubapps1.png

準備

GitHub に動作確認用のリポジトリを作っておきしょう。

また、GitHub から Webhook イベントを受け取る必要があるので、パブリックアクセス可能な URL が必要です。
今回は開発しながら適宜動作確認が行えるように ngrok というプロキシサービスを利用してローカル開発マシンのポートをインターネットに公開することにします。

こちら から ngrok へのユーザ登録を行います。
登録後、セットアップ方法の案内が表示されるのでそれに従ってセットアップしていきます。

セットアップが完了すると次のようなコマンドで開発マシンのポートを外部公開することができます。

$ nrgok http 8080

便利なサービスですがうっかり外部公開できないものを公開してしまわないように注意が必要です。

Step1: GitHub Apps 認証で API を叩く

まずはシンプルに GitHub API を叩く部分だけを作ってみます。
実行すると Issue に hello とコメントするプログラムを作成します。

GitHub App を登録する

アカウントの Settings から Developer settings を開くと GitHub Apps の画面になるので New GitHub App から登録を行います。

githubapps2.png

githubapps3.png

githubapps4.png

登録フォームに入力する際は以下を参考にしてください。

  • Github App name
    • ユニークな名前をつけてあげる必要があります
  • Homepage URL
    • 必須項目なので動作確認用のリポジトリの URL でも入力しておきます

githubapps5.png

  • Identifying and authorizing users
    • App がインストールされた後、アプリ側がインストールしたユーザを識別したり承認したりする必要がある場合に設定します
    • 今回は不要なので空欄にしておきます
  • Post installation
    • App がインストールされた後、ユーザに追加の設定を行わせたい場合に設定します
    • 今回は不要なので空欄にしておきます

githubapps6.png

  • Webhook
    • GitHub から Event を受け取る際に必要ですが、Active にチェックを入れてしまうと Webhook URL の入力を求められてしまうので一旦チェックを外しておきます

githubapps7.png

  • Repository permissions
    • Issue に読み書きしたいので IssuesAccessRead & write に設定します
    • この際、自動的に MetadataAccessRead-only に設定されます
  • Organization permissions, User permissions
    • 組織やユーザに対する権限設定も行えますが今回は不要なので何も設定しません

githubapps8.png

  • Subscribe to events
    • GitHub から受け取る Event を設定できますが、いずれかにチェックを入れると Webhook URL の入力を求められてしまうので一旦何もチェックしないでおきます

githubapps9.png

  • Where can this GitHub App be installed?
    • Only on this account を選択して自分しかインストールできないようにしておきます

githubapps10.png

設定が完了したら Create GitHub App ボタンを押して登録します。
登録後、次のように App の詳細画面に遷移するので App ID を控えておきましょう。

githubapps11.png

秘密鍵を発行する

作成後の画面を下にスクロールしていくと Generate a private key ボタンがあるのでクリックして秘密鍵を発行しましょう。

githubapps12.png

pem ファイルがローカルにダウンロードされます。

リポジトリにインストールする

左側のメニューから Install App を選択し、自分のアカウントにインストールします。

githubapps13.png

githubapps14.png

インストール先は動作確認用に作ったリポジトリのみにしておきます。

githubapps15.png

インストール後の画面の URL 末尾の ID を控えておきましょう。認証時に Installation ID として使用します。

githubapps16.png

実装

ようやく Go での実装を行っていきます。
GitHub API を叩くためのクライアントライブラリとして google/go-github というパッケージを使用します。
ただし、このパッケージは README の Authentication にも記載されているように認証処理は受け持ってくれないので自分で何とかする必要があります。

ここで、GitHub Apps の認証方法を確認しておきましょう。
Authenticating as a GitHub App に記載されているように、発行した秘密鍵を使用して RS256 アルゴリズムで署名した JWT を Authorization ヘッダに Bearer トークンとして入れることで GitHub App として認証されます。ただしこの JWT で可能なのは一時トークンの発行などに限定されており、インストール先のリポジトリを直接操作するような API を叩くことはできません。実際に API を叩くためにはインストールされた組織・ユーザ単位で一時トークンを発行し、そちらの方を使う必要があります。

GitHub Apps は開発者以外のユーザや組織にインストールされて使われることが想定されているためこのような仕組みになっているのですが、この部分の実装を自分でやるのはちょっと面倒です。
そこで、認証部分に関しては bradleyfalzon/ghinstallation パッケージを使用することにします。上記の認証処理を自動で行ってくれる http.Transport を提供してくれるパッケージで、google/go-github で生成される GitHub API Client に埋め込んで使用することができます。google/go-githubREADME でも GitHub Apps の認証用のパッケージとして紹介されているので安心して使うことができます。

前置きが長くなりましたがこれらのパッケージを使って実装すると次のようなコードになります。

main.go
package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "strconv"
    "time"

    "github.com/bradleyfalzon/ghinstallation"
    "github.com/google/go-github/v29/github"
)

const InstallationID = <your-installation-id>
const RepoOwner = "<your-repo-owner>"
const Repo = "<your-repo>"
const IssueNumber = 1

func main() {
    appID, err := strconv.ParseInt(os.Getenv("GITHUB_APP_ID"), 10, 64)
    if err != nil {
        log.Fatal(err)
    }

    tr := http.DefaultTransport
    itr, err := ghinstallation.NewKeyFromFile(tr, appID, InstallationID, "private-key.pem")
    if err != nil {
        log.Fatal(err)
    }

    client := github.NewClient(&http.Client{
        Transport: itr,
        Timeout:   5 * time.Second,
    })

    ctx := context.Background()

    body := "hello"
    comment := &github.IssueComment{
        Body: &body,
    }
    if _, _, err := client.Issues.CreateComment(ctx, RepoOwner, Repo, IssueNumber, comment); err != nil {
        log.Fatal(err)
    }
}

控えておいた App ID は環境変数から取得するようにしています。Installation ID は本来 Webhook 経由で受け取るのが正しいので一旦ハードコードしています。
コメントを投稿する Issue のリポジトリ情報や対象の Issue 番号も Webhook イベントから取得したいのでここでは一旦ハードコードしておきます(動作確認用に適当な Issue を作成しておきましょう)。

また、秘密鍵は同じディレクトリの private-key.pem というファイルに保存されていることを想定しています。

実行してみましょう。
ダウンロードした pem ファイルを private-key.pem にリネームして main.go と同じディレクトリに配置し、環境変数 GITHUB_APP_IDexport してから実行します。

$ mv frozenbonito-test-bot.2020-03-14.private-key.pem private-key.pem
$ export GITHUB_APP_ID=<your-app-id>
$ go run main.go

hello とコメントしてくれました。

githubapps17.png

上記のコードでは ghinstallation.NewKeyFromFile() を使ってファイルから秘密鍵を読み込んでいますが、 ghinstallation.New() の方を使えば変数から参照することも可能です。
以下は環境変数から秘密鍵を参照する例です。コンテナ環境で動かしたい場合はこちらの方が扱いやすいかもしれません。

main.go
    tr := http.DefaultTransport
    key := os.Getenv("GITHUB_APP_PRIVATE_KEY")
    itr, err := ghinstallation.New(tr, appID, InstallationID, []byte(key))
    if err != nil {
        log.Fatal(err)
    }

秘密鍵を環境変数にセットしてから実行します。

$ export GITHUB_APP_PRIVATE_KEY=$(cat private-key.pem)
$ go run main.go

AWS Lambda など、環境変数に改行を入れるのが難しい環境では base64 エンコードしたものを入れておきコード中でデコードするのが良さそうです。

Step2: GitHub から Event を受け取って処理する (Webhook)

API を叩けることが確認できたので次は GitHub の Webhook を利用して Issue 作成に反応するようにします。

実装

先ほどのコードを以下のように修正します。

main.go
package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "strconv"
    "time"

    "github.com/bradleyfalzon/ghinstallation"
    "github.com/google/go-github/v29/github"
)

func main() {
    http.HandleFunc("/github/events", func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()

        payload, err := github.ValidatePayload(r, nil)
        if err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusBadRequest)
            return
        }

        webhookEvent, err := github.ParseWebHook(github.WebHookType(r), payload)
        if err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusBadRequest)
            return
        }

        switch event := webhookEvent.(type) {
        case *github.IssuesEvent:
            if err := processIssuesEvent(ctx, event); err != nil {
                log.Println(err)
                w.WriteHeader(http.StatusInternalServerError)
                return
            }
        }
    })

    log.Println("[INFO] Server listening")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)
    }
}

func processIssuesEvent(ctx context.Context, event *github.IssuesEvent) error {
    if event.GetAction() != "opened" {
        return nil
    }

    installationID := event.GetInstallation().GetID()
    client, err := newGithubClient(installationID)
    if err != nil {
        return err
    }

    repoOwner := event.Repo.GetOwner().GetLogin()
    repo := event.Repo.GetName()

    issue := event.GetIssue()
    issueNumber := issue.GetNumber()
    user := issue.GetUser().GetLogin()

    body := "hello, @" + user
    comment := &github.IssueComment{
        Body: &body,
    }
    if _, _, err := client.Issues.CreateComment(ctx, repoOwner, repo, issueNumber, comment); err != nil {
        return err
    }

    return nil
}

func newGithubClient(installationID int64) (*github.Client, error) {
    appID, err := strconv.ParseInt(os.Getenv("GITHUB_APP_ID"), 10, 64)
    if err != nil {
        return nil, err
    }

    tr := http.DefaultTransport
    itr, err := ghinstallation.NewKeyFromFile(tr, appID, installationID, "private-key.pem")
    if err != nil {
        return nil, err
    }

    return github.NewClient(&http.Client{
        Transport: itr,
        Timeout:   5 * time.Second,
    }), nil
}

コードの解説
func main() {
    http.HandleFunc("/github/events", func(w http.ResponseWriter, r *http.Request) {
        // ...
    })

    log.Println("[INFO] Server listening")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)
    }
}

Webhook イベントを受け取るため、main() はサーバを起動するためのコードに書き換えています。8080 ポートの /github/events で Webhook イベントを待ち受けます。

        payload, err := github.ValidatePayload(r, nil)

github.ValidatePayload() を使って Webhook イベントの payload をチェックしています。
第二引数に Secret を与えることで署名を検証することができますが一旦ここでは nil を与えて署名検証を skip しています。

        webhookEvent, err := github.ParseWebHook(github.WebHookType(r), payload)

payload を parse してイベントを取得しています。ここで戻り値として得られるのは interface{} ですが、Type switches によってどのイベントかを判定することが可能です。
ちなみに GitHub Webhook イベントのタイプはリクエストの X-GitHub-Event ヘッダに詰められて送られてきます。第一引数に与えている github.WebHookType() はこの X-GitHub-Event ヘッダからイベントタイプを読み取る関数です。

        switch event := webhookEvent.(type) {
        case *github.IssuesEvent:
            if err := processIssuesEvent(ctx, event); err != nil {
                log.Println(err)
                w.WriteHeader(http.StatusInternalServerError)
                return
            }
        }

Type switches を利用してイベントタイプを判定し、処理を行っています。今回は Issue が作成された場合に処理を行うので *github.IssuesEvent ケース内に処理を書いています。

func processIssuesEvent(ctx context.Context, event *github.IssuesEvent) error {
    if event.GetAction() != "opened" {
        return nil
    }

    installationID := event.GetInstallation().GetID()
    client, err := newGithubClient(installationID)
    if err != nil {
        return err
    }

    repoOwner := event.Repo.GetOwner().GetLogin()
    repo := event.Repo.GetName()

    issue := event.GetIssue()
    issueNumber := issue.GetNumber()
    user := issue.GetUser().GetLogin()

    body := "hello, @" + user
    comment := &github.IssueComment{
        Body: &body,
    }
    if _, _, err := client.Issues.CreateComment(ctx, repoOwner, repo, issueNumber, comment); err != nil {
        return err
    }

    return nil
}

Issues イベントを処理する関数です。
Issues イベントは Issue に関する様々な操作が行われた際に発生しますが、1行目で Action が opened であることを判定しているので Issue の作成時にのみ反応するようになっています。

残りの処理はほぼ Step1 で main() に書いていたものと同等ですが、必要な値が Webhook イベントから取得されるようになっています。
リポジトリのオーナーや Issue の作成者を取得する際、github.User から取得することになりますが GetID()GetName() ではなく GetLogin() を使う必要があることに注意が必要です。

func newGithubClient(installationID int64) (*github.Client, error) {
    // ...
}

先ほどは main() に書いていた GitHub Client の生成処理を別関数にしました。処理はほぼ変わっていませんが、引数に Webhook イベントから取得した Installation ID が渡されることを想定しています。

今回はシンプルな実装にするため関数が呼ばれるたびに新しい GitHub Client を生成してしまっていますが、実際には Installation ID と GitHub Client の対応を map などで持たせておき同一の Installation ID に対しては Client を使いまわすようにすると良さそうです。

Issues イベントが App に配信されるように設定する

Step1 で Webhook に関する設定を後回しにしたのでここで行います。

Webhook 用の URL が必要なので作成したプログラムを実行し、ngrok でポートを公開しておきます。

$ go run main.go
2020/03/15 22:42:48 [INFO] Server listening
$ ngrok http 8080
Session Status                online
Account                       frozenbonito (Plan: Free)
Version                       2.3.35
Region                        United States (us)
Web Interface                 http://127.0.0.1:4040
Forwarding                    http://xxxxxxxx.ngrok.io -> http://localhost:8080
Forwarding                    https://xxxxxxxx.ngrok.io -> http://localhost:8080

GitHub App の設定ページを開きます(閉じてしまった場合は Settings > Developer settings > GitHub Apps で作成した App を Edit)。

下の方にある Webhook の Active にチェックを入れ、Webhook URL には ngrok で得られた https の URL に Webhook イベントを待ち受けているパスである /github/events を加えたものを入力します。
Webhook secret の設定は後で行うので空欄のままにしておきます。

githubapps18.png

設定できたら Save changes で保存します。

次に Webhook で配信するイベントの設定を行います。

左側のメニューの Permissions & events を開きます。

githubapps19.png

下の方にある Subscribe to eventsIssues にチェックを入れます。
ここで Change privileges to able to select events とエラーが出た場合は Repository permissionsIssuesAccessNo access にした後 Read & write に戻すなどの操作を行ってみてください。不具合なのかもしれませんが、Permission の方をいじらないとチェックができない仕様になっているようです。

githubapps20.png

Save changes で保存したら設定は完了です。動作確認用のリポジトリで新しい Issue を作成してみましょう。

githubapps1.png

作成した Issue に対して Bot がコメントで反応してくれました。

Step3: GitHub からのリクエストであることを検証する

GitHub App はリポジトリへの参照権限や書き込み権限などを持っているのでセキュリティには十分注意が必要です。
Step2 までで表面的な機能は実装が完了しましたが、安全のためには届いたリクエストが GitHub からのものであることを検証しなくてはいけません。

最後にこのリクエスト検証処理を実装していきます。

Webhook の Secret を設定する

GitHub からのリクエストを検証するためにはまず Webhook の Secret を設定する必要があります。

再度 GitHub App の設定ページを開きます(閉じてしまった場合は Settings > Developer settings > GitHub Apps で作成した App を Edit)。

下の方にある Webhook 設定のうち、先ほどは入力しなかった Webhook secret に Secret 文字列を入力します。

githubapps21.png

入力する文字列は暗号学的に安全な乱数から生成したランダム文字列が良いでしょう。公式ドキュメント では Ruby を使って puts SecureRandom.hex(20) で生成しています。安全なランダム文字列が得られれば方法は何でもよいのですが、あえて Go でやるなら以下のような感じでしょうか。

secret/main.go
package main

import (
    "crypto/rand"
    "fmt"
    "log"
)

func main() {
    b := make([]byte, 20)
    if _, err := rand.Read(b); err != nil {
        log.Fatal(err)
    }

    fmt.Printf("%x\n", b)
}

crypto/rand を使う必要があることに注意です。

Webhook secret を入力したら Save changes をクリックして保存しておきます。

実装

Webhook secret が設定されていると、GitHub は Secret を使用して payload を署名したものを X-Hub-Signature ヘッダで送信するようになります。
この検証は github.ValidateSignature() または github.ValidatePayload() で行うことが可能です。

先ほどのコードを以下のように修正します。

main.go
package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "strconv"
    "time"

    "github.com/bradleyfalzon/ghinstallation"
    "github.com/google/go-github/v29/github"
)

func main() {
    http.HandleFunc("/github/events", func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()

        // 修正
        secret := os.Getenv("GITHUB_APP_SECRET")
        payload, err := github.ValidatePayload(r, []byte(secret))
        if err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusBadRequest)
            return
        }

        // 省略
        // ...
    })

    // 省略
    // ...
}

// 省略
// ...

環境変数から Secret を取得するようにし、github.ValidatePayload() の第二引数に与えるだけです。github.ValidatePayload() は第二引数に Secret を与えると内部で github.ValidateSignature() を呼び出して署名の検証を行ってくれます。

実行してみましょう。
先ほど設定した Secret を環境変数に入れてから実行します。

$ export GITHUB_APP_SECRET=<your-webhook-secret>
$ go run main.go

GitHub で Issue を作成した場合は正常に動作するはずです。

実際に外部からのリクエストを弾けるかも試してみましょう。
と言っても github.ValidatePayload() は payload の形式自体もチェックしてしまうので、適当なリクエストを偽装するのも面倒です。
そこで手元の環境変数だけ変更した上で再度 GitHub からイベントを飛ばすことで「間違った Secret で署名された偽装リクエストが届いた」状態を疑似的に再現することにします。

環境変数を一時的に変更して再度実行します。

$ GITHUB_APP_SECRET=1234567890abcdef go run main.go

ここでは Issue を新しく作るのではなく GitHub のイベント再送機能を使ってみましょう。
GitHub App の設定ページの左側のメニューから Advanced を開きます。

githubapps22.png

今までに GitHub から送信されたイベントの記録が残っています。
最新の成功したものを選択します。

githubapps23.png

Redeliver ボタンで全く同じヘッダーと payload でイベントを再送することができます。

githubapps24.png

実行すると再送は 400 で失敗し、手元で実行している App は以下のようにログを吐くはずです。

$ GITHUB_APP_SECRET=123456789abcdef go run main.go
2020/03/17 01:41:56 [INFO] Server listening
2020/03/17 01:42:04 payload signature check failed

Secret が間違っている偽装リクエストをちゃんと弾けることが確認できました。

まとめ

Go 言語による GitHub App の作り方について解説しました。
実は今回のように Webhook イベントをトリガーに処理を行うケースでは GitHub Actions を使った方がサーバー不要な分お手軽に実現できます。しかし GitHub 外部で発生するイベントをトリガーにいろいろと処理をしたい場合などには GitHub App が活躍してくれそうです。

最後に完成したコードの全体を載せておきます。

main.go
package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "strconv"
    "time"

    "github.com/bradleyfalzon/ghinstallation"
    "github.com/google/go-github/v29/github"
)

func main() {
    http.HandleFunc("/github/events", func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()

        secret := os.Getenv("GITHUB_APP_SECRET")
        payload, err := github.ValidatePayload(r, []byte(secret))
        if err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusBadRequest)
            return
        }

        webhookEvent, err := github.ParseWebHook(github.WebHookType(r), payload)
        if err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusBadRequest)
            return
        }

        switch event := webhookEvent.(type) {
        case *github.IssuesEvent:
            if err := processIssuesEvent(ctx, event); err != nil {
                log.Println(err)
                w.WriteHeader(http.StatusInternalServerError)
                return
            }
        }
    })

    log.Println("[INFO] Server listening")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)
    }
}

func processIssuesEvent(ctx context.Context, event *github.IssuesEvent) error {
    if event.GetAction() != "opened" {
        return nil
    }

    installationID := event.GetInstallation().GetID()
    client, err := newGithubClient(installationID)
    if err != nil {
        return err
    }

    repoOwner := event.Repo.GetOwner().GetLogin()
    repo := event.Repo.GetName()

    issue := event.GetIssue()
    issueNumber := issue.GetNumber()
    user := issue.GetUser().GetLogin()

    body := "hello, @" + user
    comment := &github.IssueComment{
        Body: &body,
    }
    if _, _, err := client.Issues.CreateComment(ctx, repoOwner, repo, issueNumber, comment); err != nil {
        return err
    }

    return nil
}

func newGithubClient(installationID int64) (*github.Client, error) {
    appID, err := strconv.ParseInt(os.Getenv("GITHUB_APP_ID"), 10, 64)
    if err != nil {
        return nil, err
    }

    tr := http.DefaultTransport
    itr, err := ghinstallation.NewKeyFromFile(tr, appID, installationID, "private-key.pem")
    if err != nil {
        return nil, err
    }

    return github.NewClient(&http.Client{
        Transport: itr,
        Timeout:   5 * time.Second,
    }), nil
}
16
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
12