GitHub Apps を試しに作ってみるテスト
プルリクのレビュアーが偏ってスループットが悪くなるので、こういうツールを作っています。
いいねをちょっともらえたので多少ニーズはあるのだろう、と思いました。
現状 GitLab にしか対応していないので、GitHub に対応する必要があります。リポジトリ単位での制御が可能な GitHub Apps になるのかな。ただ、どんな作業が必要かあたりを付けるために、試しに作ってみようと思います。
ひとまず公式のガイドを全部眺めてみる。
アプリの概要から。
- ワークフローを自動化し改善できる
- GitHub Marketplace でアプリを共有または販売できる
- データアクセスへの細やかな権限があるので公式に推奨されている方法
- GitHub Actions でアプリを使用できる。ワークフローファイルを変更したい場合はリポジトリの管理もしくは書き込み権限とともに workflow スコープを含む OAuth トークンでユーザの代わりに認証が必要。
GitHub Apps について
- GitHub App は独自で動作し、独自のIDを使用して API 経由で直接アクションする。そのため、ボットやサービスアカウントを別途用意する必要はない
- Organization や個人アカウントに直接インストールし、特定リポジトリへのアクセス権限を付与できる。
- 細かなアクセス権限がつけられて、webhook も組み込まれている
- ユーザはGitHub App をセットアップする際に、アクセスさせるリポジトリを選択できる。
- GitHub App をインストールできるのは、Organization のオーナーか、リポジトリごとの管理者のみ。
GitHub Apps の追加・削除の管理権限
- デフォルトでは Organization 無いの GitHub Apps の設定を管理できるのは Organization のオーナーだけ
- Organization 内の Apps を管理できるユーザ追加には、管理者がそのユーザに GitHub Apps 管理権限を許可すればok
GitHub Apps の作成で気をつけるべきこと
- ユーザまたは Organization が所持できる GitHub Apps は最大100まで。
- GitHub Apps はユーザに依存しないアクションを実行する必要がある。もしアプリがユーザーからサーバへのトークンを使用している場合はさらにセキュアにするために8時間後に期限切れになるアクセストークンと、リフレッシュトークンを使える (coji: どういうこと?)
- GitHub App は必ず特定のリポジトリと統合するようにしてください (coji: どういうこと?アプリ開発時の話?)
- GitHub App は個人アカウントまたは Organization に接続する必要があります
- ユーザができる全てのことを GitHub App が知り、行えると思わないでください (coji: どういうこと?)
- 単に「GitHub でログイン」するサービスが必要な場合は GitHub App を使わないこと。ただし、GitHub App ではユーザ識別フローを使ってユーザをログインさせ、他の操作を実行は可能。
- GitHub ユーザとして動作し、ユーザが実行できることをすべて実行したいだけの場合は、GitHub App をビルドしないでください (coji: どういうこと?)
- GitHub Actions でアプリを使用していて、ワークフローファイルを変更したい場合は、リポジトリの管理もしくは書き込み権限を持った上で、 workflow スコープを含む OAuth トークンでユーザの代わりに認証を受けなければならない。
なんか防衛的な書きっぷりがおおくて意味がわからない。
アプリ構築する場合の手段は以下3つ
- GitHub Apps
- OAuth Apps
- Personal Access Token
どれが良いかはこの図でみる。
今回の例だと
- Only as me ? => No (でいいのか?as ってどういう意味)
- Act as the app? => Yes (でいいのか?as ってどういう意味)
で、GitHub App になる。
いや、この図意味不明すぎて役にたたないよw
"Only as me" => 「インテグレーションは自分自身としてのみ振る舞うのか、それともアプリケーションのように振る舞うのか?」 だとすると No でいいはず。
"Act as the app?" => 「独自のエンティティとして、自分から独立して動作させるのか?」も多分 Yes でいいはず。
たぶん。。用語が意味不明すぎて判断できない。
わからないときは、とりあえず手を動かしながらやろう。
というわけで「GitHub App」を作成する。をやる。
いきなりこう書いてあるけど、よくわからないので、後で見る。
GitHub アプリ マニフェストを使用すると、構成済みの GitHub アプリを作成できます。使用方法については、「マニフェストから GitHub アプリを作成する」を参照してください。
Register new GitHub App の画面。
以下を入れる
- GitHub App name: coji's Hello GitHub App! (Hello GitHub App! だと重複エラーと出たので id いれた)
- Homepage URL: https://www.github.com/coji/hello-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
- Active: チェック (デフォルト)
- Webhook URL: https://hello-github-app-coji.vercel.app/api/webhook (必須なのでいったん適当に)
- Webhook secret (optional): 空欄 (デフォルト)
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.)
うん、GitHub Apps を GitHub 上で作った (Register) したのはいいけど、そっからどうすんのw
目次的に次にある「GitHub App の管理」を読んでみる。多分ユーザ視点で App を追加・削除したりする方法な気がする。
- [GitHub アプリの設定] ページで、アプリを選択します。
- 左側のサイドバーで、 [アプリのインストール] をクリックします。
- 適切なリポジトリを含む組織または個人アカウントの横にある [インストール] をクリックします。
- すべてのリポジトリ、もしくは選択したリポジトリにアプリケーションをインストールしてください。
"1." が不明すぎたんだけど https://github.com/settings/apps にアクセスして出てくる一覧の中の avatar 画像をクリックまたは「Edit」ボタンクリックすると「アプリの選択」になる模様。
その上で、2. のサイドバーから「Install App」をクリックする
そうすると、自分が参加してる個人または Organization の一覧が出てきて、Install ボタンを押す。
これでインストール画面になる。
よくわかんないけど、とりあえず個人リポジトリの全部に install されたようだ。
画面真ん中には対象リポジトリの変更。
画面下部 Danger zone でアプリの一時停止と削除がある。
アプリを入れた個人リポジトリでの Settings => GitHub Apps を見るとこんなかんじ。
app ページがつくられていた
このURLをシェアしたらインストールしてもらう導線つくれるらしい
https://github.com/apps/coji-s-hello-github-app/installations/new
やばい飽きてきたw なんか動くもの作らないと飽きる。
実際にコード書いて作るところは公式にはまったくなさそう?なので今度はこの note 記事を真似してみよう。
まずは 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!
GitHub API をコールするのに、まずアクセストークンが必要らしい。
アクセストークンの取得のためには、以下3つが必要らしい。
- 秘密鍵 (pem file)
- app_id
- installation_id
サンプルコードは ruby で書いてある。Rails っぽい書き方してあるな。。
アクセストークンを発行するために installations_id というIDが必要になるので、AppsのメニューにあるAdvancedから installations_id をメモする。installations_idは下記に記してあります。
X-GitHub-Event: installation の Payload -> `/installations/{installation_id}/access_tokens`の `installation_id`
を見つけられない
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
}
}
今回の設定項目は以下
AppId: 270180
InstallationId: 31976601
あとは秘密鍵か。
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 プロパティにアクセストークンが入っているよ、ということのようだ。
JSON_WEB_TOKEN の生成は今回は nodejs でやるので、npmの jsonwebtoken 使うのが定石なのかな。
クラスメソッドさんが js で書いてる記事があった。古いけど大丈夫かな。
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) // とりあえずログに吐く
調べ物ばかりで飽きた&つかれたのでちょっと休憩
これでとれた。
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
APP_ID=270180
INSTALLATION_ID=31976601
事前に config/secret-key.pem に GitHub App の設定画面で生成&ダウンロードした秘密鍵ファイルをおいておく。
次は取得したアクセストークンでプルリク一覧取れるかやってみる。
改造して pulls を1件取得して表示する。
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
}
]
とりあえずこれで取れた、のかな。
最初のインストールの webhook で install id と対象リポジトリが取れるので、それを保存しておいて、定期的に pulls を全リポジトリ対象にとって集計して出せばよさそうです。
今回書いたコードをまとめて github にあげといた。