GeekFactory

int128.hatenablog.com

Pull Request Review 情報を API で取得する

Pull Request のレビューで Code Owner が設定されているかどうかを取得する方法を調べたのでメモ。

GraphQL APIReviewRequest オブジェクトで Code Owner かどうか取得できます。クエリの例を書いておきます。

query { 
  repository(owner: "OWNER", name: "REPO") {
    pullRequest(number: 1918) {
      reviewRequests(first: 10) {
        nodes {
          asCodeOwner
          requestedReviewer {
            ... on Team {
              team: name
            }
            ... on User {
              user: login
            }
          }
        }
      }
    }
  }
}
{
  "data": {
    "repository": {
      "pullRequest": {
        "reviewRequests": {
          "nodes": [
            {
              "asCodeOwner": true,
              "requestedReviewer": {
                "team": "sre"
              }
            },
            {
              "asCodeOwner": false,
              "requestedReviewer": {
                "user": "int128"
              }
            }
          ]
        }
      }
    }
  }
}

GitHub Actions で Issue を Project に追加する

カンバンをうまく運用するには自動化の仕組みが不可欠です。Issue や Pull Request を作ったのにカンバンに入れていなくて忘れ去っていたということ、ありますよね。本稿では GitHub に Issue や Pull Request が作成された契機で Project に自動的に追加する仕組みを考えます。

ここでは Organization で Project を使っている場合を想定します。

GraphQL Mutation で Issue を Project に追加する

まずは API で Project を操作する方法を説明します。

Issue や Pull Request を Project に追加するには addProjectCard mutation が用意されています。これを使うと Project ボードの指定したカラムに追加できます。

さっそく GraphQL Explorer で試してみましょう。

まずは適当な Issue と Project を選び、以下のクエリで ID を取得します。数字は Issue number と Project number に対応します。

query { 
  repository(owner: "ORG", name: "REPO") {
    issue(number: 27193) {
      id
    }
  }
  organization(login: "ORG") {
    project(number: 26) {
      columns(first: 1) {
        nodes {
          name
          id
        }
      }
    }
  }
}

以下のような ID が返ってくるはずです。

{
  "data": {
    "repository": {
      "issue": {
        "id": "MDSOMEISSUEID"
      }
    },
    "organization": {
      "project": {
        "columns": {
          "nodes": [
            {
              "name": "To Do",
              "id": "MDSOMEPROJECTCOLUMNID"
            }
          ]
        }
      }
    }
  }
}

先ほど取得した ID を引数にして以下の mutation を実行します。

mutation($projectColumnId: ID!, $issueID: ID!) {
  addProjectCard(input: {projectColumnId: $projectColumnId, contentId: $issueID}) {
    projectColumn {
      name
    }
  }
}
{
  "projectColumnId": "MDSOMEPROJECTCOLUMNID",
  "issueID": "MDSOMEISSUEID"
}

成功した場合は Project のカラム名が返ってくるはずです。すでに Project に Issue が追加済みの場合は以下のエラーが返されます。

{
  "data": {
    "addProjectCard": null
  },
  "errors": [
    {
      "type": "UNPROCESSABLE",
      "path": [
        "addProjectCard"
      ],
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "message": "Project already has the associated issue"
    }
  ]
}

GitHub CLI で GraphQL mutation を実行する

GitHub CLI には gh api コマンドが用意されており、REST や GraphQL のリクエストを直接実行できます。先ほどの mutation は以下のコマンドで実行できます。

gh api graphql -F 'projectColumnId=MDSOMEPROJECTCOLUMNID' -F 'issueID=MDSOMEISSUEID' -f query='
  mutation($projectColumnId: ID!, $issueID: ID!) {
    addProjectCard(input: {projectColumnId: $projectColumnId, contentId: $issueID}) {
      projectColumn {
        name
      }
    }
  }'

GraphQL Explorer で試した内容をそのままコマンドで実行できるのでめっちゃ便利です。

GitHub Actions で Issue を Project に追加する

GitHub Actions の issues event type を利用すると、Issue が変更された契機でワークフローを実行できます。さらに、ワークフローの if で発火契機を細かく指定できます。

