Open36

GitHub Apps を試しに作ってみるテスト

Coji MizoguchiCoji Mizoguchi

プルリクのレビュアーが偏ってスループットが悪くなるので、こういうツールを作っています。
https://twitter.com/techtalkjp/status/1600394526790922240

いいねをちょっともらえたので多少ニーズはあるのだろう、と思いました。

現状 GitLab にしか対応していないので、GitHub に対応する必要があります。リポジトリ単位での制御が可能な GitHub Apps になるのかな。ただ、どんな作業が必要かあたりを付けるために、試しに作ってみようと思います。

Coji MizoguchiCoji Mizoguchi

アプリの概要から。

  • ワークフローを自動化し改善できる
  • GitHub Marketplace でアプリを共有または販売できる
  • データアクセスへの細やかな権限があるので公式に推奨されている方法
  • GitHub Actions でアプリを使用できる。ワークフローファイルを変更したい場合はリポジトリの管理もしくは書き込み権限とともに workflow スコープを含む OAuth トークンでユーザの代わりに認証が必要。
Coji MizoguchiCoji Mizoguchi

GitHub Apps について

  1. GitHub App は独自で動作し、独自のIDを使用して API 経由で直接アクションする。そのため、ボットやサービスアカウントを別途用意する必要はない
  2. Organization や個人アカウントに直接インストールし、特定リポジトリへのアクセス権限を付与できる。
  3. 細かなアクセス権限がつけられて、webhook も組み込まれている
  4. ユーザはGitHub App をセットアップする際に、アクセスさせるリポジトリを選択できる。
  5. GitHub App をインストールできるのは、Organization のオーナーか、リポジトリごとの管理者のみ。
Coji MizoguchiCoji Mizoguchi

GitHub Apps の追加・削除の管理権限

  • デフォルトでは Organization 無いの GitHub Apps の設定を管理できるのは Organization のオーナーだけ
  • Organization 内の Apps を管理できるユーザ追加には、管理者がそのユーザに GitHub Apps 管理権限を許可すればok
Coji MizoguchiCoji Mizoguchi

GitHub Apps の作成で気をつけるべきこと

  1. ユーザまたは Organization が所持できる GitHub Apps は最大100まで。
  2. GitHub Apps はユーザに依存しないアクションを実行する必要がある。もしアプリがユーザーからサーバへのトークンを使用している場合はさらにセキュアにするために8時間後に期限切れになるアクセストークンと、リフレッシュトークンを使える (coji: どういうこと?)
  3. GitHub App は必ず特定のリポジトリと統合するようにしてください (coji: どういうこと?アプリ開発時の話?)
  4. GitHub App は個人アカウントまたは Organization に接続する必要があります
  5. ユーザができる全てのことを GitHub App が知り、行えると思わないでください (coji: どういうこと?)
  6. 単に「GitHub でログイン」するサービスが必要な場合は GitHub App を使わないこと。ただし、GitHub App ではユーザ識別フローを使ってユーザをログインさせ、他の操作を実行は可能。
  7. GitHub ユーザとして動作し、ユーザが実行できることをすべて実行したいだけの場合は、GitHub App をビルドしないでください (coji: どういうこと?)
  8. GitHub Actions でアプリを使用していて、ワークフローファイルを変更したい場合は、リポジトリの管理もしくは書き込み権限を持った上で、 workflow スコープを含む OAuth トークンでユーザの代わりに認証を受けなければならない。

なんか防衛的な書きっぷりがおおくて意味がわからない。

Coji MizoguchiCoji Mizoguchi

アプリ構築する場合の手段は以下3つ

  1. GitHub Apps
  2. OAuth Apps
  3. Personal Access Token

どれが良いかはこの図でみる。

手段選択フロー

Coji MizoguchiCoji Mizoguchi

今回の例だと

  1. Only as me ? => No (でいいのか?as ってどういう意味)
  2. Act as the app? => Yes (でいいのか?as ってどういう意味)
    で、GitHub App になる。

いや、この図意味不明すぎて役にたたないよw

Coji MizoguchiCoji Mizoguchi

"Only as me" => 「インテグレーションは自分自身としてのみ振る舞うのか、それともアプリケーションのように振る舞うのか?」 だとすると No でいいはず。

"Act as the app?" => 「独自のエンティティとして、自分から独立して動作させるのか?」も多分 Yes でいいはず。

たぶん。。用語が意味不明すぎて判断できない。

Coji MizoguchiCoji Mizoguchi

