RustでGithub Appを呼び出す

この記事は[ユニークビジョン株式会社 Advent Calendar 2024](https://qiita.com/advent-calendar/2024/uniquevision)の11日目です。

### はじめに

最近、APIリクエストを受けた際に自動的にGitHubブランチを作成する機能を実装しました。

単純にGitHub APIを直接呼び出すことも可能ですが、そのアプローチには以下のような課題があります：

- APIトークンの安全な管理
　特定のユーザーにトークンを紐づけておくと、そのユーザーが異動や退職した際にトークンの管理が困難になります。
- 大規模システムにおける権限管理の複雑さ
複数の作業を自動化する場合、APIトークンの実装だとスコープが広くなり、権限管理が難しくなります。

これらの課題に対応するため、今回はGitHub Appを活用した実装方法を選択しました。本記事では、その実装と得られた知見について紹介します。

### Github Appとは

GitHub Appは、GitHubの機能を拡張するツールです。GitHub上での操作（Issue作成、PRへのコメント、プロジェクト管理など）が可能であったり、GitHub上のイベントに基づいて外部サービスと連携可能（例：IssueがオープンされたらSlackに投稿）です。


以下のような特徴があります。
- 細かい権限設定が可能
  - リポジトリ単位で必要最小限の権限を付与できます
  - 例：イシューの作成のみ、PRの作成のみなど
- webhook対応
  - GitHubのイベントをリアルタイムで検知し、自動処理を実行可能
- JWT認証による安全な通信
  - 一時的なトークンを使用するため、セキュリティリスクを軽減


[GitHub Marketplace](https://github.com/marketplace?type=apps)から、様々なGitHub Appをインストールできます。自分でGitHub Appを作成することも可能です。どちらの場合も、使いたいリポジトリに対してインストールする必要があります。

### GitHub Appのしくみ
大きく分けてWebhookをトリガーとして実行する方法と、APIを直接呼び出す方法、Github Actionsをトリガーとして実行する方法があります。今回はAPIを直接呼び出す方法を紹介します。

シーケンス図は以下のようになっています。

```mermaid
sequenceDiagram
    participant Client as クライアント
    participant Backend as バックエンドAPI
    participant App as GitHub App
    participant GitHub as GitHub
    participant API as GitHub API

    Client->>Backend: 1. APIリクエスト
    Backend->>App: 2. JWT認証
    App-->>Backend: 3. レスポンス
    Backend->>App: 4. トークン要求
    App->>GitHub: 5. トークン要求
    GitHub-->>App: 6. レスポンス
    App-->>Backend: 7. レスポンス
    Backend->>App: 8. API呼び出し
    App->>API: 9. レスポンス
    API-->>App: 10. レスポンス
    App-->>Backend: 11. レスポンス
    Backend-->>Client: 12. レスポンス
```

この図は、クライアントからのリクエストを起点に、以下の流れでGitHub操作を実行します：

1. JWT認証によりGitHub Appの認証
2. 認証後、一時的なアクセストークンを取得
3. トークンを使用してGitHub APIを呼び出し
4. 結果をクライアントに返却


### GitHub Appの作成
1. GitHubの[GitHub Apps](https://github.com/settings/apps)にアクセスします。
2. 「New GitHub App」をクリックします。
3. GitHub App nameを入力します。ここは全世界でユニークである必要があるので、個人で使う限りはmisty1999-github-appのようにprefixとして自分のIDを入れると良いと思います。Homepage URLはなんでもいいです。Webhook URLは今回は設定する必要はありません。
4. 「Create GitHub App」をクリックします。このような画面になります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3563801/64827476-3531-3308-fda2-8f48d8380999.png)
5. 下のほうにある「Generate a private key」をクリックします。「Generate」をクリックします。秘密鍵がダウンロードされます。
6. 権限を設定します。左側の「Permissions&events」をクリックします。Repository permissionsのところで、Repository permissions>issuesをRead & writeを選択します。

以上でGitHub Appの作成は完了です。作成したGitHub AppのClient IDと秘密鍵をバックエンドAPIで使います。

続いて、作成したAppを自分のリポジトリにインストールします。作成したAppのページの左側にある「Install App」をクリックします。自分のユーザー名の右側の「Install」を選択します。only select repositoriesから自分のリポジトリを選択して「Install」を押せば、リポジトリへのインストールが完了します。

### Octocrabについて

[octocrab](https://github.com/XAMPPRocky/octocrab)は、GitHub APIをラップしたRustのクレートです。GitHub AppやGitHub Actionsに関する操作を直感的に実装することができます。先ほどの図でバックエンドからGitHubにアクセスする処理がありましたが、今回の実装ではバックエンドからAppを呼び出す代わりにoctocrabを使ってクライアントを作成し、そこから呼び出しています。クライアントを使用することで、認証情報やレートリミットの機能を一元的に管理できるメリットがあります。

### バックエンドAPIの実装

https://github.com/misty1999/my-git-apps
こちらにGitHub Appを使ったAPIの実装をしています。メインの処理は[create_issue.rs](https://github.com/misty1999/my-git-apps/blob/master/backend/src/create_issue.rs)で行っています。今回はissueを自動作成できるようにしてみました。

```rust
use jsonwebtoken::EncodingKey;
use octocrab::Octocrab;
use std::error::Error as StdError;

const REPO_OWNER: &str = "misty1999";
const REPO_NAME: &str = "my-git-apps";

pub async fn create_issue(title: &str, body: &str) -> Result<String, String> {
    // 設定を取得
    let config = crate::config::Config::from_env()
        .map_err(|e| format!("設定の読み込みに失敗: {}", e))?;
    
    let app = config.github_app
        .ok_or("GitHub App設定が見つかりません")?;
```

ここまでで、GitHub Appの設定を読み込んでいます。`REPO_OWNER`と`REPO_NAME`は自分のリポジトリを指定しています。config.github_appには、GitHub AppのIDと秘密鍵が入っています。config.rsで、`.env`と`private-key.pem`を読み込んでいます。Github AppのIDと秘密鍵をそれぞれのファイルに入れています。

```rust
    // GitHub Appクライアントの作成
    let octocrab = Octocrab::builder()
        .app(
            octocrab::models::AppId(
                app.id.parse::<u64>()
                    .map_err(|e| format!("GitHub App IDのパースに失敗: {}", e))?
            ),
            EncodingKey::from_rsa_pem(app.secret.trim().as_bytes())
                .map_err(|e| format!("RSA秘密鍵のパースに失敗: {}", e))?
        )
        .build()
        .map_err(|e| format!("Octocrabインスタンスの作成に失敗: {}", e))?;

    // インストールトークンの取得
    let installation = octocrab
        .apps()
        .get_repository_installation(REPO_OWNER, REPO_NAME)
        .await
        .map_err(|e| format!("インストレーション情報の取得に失敗: {}\nステータスコード: {}", 
            e.to_string(),
            e.source().map_or("不明".to_string(), |s| s.to_string())
        ))?;

    let access_token: octocrab::models::InstallationToken = octocrab
        .post(
            format!("/app/installations/{}/access_tokens", installation.id),
            None::<&()>
        )
        .await
        .map_err(|e| e.to_string())?;
```
この部分は特に重要で、GitHub Appの認証フローの核となる処理です：
1. 秘密鍵をセットしたクライアントを作成
2. 秘密鍵から作成したjwtによる認証を行う
3. 認証が完了したので、取得したインストールIDを使用してアクセストークンを発行

```rust
    // Issueの作成
    let issue = Octocrab::builder()
        .personal_token(access_token.token)
        .build()
        .map_err(|e| e.to_string())?
        .issues(REPO_OWNER, REPO_NAME)
        .create(title)
        .body(body)
        .send()
        .await
        .map_err(|e| e.to_string())?;

    Ok(format!("Issue '{}' を作成しました (#{})", title, issue.number))
}
```
認証が完了したので、GitHub APIを呼び出してIssueを作成しています。

### まとめ
・GitHub Appを使うことで、属人化を防ぎつつ、よりセキュリティに配慮した実装が可能
・GitHub Appをクライアントから使う場合、jwtを使って認証を行い、それを使ってアクセストークンを取得するという多段階の認証を行う必要がある
・octocrabを使うと、この認証を直感的に実装できる