Rust + Cloudflare WorkesとGithubを連携して、Slackに通知する

# はじめに

この記事はhttps://qiita.com/advent-calendar/2024/rust の16日目の記事です。

# 自己紹介

Rust歴、約10ヶ月の中級Rust使いになりたいものです。仕事では、PHPやPythonをメインを扱っており、個人で日々、Rustを書いてます。Rustについては、まだまだ勉強中です。

Rust歴も浅く、皆さんのためになるようなRust情報を提供ができてないかもしれませんが、Rustが好きなので、アドベントカレンダーを通して何かしらできたらな、という思いです。
至らない点があると思いますが、温かい目で見守ってもらえば思います。

# 本題

勉強の一環として、Cloudflare Workes、Githubを連携して、issueやPullRequestをSlackに通知する仕組みを作ってみました。こちらを共有したいと思います。

# 背景

Rustでオリジナルアプリ作っていくうちに、ネタが尽きてきたので何かと連携したものでも作ってみようか、と思ったのがきっかけでした。

調べてみると、Cloudflare Workesのチュートリアルにちょうど作成したissueを通知するSlackbotがあったので、これを参考にRustで作ることにしました。

https://developers.cloudflare.com/workers/tutorials/build-a-slackbot/#creating-the-webhook-route

# issue編

チュートリアルはnode.jsで作る手順になっているのを、Rustで実装しました。

## Slackでslash（/issue）コマンド実行

コードはroutingの処理部分になります。
Slackに表示されるまでの流れは下記で、Cloudflare Workesでリクエストとレスポンスの処理を実施してます。

1. Slackでの`/issue owner/repository#issue番号` コマンドを実行
2. 受け取ったリクエスト情報（owner/repository#issue番号）を元に、Gihubへissue情報取得リクエスト
3. issue情報を元にSlack投稿用のjson作成
4. 作成したjsonをレスポンス
5. issueの情報をが表示される

```
pub async fn lookup(mut req: Request, _ctx: RouteContext<()>) -> Result<Response> {
    let body = req.text().await?;

    if body.is_empty() {
        return Response::error("Not found", 404);
    }

    let params: SlashCommandRequest = serde_qs::from_str(&body).unwrap();

    let issue = Github
        .fetch_issue(&params.text)
        .await
        .map_err(|e| worker::Error::RustError(format!("{}", e)));

    match issue {
        Ok(issue) => {
            let result = serde_json::json!(
                SlashCommandResponse {
                    blocks: Slack.construct_gh_issue_slack_message(
                        &issue,
                        &Slack.issue_slack_message_text_lines(&issue, &body, "")
                    ),
                    response_type: "in_channel".to_string(),
                }
            );

            Response::from_json(&result)
        },
        Err(e) => Response::error(format!("{:?}", e), 500),
    }
}
```

### Slack表示

![20240710213903.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185639/9a9f509c-24ba-74d2-6ca7-2b177c9b20c6.png)


## issue作成自動通知〜Slack表示

Githubにwebhookを設定し、issue作成のアクションがあると、Slackに通知されるまでの流れが下記です。
こちらはGithubからリクエストされたものをSlack投稿用jsonへ変換し、通知してます。

1. webhookからのリクエストをjsonで取得
2. jsonを元にslack投稿用のjson作成
3. Slackへjsonをリクエスト
4. Slackに作成したissueを表示

```
pub async fn webhook(mut req: Request, ctx: RouteContext<()>) -> Result<Response> {
    let body: GithubWebhookRequest = req.json().await?;

    let text_lines = Slack.issue_slack_message_text_lines(
        &body.issue,
        &format!("{}/{}#{}", body.repository.owner.login, body.repository.name, body.issue.number),
        &format!("An issue was {}", body.action)
    );

    let slash_command_response = SlashCommandResponse {
        blocks: Slack.construct_gh_issue_slack_message(&body.issue, &text_lines),
        response_type: "in_channel".to_string(),
    };

    let result = Slack
        .send_issue_slack_message(
            &ctx.secret("SLACK_WEBHOOK_URL")?.to_string(),
            &slash_command_response
        )
        .await
        .map_err(|e| worker::Error::RustError(format!("{}", e)));

    match result {
        Ok(_) => Response::ok("OK"),
        Err(e) => Response::error(format!("Unable to handle webhook, message:{:?}", e), 500),
    }
}
```

### Slack表示

![20240710214836.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185639/e328054e-cec9-3dc2-92b1-bc5f10bbdb23.png)


# PullRequest編

次はGithubでPullRequestを作成したら、Slackへ通知するように先程のissue通知を改修してみました。（slashコマンドはなし）
スッキリ書くなら、PullRequest用でわけたほうがよかったかもしれませんが、issueを改修してます。

理由としては以下になります。