いきなりこう書いてあるけど、よくわからないので、後で見る。

GitHub アプリ マニフェストを使用すると、構成済みの GitHub アプリを作成できます。使用方法については、「マニフェストから GitHub アプリを作成する」を参照してください。

Coji MizoguchiCoji Mizoguchi

Register new GitHub App の画面。

以下を入れる

Identifying and authorizing users

  • Callback URL: https://hello-github-app-coji.vercel.app/install (必須なのでいったん適当に)
  • Expire user authorization tokens: チェック (デフォルト)
  • Request user authorization (OAuth) duaring installation: 空欄 (デフォルト)
  • Enable Device Flow: 空欄 (デフォルト)

Post installation

  • Setup URL (optional): 空欄 (デフォルト)
  • Redirect on update: 空欄 (デフォルト)

Webhook

Permissions

※ Repository permissions, Organization permissions, Account permissions の3つがある。

  • Repository permissions: Metadata, Pull requests の Read-only
  • Organization permissions: Members
    ※ 今回のに関係ありそうなやつだけ。プルリクの一覧と、メンバー一覧が取れたらいいんじゃないかな?という勘で。

Subscribe to events

※ どういうこと?
ぜんぜんわかんないんだけど、今回のに関係あるような気がする以下をチェック

  • Meta: When this App is deleted and the associated hook is removed.
  • Member: Collaborator added to, removed from, or has changed permissions for a repository.
  • Membership: Team membership added or removed.
  • Pull request: Pull request assigned, auto merge disabled, auto merge enabled, closed, converted to draft, demilestoned, dequeued, edited, enqueued, labeled, locked, milestoned, opened, ready for review, reopened, review request removed, review requested, synchronized, unassigned, unlabeled, or unlocked.
  • Repository: Repository created, deleted, archived, unarchived, publicized, privatized, edited, renamed, or transferred.
  • Team: Team is created, deleted, edited, or added to/removed from a repository.
  • Team added or modified on a repository.

Where can this GitHub App be installed?

  • Any account にチェック (Allow this GitHub App to be installed by any user or organization.)
Coji MizoguchiCoji Mizoguchi

うん、GitHub Apps を GitHub 上で作った (Register) したのはいいけど、そっからどうすんのw

Coji MizoguchiCoji Mizoguchi

目次的に次にある「GitHub App の管理」を読んでみる。多分ユーザ視点で App を追加・削除したりする方法な気がする。

Coji MizoguchiCoji Mizoguchi
  1. [GitHub アプリの設定] ページで、アプリを選択します。
  2. 左側のサイドバーで、 [アプリのインストール] をクリックします。
  3. 適切なリポジトリを含む組織または個人アカウントの横にある [インストール] をクリックします。
  4. すべてのリポジトリ、もしくは選択したリポジトリにアプリケーションをインストールしてください。

"1." が不明すぎたんだけど https://github.com/settings/apps にアクセスして出てくる一覧の中の avatar 画像をクリックまたは「Edit」ボタンクリックすると「アプリの選択」になる模様。

その上で、2. のサイドバーから「Install App」をクリックする

そうすると、自分が参加してる個人または Organization の一覧が出てきて、Install ボタンを押す。
これでインストール画面になる。

Coji MizoguchiCoji Mizoguchi

よくわかんないけど、とりあえず個人リポジトリの全部に install されたようだ。

画面真ん中には対象リポジトリの変更。
画面下部 Danger zone でアプリの一時停止と削除がある。

Coji MizoguchiCoji Mizoguchi

アプリを入れた個人リポジトリでの Settings => GitHub Apps を見るとこんなかんじ。

Coji MizoguchiCoji Mizoguchi

まずは nodejs で tsx つかって typescript の cli として動かそう。
雛形。

const main = () => {
  console.log("Hello github-app!")
}
main()
{
  "name": "hello-githup-app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "tsx hello"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "tsx": "^3.12.1",
    "typescript": "^4.9.4"
  }
}

実行

% pnpm dev

> hello-githup-app@1.0.0 dev /Users/coji/progs/spike/github-rest/hello-githup-app
> tsx hello

Hello github-app!
Coji MizoguchiCoji Mizoguchi

GitHub API をコールするのに、まずアクセストークンが必要らしい。
アクセストークンの取得のためには、以下3つが必要らしい。

  1. 秘密鍵 (pem file)
  2. app_id
  3. installation_id
Coji MizoguchiCoji Mizoguchi

サンプルコードは ruby で書いてある。Rails っぽい書き方してあるな。。

