Well in Time
利用 Cloud function 製作 GitHub Apps
06-14-202013 Min Read

好歌分享:MONKEY MAJIK × 岡崎体育 / 留学生

前言

前陣子在公司的專案裡頭想引用 standard-version 這套工具來優化 release changelog 的過程,但發現雖然可以用 commitlint 或是 commitizen 來輔助大家遵循 commit message 的 convention,卻沒辦法簡單的控制 Pull Request 的 title 格式,或是在 GitHub 上 squash merge 時的 commit format,雖然不是太大的問題,code review 的時候稍微注意一下即可,但還是很希望能有個工具來幫忙,心靈上會舒服些。

GitHub 的 marketplace 上其實找得到可用的 GitHub Apps,可惜公司 policy 的緣故,無法直接使用,試想了ㄧ下原理與實作方法的選項,覺得足夠簡單,可以自己實作,順便學習如何製作 GitHub App,並以這篇文章與大家稍作分享。原始碼分享於此 - PRLint-serverless

效果大致如下,依據你的 PR title 有無符合特定格式,改變 status check 的狀態:

DEMO

需求分析

想達成 Demo 的功能,我們需要監聽 Pull Request 被 Create、Update 的事件,並且透過 GitHub API 將 Pull Request 上的狀態做更改。

而要能監聽 GitHub 上的事件,想必是需要設定 webhook endpoint 給 GitHub 呼叫。若是幾年前的我,大概直覺會想到去 Heroku 或是 Digital ocean 開一個最低規格的機器來架 server,但現在我們有了各種 serverless 服務可以使用,AWS Lamda、GCP Cloud Function、Azure Function 等等,基本上只要寫好一個 function 就能 deploy 上去當作 webhook 給其他服務呼叫了。用什麼都可以,但因為公司使用的是 GCP 平台,所以我也就順勢採用 Cloud Function 來作為我的 webhook endpoint。

總結所需要的技術只有兩個:GitHub API 與 Cloud Function。

GitHub API

GitHub 開放的 API 很多,每個 API 可以控制的權限分得很細,官方文件針對每個 API 的參數、用法都有提供範例與解釋,不過我覺得有些專屬於 GitHub API 的名詞還是需要花點時間去額外搜尋資料釐清。

github api doc

目前 GitHub 上有使用 GraphQL 的 v4 版本,以及 Rest API 的 v3,兩種都能使用,端看你的需求,這次的實作是採用 Rest API。

若要監聽 Pull Request 的 event,得用到 - Event types and payloads API,從中可以找到 PullRequestEvent

github-api-pr-event

event 回傳的 payload 包藏不少資訊,從 action 中可以得知該 event 是被哪種操作所觸發,像是 opened, closededited 等等。而關於 Pull Request 的詳細內容,會放在 pull_request 這個物件裡,從 Pull Request APIGet a pull requestResponse 中,我們可以找到 API 回傳的完整 payload 範例,資訊含量非常多,你在 GitHub UI 看得到的內容都找得到,甚至包含 Repo 的資訊。

這些豐富的資訊中,有一個 statuses_url,這是我們創建 Pull request 狀態的端點,待會我們會再提到,可以從 Statues API 了解。

Cloud Function

了解要使用的 GitHub API 後,接著就是要撰寫我們的 webhook endpoint,也就是 Cloud Function。要開始使用 Cloud Function 很簡單,到你的 GCP project 底下點選 Cloud Functions,按下 CREATE FUNCTION 即可。

create-cloudfunction

創建 Cloud Function 的頁面上可以設定 function 名稱、要配置的記憶體大小、Trigger 的介面(除了能被 HTTP 的 request 觸發外,也能設定由 Cloud Storage、Firestore、Cloud pub/sub 等等服務來啟動函式執行)

cloud-function-details