例として、Issue に特定のラベルが付いている場合に自動的に Project に追加するワークフローを書いてみましょう。

on:
  issues:
    types:
      # Issue の新規作成や変更、ラベル追加を契機にする(ラベル削除は別に考える必要あり)
      - opened
      - edited
      - labeled

env:
  GITHUB_TOKEN: ${{ secrets.YOUR_TOKEN }}
  MUTATION: |
    mutation($projectColumnId: ID!, $issueID: ID!) {
      addProjectCard(input: {projectColumnId: $projectColumnId, contentId: $issueID}) {
        projectColumn {
          name
        }
      }
    }

jobs:
  my-kanban:
    if: contains(github.event.issue.labels.*.name, 'Kubernetes') # 特定のラベルが付いている場合のみ
    runs-on: ubuntu-latest
    env:
      PROJECT_COLUMN_ID: MDSOMEPROJECTCOLUMNID
    steps:
      - run: gh api graphql -F "projectColumnId=$PROJECT_COLUMN_ID" -F 'issueID=${{ github.event.issue.node_id }}' -f query="$MUTATION"

このワークフローを配置して、Issue にラベルを付けてみましょう。以下の動作になるはずです。

  • Issue に Kubernetes ラベルが付いている場合: ジョブが実行されて Project に Issue が追加される
  • Issue に Kubernetes ラベルが付いていない場合: ワークフローは実行されるがジョブは実行されない

GitHub Actions の課金体系を見る限り、ジョブが実行された場合のみ課金されるようですが、実際は未確認です。

まとめ

  • GraphQL のクエリをそのまま gh api コマンドに貼り付けて実行できるのが便利
  • GitHub Actions の event trigger と条件分岐で細かく制御できる

Envoy OAuth2 Filterを試す(未完)

EnvoyのOAuth2 Filterを試してみました。残念ながら期待通りに動きませんでした。メモだけ残しておきます。

www.envoyproxy.io

構成

  • EKS 1.18
  • Envoy 1.17.0-dev-483dd3

以下のトラフィックパスを構築します。

Browser
↓
Internet-facing ALB
↓
Service/NodePort
↓
Pod (envoy)
↓
Service
↓
Pod (ingress-nginx)
↓
Ingress (echoserver)
↓
Service (echoserver)
↓
Pod (echoserver)

Route53やALBを設定してインターネットからドメイン名でアクセス可能にしておきます。

また、Google Identity PlatformであらかじめClient IDを作成しておきます。Redirect URIsには https://echoserver.example.com/callback を指定しておきます(example.com は自分のドメインに置き換えてください)。

Envoyの設定

Example configuration を参考にYAMLを書きます。まずは検証のために必要最小限に設定にします。分かりにくいのでコメントを入れました。

# /etc/envoy/envoy.yaml
static_resources:
  listeners:
    - name: listener_0
      address:
        socket_address: { address: 0.0.0.0, port_value: 10000 }
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                codec_type: "AUTO"
                stat_prefix: ingress_http
                route_config:
                  virtual_hosts:
                    # すべてのトラフィックを service cluster に転送する
                    - name: service
                      domains: ["*"]
                      routes:
                        - match:
                            prefix: "/"
                          route:
                            cluster: service
                            timeout: 5s
                http_filters:
                  # OAuth2 Filterを適用する
                  - name: envoy.filters.http.oauth2
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.oauth2.v3alpha.OAuth2
                      config:
                        # Google OAuth2 Endpointを定義する
                        token_endpoint:
                          cluster: auth
                          uri: oauth2.googleapis.com/token
                          timeout: 3s
                        authorization_endpoint: https://accounts.google.com/o/oauth2/v2/auth
                        redirect_uri: "https://%REQ(:authority)%/callback"
                        redirect_path_matcher:
                          path:
                            exact: /callback
                        signout_path:
                          path:
                            exact: /signout
                        credentials:
                          # Client ID/Secretを定義する
                          client_id: REDACTED.apps.googleusercontent.com
                          token_secret:
                            name: token
                            sds_config:
                              path: "/etc/envoy/secret.yaml"
                          hmac_secret:
                            name: hmac
                            sds_config:
                              path: "/etc/envoy/secret.yaml"
                  - name: envoy.router
  clusters:
    - name: service
      connect_timeout: 5s
      type: LOGICAL_DNS
      lb_policy: ROUND_ROBIN
      load_assignment:
        cluster_name: service
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      # 後続のバックエンドに転送する
                      address: ingress-nginx-controller.ingress-nginx.svc.cluster.local
                      port_value: 80
    - name: auth
      connect_timeout: 5s
      type: LOGICAL_DNS
      lb_policy: ROUND_ROBIN
      load_assignment:
        cluster_name: auth
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: oauth2.googleapis.com
                      port_value: 443