Coji MizoguchiCoji Mizoguchi

アクセストークンを発行するために installations_id というIDが必要になるので、AppsのメニューにあるAdvancedから installations_id をメモする。installations_idは下記に記してあります。

X-GitHub-Event: installation の Payload -> `/installations/{installation_id}/access_tokens`の `installation_id`

を見つけられない

Coji MizoguchiCoji Mizoguchi

Apps メニューの Advanced をクリックしたら 「Recent Deliveries」というところに uuid 的なのと instration.created などと表示されるログっぽいのがある。

これの ... をクリックすると
POST Request の詳細が書いてある。

どうもアプリ登録時に設定した webhook URL にインストールを通知した webhook が飛んでるようだ。そこのbody (payload) に json で 以下のようなのが入ってる。

今回は installation.id のところにある 31976601 がそれっぽい。
アプリでこれ受け取って保存しとくのかな。

{
  "action": "created",
  "installation": {
    "id": 31976601,
    "account": {
      "login": "coji",
      "id": 9904,
      "node_id": "MDQ6VXNlcjk5MDQ=",
      "avatar_url": "https://avatars.githubusercontent.com/u/9904?v=4",
      "gravatar_id": "",
      "url": "https://api.github.com/users/coji",
      "html_url": "https://github.com/coji",
      "followers_url": "https://api.github.com/users/coji/followers",
      "following_url": "https://api.github.com/users/coji/following{/other_user}",
      "gists_url": "https://api.github.com/users/coji/gists{/gist_id}",
      "starred_url": "https://api.github.com/users/coji/starred{/owner}{/repo}",
      "subscriptions_url": "https://api.github.com/users/coji/subscriptions",
      "organizations_url": "https://api.github.com/users/coji/orgs",
      "repos_url": "https://api.github.com/users/coji/repos",
      "events_url": "https://api.github.com/users/coji/events{/privacy}",
      "received_events_url": "https://api.github.com/users/coji/received_events",
      "type": "User",
      "site_admin": false
    },
    "repository_selection": "all",
    "access_tokens_url": "https://api.github.com/app/installations/31976601/access_tokens",
    "repositories_url": "https://api.github.com/installation/repositories",
    "html_url": "https://github.com/settings/installations/31976601",
    "app_id": 270180,
    "app_slug": "coji-s-hello-github-app",
    "target_id": 9904,
    "target_type": "User",
    "permissions": {
      "metadata": "read",
      "pull_requests": "read"
    },
    "events": [
      "member",
      "membership",
      "pull_request",
      "repository",
      "team",
      "team_add"
    ],
    "created_at": "2022-12-08T11:20:18.000+09:00",
    "updated_at": "2022-12-08T11:20:19.000+09:00",
    "single_file_name": null,
    "has_multiple_single_files": false,
    "single_file_paths": [

    ],
    "suspended_by": null,
    "suspended_at": null
  },
  "repositories": [
    {
      "id": 507292526,
      "node_id": "R_kgDOHjyrbg",
      "name": "upflow",
      "full_name": "coji/upflow",
      "private": false
    },
  ],
  "requester": null,
  "sender": {
    "login": "coji",
    "id": 9904,
    "node_id": "MDQ6VXNlcjk5MDQ=",
    "avatar_url": "https://avatars.githubusercontent.com/u/9904?v=4",
    "gravatar_id": "",
    "url": "https://api.github.com/users/coji",
    "html_url": "https://github.com/coji",
    "followers_url": "https://api.github.com/users/coji/followers",
    "following_url": "https://api.github.com/users/coji/following{/other_user}",
    "gists_url": "https://api.github.com/users/coji/gists{/gist_id}",
    "starred_url": "https://api.github.com/users/coji/starred{/owner}{/repo}",
    "subscriptions_url": "https://api.github.com/users/coji/subscriptions",
    "organizations_url": "https://api.github.com/users/coji/orgs",
    "repos_url": "https://api.github.com/users/coji/repos",
    "events_url": "https://api.github.com/users/coji/events{/privacy}",
    "received_events_url": "https://api.github.com/users/coji/received_events",
    "type": "User",
    "site_admin": false
  }
}
Coji MizoguchiCoji Mizoguchi

今回の設定項目は以下

AppId: 270180
InstallationId: 31976601

あとは秘密鍵か。

Coji MizoguchiCoji Mizoguchi

ruby のコードを読み解くと、

https://api.github.com/installations/${INSTALLATION_ID}/access_tokens

に対して、リクエストヘッダに以下を含めて POST をする。