URL 就是此 cloud function 的 endpoint,到時候就是要把這個 url 設定到 GitHub 的 webhook 上。此外,要記得把 Allow unauthenticated invocations 的選項打勾,此舉能將該 endpoint 公開給所有人存取,GitHub webhook 也才能打得到這隻 API。

接著最後就是設定程式碼的部分,你可以直接把程式碼貼上(inline editor)、壓縮成 zip 檔上傳(ZIP upload, ZIP from cloud storage)和連接 repository(cloud source repository)。

也有多種 runtime 可以選擇:

cloud-function-runtimes

runtime 結構大同小異,都會有一個 entry file,與一個對應的套件管理檔案,以 NodeJS 為例就是一個 index.jspackage.json。因此你要在你的 cloud function 中使用第三方套件是沒問題的。

另外,也能夠有不同的資料夾結構,將一些邏輯拆分到別的檔案再 import 進來也可以(依照相對路徑存取),但當然就必須選擇 ZIP upload 等方式上傳你的專案。

至於 Cloud Function 的基本結構,可以從 inline editor 提供的範例來觀察,以 NodeJS 為例:

/**
 * Responds to any HTTP request.
 *
 * @param {!express:Request} req HTTP request context.
 * @param {!express:Response} res HTTP response context.
 */
exports.helloWorld = (req, res) => {
  let message = req.query.message || req.body.message || 'Hello World!';
  res.status(200).send(message);
};

其實就像是 Express 的一個 route 或 middleware 的結構,傳入 reqres 物件讓你操作。

exports 的名稱則是用在設定中,讓 Cloud Function 知道要呼叫哪個函式:

cloud-funciton-name

開始實作

你可能會有個疑惑,雖然我們已經知道 cloud function 的結構與設定方式,但難道我每寫完一段程式想要測試一下時,就得重新上傳到 cloud function 一次嗎?

當然不用,Google Cloud team 有推出一個 @google-cloud/functions-framework 套件可以使用,透過 functions-framework --target=${function name} 的方式啟動你的 cloud function,會幫你起一個 express server,監聽在 port 8080:

cloud-function-framework-cli

接著你可以使用 ngrok 將其 expose 成 public access 的 url,就能用來設定在 webhook 上,同時又能一邊持續開發。

結合 GitHub API 與 Cloud Function

當你有了 webhook url,就可以先到 GitHub repo 去設定看看,實際測試 webhook 與 GitHub API 的串連。方法也很簡單,到你想使用的 repository 中,選擇 Settings -> Webhooks -> Add webhook,就會看到下面的畫面:

git-webhook

Payload URL 填入你的 ngrok url,Content-type 可以選擇 json 格式。

最後注意一下,你可以選擇哪些 events 會 trigger 你的這隻 webhook,選擇 Let me select individual events. 並勾選 Pull Requests 的選項,這樣才不會拿到其餘你不需要的事件資訊。

select-individual-event

pull-request-event

設定完後回到我們的程式碼,最基礎的 webhook 架構如下:

const prStatus = ['opened', 'edited', 'ready_for_review'];
exports.prLint = async (req, res) => {
  const { pull_request: pullRequest = {}, action } = req.body;
  const { statuses_url: statusesUrl, title } = pullRequest;
  if (prStatus.indexOf(action) !== -1) {
    // check pr title
    const isValid = validatePullReqeustTitle(title);
    // create status
    // ...
  }
  return res.status(200);
};

依照我們在 GitHub API 所瞭解到的 Event API 與 Pull Request Object,我們知道可以從 req.body 中取出 pull_request 物件,而在該物件中能取得 actiontitlestatuses_url 兩個我們需要的資訊。

接著就能實作我們 GitHub App 想要的功能邏輯,包含 filter 掉我們不想要的 action 操作、驗證 Title 是否有符合格式、創建 pull request status 等等。

創建 pull request status

程式碼如下:

// call status api
const body = {
  state: isValid ? 'success' : 'error',
  description: isValid ? 'pass pr lint' : 'please check your pr title',
  context: 'pr-lint',
};
const headers = {
  Authorization: `Token ${accessTokens}`,
  Accept: 'application/json',
};

try {
  const stream = await fetch(statusesUrl, {
    method: 'POST',
    headers,
    body: JSON.stringify(body),
    json: true,
  });
  await stream.json();
  return res.status(200);
} catch (err) {
  return res.status(400).json({
    message: 'PR lint error',
  });
}

使用上來說非常簡單,Statues API 接收的 Post body 有四個 properties 可以設置:

{
  "state": "success", // error, failure, pending, or success.
  "target_url": "https://example.com/build/status",
  "description": "The build succeeded!",
  "context": "continuous-integration/jenkins"
}

state 就是你想設定的狀態,有四種可以選;target_url 則是使用者點選該狀態後要連結去的地方,可以忽略不設;description 就是顯示在狀態列的文字;而 context 則是讓系統知道這是由第三方 App 所創立的 status。

要發送 Post API 到 GitHub 上需要有 accessToken,有使用過 webhook 的讀者應該知道,我們可以輕易從 GitHub 個人 profile settings 中的 Developer options 產生 Personal Token:

github-personal-token

取得 personal token 後填入上方範例程式碼的 accessTokens,就能夠發送 Post request 到我們從 pull request event 中取得的 statuses_url,在該 Pull Request 的頁面產生一個 Check status:

github-check-status

到這邊為止看起來就完成了,只要我們把程式碼部署到 Cloud Function 上,將 Webhook 的 URL 更改成實際的連結,一切就大功告成。

對,也不對。

如果你仔細看一下你創建的 Check Status,你會發現因為你用的是 Personal Token,他會顯示該狀態是由你本人產生的:

github-check-status-issue

這當然不是太大的問題,但看起來不是很專業,而且當你用在多個公司專案時,總是出現你的大頭貼好像很討人厭啊。要解決這問題,就需要創建 GitHub App 了。

GitHub App

GitHub App 目前有分兩種類型:OAuth Apps 與普通的 GitHub Apps,官網有詳細的差別說明,我們的案例只需要用到一般的 GitHub Apps 即可,一樣在官網有手把手的創建教學範例

我們之所以需要用到 GitHub App,是因為我們想要能夠以 GitHub App 的名義去取得 AccessToken,利用該 AccessToken 去創建 pull request 的 check status。

為此,有幾個步驟需要進行:

在 GitHub 上新增一個 GitHub App

在你個人的 GitHub developer settings 頁面 中,有個 GitHub Apps 的選項,可以 New GitHub App

github-apps

創建的時候有很多欄位可以填選,像是 App 名稱、網站、Logo 等等,但基本上重要的只有 WebhookRepository Permissions(其實 GitHub App 除了 repository permission 可以設定外,也能設定到 Organization 與 User 兩種不同層級的權限,不過目前我們只需要 repository 層級即可):

跟先前我們在 repo 的 webhook 是ㄧ樣的

github-app-webhook

為了讓我們的 GitHub App 能存取 Repo 的 Pull request 與 status,需要將這兩個的權限設定為 Read&Write。

github-app-repo-permission

當你設定完後,下方會出現你可以訂閱的 Event,而我們一樣選擇 pull request

github-app-subscribe-event

產生該 App 的 Private keys

當你都創建好 App 後,App settings 的頁面最下方會有一個 Private keys 的區塊,點選 Generate a private key 的按鈕,會自動下載一份 .pem 的檔案到你電腦裡,而這把 Key 就是我們用來產生 JWT 的關鍵:

github-app-private-key

利用該 Private keys 去產生 JWT(JSON Web Token)

產生 JWT 的方式有很多,在 NodeJS 上我是用 Auth0 的 jsonwebtoken 這個套件。

要產生 GitHub App 能使用來取得 AccessToken 的 JWT,需要將一些資訊利用剛剛下載的那把 key 簽署到 JWT 上 ref

