はじめに
この記事は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で作ることにしました。
issue編
チュートリアルはnode.jsで作る手順になっているのを、Rustで実装しました。
Slackでslash(/issue)コマンド実行
コードはroutingの処理部分になります。
Slackに表示されるまでの流れは下記で、Cloudflare Workesでリクエストとレスポンスの処理を実施してます。
- Slackでの
/issue owner/repository#issue番号
コマンドを実行 - 受け取ったリクエスト情報(owner/repository#issue番号)を元に、Gihubへissue情報取得リクエスト
- issue情報を元にSlack投稿用のjson作成
- 作成したjsonをレスポンス
- 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(¶ms.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表示
issue作成自動通知〜Slack表示
Githubにwebhookを設定し、issue作成のアクションがあると、Slackに通知されるまでの流れが下記です。
こちらはGithubからリクエストされたものをSlack投稿用jsonへ変換し、通知してます。
- webhookからのリクエストをjsonで取得
- jsonを元にslack投稿用のjson作成
- Slackへjsonをリクエスト
- 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表示
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指定できる人おらず、この実装はしてません
バッチ編
最後にgithub apiからの取得結果を元に、mrkdwn形式のSlackメッセージにフォーマットして、通知するバッチになります。ざっくりいうと、reqwestクレートを使って、github apiから情報を取得し、slackへpostする感じになってます。
- github apiから全リポジトリ取得
- リポジトリ一覧をループし、各リポジトリのOpenになってるPullRequestを取得
- PullRequestのreviewersとreviewを取得
- PullRequestをapprovalにしてないreviewersのmember取得
- Slack通知メッセージ形式にフォーマット
- 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表示
Github
実際のコードはこちらになります。
issue && PullRequest
バッチ
まとめ & 所感
- 各連携は簡単
- すべて無料でできる
- Rustの勉強になった(ジェネリクス、トレイト境界について、理解が進んだ気がする)
まだまだRustの理解や力不足感は否めませんが、これからもRustのコードをたくさん書いて、もっとレベルアップしたいなと思います。