Go で GitHub App (Bot) を作る

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

## 作るもの

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

![githubapps1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/a648cead-5d5c-d8ac-d02c-854abd86e05e.png)


## 準備

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

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

[こちら](https://dashboard.ngrok.com/signup) から ngrok へのユーザ登録を行います。
登録後、セットアップ方法の案内が表示されるのでそれに従ってセットアップしていきます。

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

```console
$ nrgok http 8080
```

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

## Step1: GitHub Apps 認証で API を叩く

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

### GitHub App を登録する

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

![githubapps2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/4340a209-e452-fa19-0e50-ed33bbc5ec2a.png)


![githubapps3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/9ed1baf9-ca66-82d9-7e2b-a8e55b977dd9.png)


![githubapps4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/0aab305d-dca6-0a1e-a29c-9ebe66159e38.png)


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

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

![githubapps5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/a6e17b5b-e8dc-6105-c9f2-96e5ad9b1b62.png)

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

![githubapps6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/0d482388-8152-154d-2e05-a5ad74f99060.png)

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

![githubapps7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/d42aa96f-fdf1-242c-b8d3-fba5595bfe16.png)

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

![githubapps8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/8ba0e31b-9f74-75ac-f007-0a04e460f1d1.png)

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

![githubapps9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/dda194dd-4901-5886-9acf-8917f28bf08b.png)

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

![githubapps10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/b4e4e3c7-a3a6-4cd1-fdca-58c9fa7d2318.png)


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

![githubapps11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/968d681d-1df7-47f0-c281-24ef7d2a1271.png)

### 秘密鍵を発行する

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

![githubapps12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/6811d700-a89e-6f4b-da95-ea57a2d1c958.png)

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

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

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

![githubapps13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/691be02a-571a-d1bb-183f-2cfcdd25f85e.png)

![githubapps14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/301afd2f-f51e-c084-00b7-ccb0c2ffddc4.png)

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

![githubapps15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/e251c2b5-4aab-8680-3db3-b3c3a673806f.png)


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

![githubapps16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/b0462cd0-3145-069b-3c8c-0a783c578438.png)

### 実装

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

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

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

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

```go: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_ID` を `export` してから実行します。

```console
$ 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/c1346991-7da0-67b7-4b9e-0229b320b109.png)

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

```go: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)
	}
```

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

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

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

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

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

### 実装

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

```go: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
}
```

<details><summary>コードの解説</summary><div>

```go
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 イベントを待ち受けます。

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

[`github.ValidatePayload()`](https://pkg.go.dev/github.com/google/go-github/v29/github?tab=doc#ValidatePayload)　を使って Webhook イベントの payload をチェックしています。
第二引数に Secret を与えることで署名を検証することができますが一旦ここでは `nil` を与えて署名検証を skip しています。

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

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

```go
        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`](https://pkg.go.dev/github.com/google/go-github/v29/github?tab=doc#IssuesEvent) ケース内に処理を書いています。

```go
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`](https://pkg.go.dev/github.com/google/go-github/v29/github?tab=doc#User) から取得することになりますが [`GetID()`](https://pkg.go.dev/github.com/google/go-github/v29/github?tab=doc#User.GetID) や [`GetName()`](https://pkg.go.dev/github.com/google/go-github/v29/github?tab=doc#User.GetName) ではなく [`GetLogin()`](https://pkg.go.dev/github.com/google/go-github/v29/github?tab=doc#User.GetLogin) を使う必要があることに注意が必要です。

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

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

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

</div></details>

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

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

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

```console
$ go run main.go
2020/03/15 22:42:48 [INFO] Server listening
```

```console
$ 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/9913ecba-671d-0f46-115b-9bdef964090c.png)

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

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

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

![githubapps19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/3f192835-bfeb-d33e-0e57-53386bf46819.png)

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

![githubapps20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/5047900e-abf8-27b7-95df-67570a989a87.png)

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

![githubapps1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/a648cead-5d5c-d8ac-d02c-854abd86e05e.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/8e211305-dd30-9ee9-2578-07cf888a0dab.png)

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

```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](https://pkg.go.dev/crypto/rand?tab=doc) を使う必要があることに注意です。

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

### 実装

`Webhook secret` が設定されていると、GitHub は Secret を使用して payload を署名したものを `X-Hub-Signature` ヘッダで送信するようになります。
この検証は [`github.ValidateSignature()`](https://pkg.go.dev/github.com/google/go-github/v29/github?tab=doc#ValidateSignature) または [`github.ValidatePayload()`](https://pkg.go.dev/github.com/google/go-github/v29/github?tab=doc#ValidatePayload) で行うことが可能です。

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

```go: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 を環境変数に入れてから実行します。

```console
$ 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/9a32b978-5713-1d45-9ce1-07c45350c802.png)

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

![githubapps23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/868673b5-a978-90b1-2d65-8273aa27da44.png)

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

![githubapps24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/6ef13223-944d-12c8-1715-f8142fef943b.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](https://help.github.com/ja/actions) を使った方がサーバー不要な分お手軽に実現できます。しかし GitHub 外部で発生するイベントをトリガーにいろいろと処理をしたい場合などには GitHub App が活躍してくれそうです。

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

```go: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
}
```