Authorization: Bearer ${JSON_WEB_TOKEN}
Accept: application/vnd.github.machine-man-preview+json

POST の Payload は以下 (nodejs前提)

{
  iat: dayjs().unix(),
  exp: dayjs().add(10, 'minutes').unix(),
  iss: process.env.APP_ID
}

すると、レスポンスが JSON で帰ってきて、そのなかの token プロパティにアクセストークンが入っているよ、ということのようだ。

Coji MizoguchiCoji Mizoguchi

クラスメソッドさんが js で書いてる記事があった。古いけど大丈夫かな。

https://dev.classmethod.jp/articles/register-github-app-and-get-access-token/#toc-9

const jwt = require("jsonwebtoken")
const fs = require("fs")
const axios = require("axios")

const payload = {
    exp: Math.floor(Date.now() / 1000) + 60,  // JWT expiration time
    // ちょっとだけ時間を手前にしておくとアクセストークンの発行に失敗し辛いらしい。
    // https://qiita.com/icoxfog417/items/fe411b94b8e7ae229e3e#github-apps%E3%81%AE%E8%AA%8D%E8%A8%BC
    iat: Math.floor(Date.now() / 1000) - 10,       // Issued at time 
    iss: <your-apps-id>
}

const cert = fs.readFileSync("./test.pem").toString()
const token = jwt.sign(payload, cert, { algorithm: 'RS256'});

axios.default.post("https://api.github.com/installations/<your-installation-id>/access_tokens", null, {
    headers: {
        Authorization: "Bearer " + token,
        Accept: "application/vnd.github.machine-man-preview+json"
    }
})
.then(res => console.log(res.data.token)) // とりあえずログに吐く
.catch(console.log) // とりあえずログに吐く
Coji MizoguchiCoji Mizoguchi

これでとれた。

hello.ts

import jwt from 'jsonwebtoken'
import fs from 'node:fs/promises'
import dayjs from 'dayjs'
import dotenv from 'dotenv'
dotenv.config()

/**
 * JWT を生成する
 * @param payload
 * @returns
 */
const buildToken = async (payload: object) => {
  const cert = await fs.readFile('./config/secret-key.pem', 'utf8')
  return jwt.sign(payload, cert, { algorithm: 'RS256' })
}

/**
 * アクセストークンを取得する
 */
const main = async () => {
  const payload = {
    iat: dayjs().unix() - 10, // ちょっと前にすると無効になりにくいらしい。
    exp: dayjs().add(10, 'minutes').unix(), // 10分間だけ有効
    iss: process.env.APP_ID,
  }

  const ret = await fetch(
    `https://api.github.com/app/installations/${process.env.INSTALLATION_ID}/access_tokens`,
    {
      method: 'POST',
      body: JSON.stringify(payload),
      headers: {
        Authorization: `Bearer ${await buildToken(payload)}`,
        Accept: 'application/vnd.github.machine-man-preview+json',
      },
    },
  )

  console.log(await ret.json())
}
main()

出力

{
  token: 'ghs_*******************************',
  expires_at: '2022-12-08T06:38:59Z',
  permissions: { metadata: 'read', pull_requests: 'read' },
  repository_selection: 'all'
}

dotenv

.env
APP_ID=270180
INSTALLATION_ID=31976601

事前に config/secret-key.pem に GitHub App の設定画面で生成&ダウンロードした秘密鍵ファイルをおいておく。

Coji MizoguchiCoji Mizoguchi

次は取得したアクセストークンでプルリク一覧取れるかやってみる。

Coji MizoguchiCoji Mizoguchi

改造して pulls を1件取得して表示する。

main.ts
import jwt from 'jsonwebtoken'
import fs from 'node:fs/promises'
import dayjs from 'dayjs'
import dotenv from 'dotenv'
dotenv.config()

/**
 * JWT を生成する
 * @param payload
 * @returns
 */
const buildToken = async (payload: object) => {
  const cert = await fs.readFile('./config/secret-key.pem', 'utf8')
  return jwt.sign(payload, cert, { algorithm: 'RS256' })
}

/**
 * アクセストークンを取得する
 */
const requestAccessToken = async () => {
  const payload = {
    iat: dayjs().unix() - 10, // ちょっと前にすると無効になりにくいらしい。
    exp: dayjs().add(10, 'minutes').unix(), // 10分間だけ有効
    iss: process.env.APP_ID,
  }

  const ret = await fetch(
    `https://api.github.com/app/installations/${process.env.INSTALLATION_ID}/access_tokens`,
    {
      method: 'POST',
      body: JSON.stringify(payload),
      headers: {
        Authorization: `Bearer ${await buildToken(payload)}`,
        Accept: 'application/vnd.github.machine-man-preview+json',
      },
    },
  )

  // アクセストークンを取得
  const { token: accessToken } = await ret.json()
  return accessToken
}