- githubからのissueとPullRequestのリクエスト内容はほぼ同じ（各イベントによって、特有の項目はある）
- slackに表示したい内容はリンクのURLが異なるぐらいのため

## 改修

トレイト境界を使って、issueとPullRequestのどちらもいけるようにしました。
※ジェネリクス関数でも可能だったかも。ここらへんはRust力不足。

### 共通関数

githubからのリクエストを受け取る構造体を下記のように定義しました（一部抜粋）

```
#[derive(Clone, Serialize, Deserialize)]
pub struct Issue {
  pub title: String,
  pub body: Option<String>,
}

#[derive(Clone, Serialize, Deserialize)]
pub struct PullRequest {
  pub title: String,
  pub body: Option<String>,
}
```

トレイトに共通の関数を定義し、先程の構造体に実装します

```
pub trait PayloadRepository {
    fn title(&self) -> &str;
    fn body(&self) -> Option<&str>;
}

impl PayloadRepository for Issue {
    fn title(&self) -> &str {
        &self.title
    }

    fn body(&self) -> Option<&str> {
        self.body.as_deref()
    }
}

impl PayloadRepository for PullRequest {
    fn title(&self) -> &str {
        &self.title
    }

    fn body(&self) -> Option<&str> {
        self.body.as_deref()
    }
}
```

最後にトレイト境界で、共通関数に型パラメータの制約を追加し、issue型 or PullRequest型でも同じ関数を使って、フィールドの値を取得できるようにしました。

```
pub fn create_message<T: PayloadRepository>(&self, payload: &T, body: &GithubWebhookRequest) -> Message {
    let text_lines = self.text_lines(
        payload,
        &format!("{}/{}#{}", body.repository.owner.login, body.repository.name, payload.number()),
        &format!("An {} was {}", body.label(), body.action)
    );
}
```

### Slack表示

webhookからのリクエストがissue or Pull requestのどちらでも、slackへ通知されるようになりました。
Pull requestの場合、reviewsの人をメンションさせたいのですが、個人開発のため、reviews指定できる人おらず、この実装はしてません:cry:

![20240725234511.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185639/6f1bd352-fe52-d415-9932-78a1b831a9f1.png)

# バッチ編

最後にgithub apiからの取得結果を元に、mrkdwn形式のSlackメッセージにフォーマットして、通知するバッチになります。ざっくりいうと、reqwestクレートを使って、github apiから情報を取得し、slackへpostする感じになってます。

1. github apiから全リポジトリ取得
2. リポジトリ一覧をループし、各リポジトリのOpenになってるPullRequestを取得
3. PullRequestのreviewersとreviewを取得
4. PullRequestをapprovalにしてないreviewersのmember取得
5. Slack通知メッセージ形式にフォーマット
6. Slackへ通知

## github api

以下は、github apiへ取得する一部コードです。こんな感じで取得できます。
気をつける点は、headersでUSER_AGENTが必要なことです。これがないとリクエストが失敗する。（仮で適当な値を設定してます。）
参考にしたgithub apiの各サンプルのcurlコマンドにUSER_AGENTはないので、解決するのに時間がかかりました。

```
pub fn new() -> Self {
    let token = env::var("GITHUB_TOKEN").unwrap();

    let mut headers = HeaderMap::new();
    headers.insert(USER_AGENT, HeaderValue::from_static("request"));
    headers.insert(ACCEPT, HeaderValue::from_static("application/vnd.github.v3+json"));
    headers.insert(AUTHORIZATION, HeaderValue::from_str(&format!("token {}", token)).unwrap());
    headers.insert("X-GitHub-Api-Version", HeaderValue::from_static("2022-11-28"));

    let client = reqwest::Client::new();

    Self { client, headers }
}

pub async fn fetch<T: for<'de> Deserialize<'de>>(&self, url: &str) -> Result<Vec<T>> {
    let response = self.client
        .get(url)
        .headers(self.headers.clone())
        .send()
        .await?;

    match response.error_for_status_ref() {
        Ok(_) => {
            let repos = response.json::<Vec<T>>().await?;
            Ok(repos)
        },
        Err(e) => {
            Err(e.into())
        }
    }
}
```

## Slack表示

![20240722001549.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185639/fd67b243-09bb-c6c1-f708-f541cd8ef935.png)


# Github

実際のコードはこちらになります。

## issue && PullRequest

https://github.com/reo0306/rust-slack-bot

## バッチ

https://github.com/reo0306/pr-notice-slack-bot

# まとめ & 所感

- 各連携は簡単
- すべて無料でできる
- Rustの勉強になった（ジェネリクス、トレイト境界について、理解が進んだ気がする）

まだまだRustの理解や力不足感は否めませんが、これからもRustのコードをたくさん書いて、もっとレベルアップしたいなと思います。