# /etc/envoy/secret.yaml
resources:
  - "@type": "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.Secret"
    name: token
    generic_secret:
      secret:
        inline_string: REDACTED
  - "@type": "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.Secret"
    name: hmac
    generic_secret:
      secret:
        inline_string: REDACTED

Envoyのデプロイ

以下のYAMLをデプロイします。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: envoy
spec:
  replicas: 1
  selector:
    matchLabels:
      app: envoy
  template:
    metadata:
      labels:
        app: envoy
    spec:
      containers:
        - args:
            - --config-path
            - /etc/envoy/envoy.yaml
            - --bootstrap-version
            - "3"
            - --service-node
            - ingress-envoy
            - --service-cluster
            - ingress-envoy
          image: envoyproxy/envoy-dev:483dd3007f15e47deed0a29d945ff776abb37815
          name: envoy
          ports:
            - name: http
              containerPort: 10000
              protocol: TCP
          resources:
            limits:
              memory: 256Mi
            requests:
              cpu: 10m
              memory: 256Mi
          volumeMounts:
            - mountPath: /etc/envoy
              name: envoy
      volumes:
        - name: envoy
          configMap:
            name: envoy
---
apiVersion: v1
kind: Service
metadata:
  name: envoy
spec:
  type: NodePort
  ports:
    - name: http
      port: 80
      nodePort: 30280
      protocol: TCP
      targetPort: http
  selector:
    app: envoy

Envoy v3 APIを利用するには引数に --bootstrap-version 3 が必要です。OAuth2 Filter APIはv3で定義されています。

また、 generic_secret を定義するには引数に --service-node --service-cluster が必要です。これらを指定しないと以下のエラーが出ます。

GenericSecretSdsApi: node 'id' and 'cluster' are required. Set it either in 'node' config or via --service-node and --service-cluster options.

動作確認

ブラウザで https://echoserver.example.com にアクセスします。OAuth2 authorization endpointへのリダイレクトまでは動作したのですが、残念ながら以下のエラー画面になってしまいました。

承認エラー エラー 400: invalid_scope Some requested scopes were invalid. {invalid=[user]}

ブラウザのログを確認すると、以下のようなリクエストが飛んでいました。

https://echoserver.example.com
↓302
https://accounts.google.com/o/oauth2/v2/auth?client_id=REDACTED&scope=user&response_type=code&redirect_uri=https%3A%2F%2Fechoserver.example.com%2Fcallback&state=https%3A%2F%2Fechoserver.example.com%2F
↓302
https://accounts.google.com/signin/oauth/error?authError=REDACTED

OAuth2 scopeをemailなどに変更すれば動作すると思いますが、Envoyのドキュメントには設定項目が見当たりませんでした。v1.16時点ではoauth.protoに定義がないようです。ここでお手上げになりました。

(12/25追記) OAuth2 scopeを指定できるようにするPull Requestがあるようです。

github.com

このPull Requestを実際に動かしているコードもあるようです。

github.com

今後の課題

実用になるまでは以下の課題がありそうです。

  • pass_through_matcher でFilterの除外条件を定義できるが実用に耐えられるか。 authority, pathregex matchが使えるので大丈夫そう。
  • email などでリソースにアクセスできる条件を絞り込めるか。現状は UserInfo から属性を取得する実装がなさそうなので無理そう。