GitHub Appはリポジトリにインストールできるアプリケーションで、 Access TokenやOAuth Appと異なり ユーザーとは独立した権限を与えて実行することができる。
今回はPRの特定のコメントに反応して返事を返すAppを作る。
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
から送られたイベントとそれに対するレスポンスが確認でき、再送することもできて便利。