const main = async () => {
  // アクセストークンを取得
  const accessToken = await requestAccessToken()

  // 最新のプルリクエストを取得
  const pulls = await fetch(
    `https://api.github.com/repos/coji/upflow/pulls?state=all&per_page=1`,
    {
      method: 'GET',
      headers: {
        Accept: 'application/vnd.github.v3+json',
        Authorization: `Bearer ${accessToken}`,
        'X-GitHub-Api-Version': '2022-11-28',
      },
    },
  )

  console.log(await pulls.json())
}
main()
実行結果
[
  {
    url: 'https://api.github.com/repos/coji/upflow/pulls/57',
    id: 1092473610,
    node_id: 'PR_kwDOHjyrbs5BHdMK',
    html_url: 'https://github.com/coji/upflow/pull/57',
    diff_url: 'https://github.com/coji/upflow/pull/57.diff',
    patch_url: 'https://github.com/coji/upflow/pull/57.patch',
    issue_url: 'https://api.github.com/repos/coji/upflow/issues/57',
    number: 57,
    state: 'closed',
    locked: false,
    title: 'fix stage',
    user: {
      login: 'coji',
      id: 9904,
      node_id: 'MDQ6VXNlcjk5MDQ=',
      avatar_url: 'https://avatars.githubusercontent.com/u/9904?v=4',
      gravatar_id: '',
      url: 'https://api.github.com/users/coji',
      html_url: 'https://github.com/coji',
      followers_url: 'https://api.github.com/users/coji/followers',
      following_url: 'https://api.github.com/users/coji/following{/other_user}',
      gists_url: 'https://api.github.com/users/coji/gists{/gist_id}',
      starred_url: 'https://api.github.com/users/coji/starred{/owner}{/repo}',
      subscriptions_url: 'https://api.github.com/users/coji/subscriptions',
      organizations_url: 'https://api.github.com/users/coji/orgs',
      repos_url: 'https://api.github.com/users/coji/repos',
      events_url: 'https://api.github.com/users/coji/events{/privacy}',
      received_events_url: 'https://api.github.com/users/coji/received_events',
      type: 'User',
      site_admin: false
    },
    body: null,
    created_at: '2022-10-19T14:51:12Z',
    updated_at: '2022-10-19T14:53:50Z',
    closed_at: '2022-10-19T14:53:49Z',
    merged_at: '2022-10-19T14:53:49Z',
    merge_commit_sha: '428f6699529334e02242c941db77172720e57689',
    assignee: null,
    assignees: [],
    requested_reviewers: [],
    requested_teams: [],
    labels: [],
    milestone: null,
    draft: false,
    commits_url: 'https://api.github.com/repos/coji/upflow/pulls/57/commits',
    review_comments_url: 'https://api.github.com/repos/coji/upflow/pulls/57/comments',
    review_comment_url: 'https://api.github.com/repos/coji/upflow/pulls/comments{/number}',
    comments_url: 'https://api.github.com/repos/coji/upflow/issues/57/comments',
    statuses_url: 'https://api.github.com/repos/coji/upflow/statuses/6aa200b08ce72ed9c6da92c7525075e5d1c32767',
    head: {
      label: 'coji:chore/playwright',
      ref: 'chore/playwright',
      sha: '6aa200b08ce72ed9c6da92c7525075e5d1c32767',
      user: [Object],
      repo: [Object]
    },
    base: {
      label: 'coji:main',
      ref: 'main',
      sha: '63dac24b78590ed386a96a9996752908eb4ce8b8',
      user: [Object],
      repo: [Object]
    },
    _links: {
      self: [Object],
      html: [Object],
      issue: [Object],
      comments: [Object],
      review_comments: [Object],
      review_comment: [Object],
      commits: [Object],
      statuses: [Object]
    },
    author_association: 'OWNER',
    auto_merge: null,
    active_lock_reason: null
  }
]
Coji MizoguchiCoji Mizoguchi

とりあえずこれで取れた、のかな。

最初のインストールの webhook で install id と対象リポジトリが取れるので、それを保存しておいて、定期的に pulls を全リポジトリ対象にとって集計して出せばよさそうです。