module.exports = function getJWT() {
  const payload = {
    // issued at time
    iat: Math.floor(Date.now() / 1000),
    // JWT expiration time (10 minute maximum)
    exp: Math.floor(Date.now() / 1000) + 10 * 60,
    // GitHub App's identifier
    iss: YOUR_APP_ID, // https://github.com/settings/apps/${your app}
  };
  let privateKey;
  try {
    privateKey = fs.readFileSync(__dirname + '/../key/your-app.private-key.pem');
  } catch (e) {
    console.log({ e });
  }
  return jsonwebtoken.sign(payload, privateKey, { algorithm: 'RS256' });
};

最主要的資訊是 iss,可以從你的 GitHub App 設定頁面取得 App 的 ID,而其餘時間的資訊其實對我們來說不太重要,因為每次 Cloud Funciton 被呼叫的時候,我們都會重新去申請一次 AccessToken,所以 Expiration 的時間問題不大。

透過 jsonwebtoken.sign 把剛剛下載的 Key 跟相關的 Payload 結合產生 JWT,接著就能拿這個 Token 去申請 AccessToken。

以該 JWT 與 GitHub App 的 installations id 去取得屬於該 App 的 AccessToken

要以 GitHub App 的身份取得 AccessToken 需要呼叫的 endpoint 為:

POST /app/installations/:installation_id/access_tokens ref

其中需要用到 GitHub App 的 installation id,而這個資訊其實也包含在我們 subscribe 的 pull request event 回傳的物件中:

-const { pull_request: pullRequest = {}, action } = req.body;
+const { pull_request: pullRequest = {}, action, installation } = req.body;

在呼叫 access token API 時要注意一點,官方文件特別叮囑:

Note: To access the API with your GitHub App, you must provide a custom media type in the Accept Header for your requests.

所謂的 custom meida type 就是 application/vnd.github.machine-man-preview+json,因此在呼叫 API 時記得要將 Accept 改成該類型。

const getAccessToken = async function (installationId = '') {
  try {
    // Get a JWT every time
    let JWT = getJWT();
    const response = await fetch(`${GITHUB_API_URL}/installations/${installationId}/access_tokens`, {
      method: 'POST',
      headers: {
        Accept: 'application/vnd.github.machine-man-preview+json',
        Authorization: `Bearer ${JWT}`,
      },
    });
    const result = await response.json();
    return result.token;
  } catch (exception) {
    // eslint-disable-next-line no-console
    console.log({ exception });
  }
};

修改 API request Header

最後取得 AccessToken 後,回到我們最初發送 Status API 的 request,將原有的 personal access token 取代掉,並將 Accept header 也改為 application/vnd.github.machine-man-preview+json,就大功告成了!

const headers = {
- Authorization: `Token ${personal accessToken}`,
+ Authorization: `Token ${github app accessToken}`,
- Accept: 'application/json',
+ Accept: 'application/vnd.github.machine-man-preview+json',
};

透過 GitHub App 取得的 AccessToken 所創建的 Check status 運作起來就會有這樣的效果,就是個第三方 App 所產生的,而不是你個人的大頭照:

github-app-final

完整程式碼請參考:PRLint-serverless

結論

一個不小心似乎又把篇幅拉得太長,使用 GitHub App 與 Cloud Function 其實真的很簡單,只是步驟稍微多了些,但每一個步驟都只需要做一點點事情,或是設定一些資訊,只要實作過一次後,要再次使用就會快很多了。

花費些微的力氣,利用 Serverless 的解決方案搭配 GitHub App/API,能提昇不少生產力,是很值得的投資,希望大家都能試試看!

資料來源

  1. GitHub Developer Guide
  2. Cloud Function Docs
  3. prlint github app
© by Arvin Huang. All rights reserved.
theme inspired by @mhadaily
Last build: 05-05-2023