AWS SAMとGoでPRのコメントに対して返事を返すGitHub Appを作る

(2019-07-19)

GitHub Appはリポジトリにインストールできるアプリケーションで、 Access TokenやOAuth Appと異なり ユーザーとは独立した権限を与えて実行することができる。

今回はPRの特定のコメントに反応して返事を返すAppを作る。

Botによるコメント投稿

AWS SAMでデプロイする。全体のコードはGitHubにある。

AWS SAMでLambdaの関数をデプロイしServerless Application Repositoryに公開する - sambaiz-net

GitHub Appの作成

Settings > Developer settings から作成できる。いろいろ項目はあるが、NameとHomepage URL、Webhook URLを入れればひとまず作成はできる。 Webhook URLはあとで決まるので適当な値を入れておく。必須にはなっていないがリクエストを検証するため適当なWebhook secretも入れる。 PermissionsはPull requestsではなくIssueのRead & Writeが必要で、 さらにそうすると表示されるようになるSubscribe to eventsのIssue commentにチェックを入れる。

作成すると秘密鍵がダウンロードできるのでSecretsmanagerに上げておき、LambdaのRoleにもこれを取得できるRoleを付ける。

AWS Systems Manager (SSM)のParameter Storeに認証情報を置き参照する - sambaiz-net

$ aws secretsmanager create-secret --name GitHubCdkAppSecretKey --secret-string $(cat private-key.pem)
Policies:
  - PolicyName: read-cdk-github-app-secret-key
    PolicyDocument:
    Version: "2012-10-17"
    Statement:
      - Effect: Allow
        Action: secretsmanager:GetSecretValue
        Resource: <secret-key arn>

Webhooksのリクエスト内容

設定でチェックを入れたeventが起きると次のようなリクエストが送られてくる。 Headerの X-GitHub-Event によってbodyの中身が決まり、 X-Hub-Signature と、bodyと設定したWebhook secretから生成したHMACのMAC値を比較することで リクエストを検証することができる。

POST /payload HTTP/1.1
Host: localhost:4567
X-GitHub-Delivery: 72d3162e-cc78-11e3-81ab-4c9367dc0958
X-Hub-Signature: sha1=7d38cdd689735b008b3c702edd92eea23791c5f6
User-Agent: GitHub-Hookshot/044aadd
Content-Type: application/json
Content-Length: 6615
X-GitHub-Event: issues
{
  "action": "opened",
  "issue": {
    "url": "https://api.github.com/repos/octocat/Hello-World/issues/1347",
    "number": 1347,
    ...
  },
  "repository" : {
    "id": 1296269,
    "full_name": "octocat/Hello-World",
    "owner": {
      "login": "octocat",
      "id": 1,
      ...
    },
    ...
  },
  "sender": {
    "login": "octocat",
    "id": 1,
    ...
  }
}

実装

Signatureを検証し、対応するEventのstructにパースして処理を行う流れ。

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"os"
	"time"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/awserr"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/secretsmanager"
	jwt "github.com/dgrijalva/jwt-go"
	"github.com/google/go-github/v26/github"
	"go.uber.org/zap"
	"golang.org/x/oauth2"
)

func handler(event events.APIGatewayProxyRequest) (Response, error) {
	ctx := context.Background()
	if err := github.ValidateSignature(event.Headers["X-Hub-Signature"], []byte(event.Body), []byte(webhookSecret)); err != nil {
		logger.Info("Signature is invalid", zap.Error(err))
		return Response{
			StatusCode: http.StatusBadRequest,
		}, nil
	}
	hook, err := github.ParseWebHook(event.Headers["X-GitHub-Event"], []byte(event.Body))
	if err != nil {
		logger.Error("Failed to parse hook", zap.Error(err))
		return Response{
			StatusCode: http.StatusBadRequest,
		}, nil
	}
	switch hook := hook.(type) {
	case *github.IssueCommentEvent:
		err = processIssueCommentEvent(ctx, hook)
	}
	if err != nil {
		logger.Error("Failed to process an event", zap.Error(err))
		return Response{
			StatusCode: http.StatusInternalServerError,
		}, err
	}
	return Response{
		StatusCode: http.StatusOK,
	}, nil
}

func processIssueCommentEvent(
	ctx context.Context,
	hook *github.IssueCommentEvent) error {
	if hook.GetComment().GetBody() != "ping" {
		return nil
	}
	client, err := newGitHubClient(ctx, hook.GetInstallation().GetID())
	if err != nil {
		return err
	}
	_, _, err = client.Issues.CreateComment(
		ctx,
		hook.GetRepo().GetOwner().GetLogin(),
		hook.GetRepo().GetName(),
		hook.GetIssue().GetNumber(),
		&github.IssueComment{
			Body: &[]string{"pong"}[0],
		},
	)
	return err
}

なお無条件に返すようにするとbotの投稿でさらにeventが発火して無限ループしてしまう。

無限ループ

クライアントの生成

リポジトリに対してAPIを呼ぶには、まずダウンロードした秘密鍵でJWTを作り、これでInstallationTokenを取得してHeaderに乗せる必要がある。

func newGitHubClient(ctx context.Context, installationID int64) (*github.Client, error) {
	now := time.Now()
	payload := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
		"iat": now.Unix(),
		"exp": now.Add(10 * time.Minute).Unix(),
		"iss": appID,
	})
	pem, err := getSecret(privateKeyArn)
	if err != nil {
		return nil, err
	}
	privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(pem))
	if err != nil {
		return nil, err
	}
	token, err := payload.SignedString(privateKey)
	if err != nil {
		return nil, err
	}
	ts := oauth2.StaticTokenSource(
		&oauth2.Token{AccessToken: token},
	)
	tc := oauth2.NewClient(ctx, ts)
	client := github.NewClient(tc)

	installationToken, _, err := client.Apps.CreateInstallationToken(ctx, installationID)
	if _, _, err := client.Apps.Get(ctx, ""); err != nil {
		return nil, fmt.Errorf("Failed to create installation token: %s", err.Error())
	}
	ts = oauth2.StaticTokenSource(
		&oauth2.Token{AccessToken: installationToken.GetToken()},
	)
	tc = oauth2.NewClient(ctx, ts)
	client = github.NewClient(tc)
	return client, nil
}

動作確認

実際動かしてみるとなかなかうまくいかない。 GitHub Apps > Advanced から送られたイベントとそれに対するレスポンスが確認でき、再送することもできて便利。

送られたイベント