From fdda383f5a66df272e42306d4260d9c7b76be765 Mon Sep 17 00:00:00 2001 From: kjuulh Date: Sat, 13 Apr 2024 01:17:09 +0200 Subject: [PATCH] feat: add bot Signed-off-by: kjuulh --- crates/contractor/src/api.rs | 106 +++++++++++++++++++++++- crates/contractor/src/schedule.rs | 2 - crates/contractor/src/services.rs | 1 + crates/contractor/src/services/bot.rs | 65 +++++++++++++++ crates/contractor/src/services/gitea.rs | 47 +++++------ 5 files changed, 191 insertions(+), 30 deletions(-) create mode 100644 crates/contractor/src/services/bot.rs diff --git a/crates/contractor/src/api.rs b/crates/contractor/src/api.rs index 192c9fd..91e6d02 100644 --- a/crates/contractor/src/api.rs +++ b/crates/contractor/src/api.rs @@ -1,15 +1,31 @@ -use std::net::SocketAddr; +use std::{net::SocketAddr, sync::Arc}; -use axum::{extract::MatchedPath, http::Request, routing::get, Router}; +use anyhow::Context; +use axum::{ + body::Body, + extract::{MatchedPath, State}, + http::Request, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; use tower_http::trace::TraceLayer; -use crate::SharedState; +use crate::{ + services::{ + bot::{BotRequest, BotState}, + gitea::Repository, + }, + SharedState, +}; pub async fn serve_axum(state: &SharedState, host: &SocketAddr) -> Result<(), anyhow::Error> { tracing::info!("running webhook server"); let app = Router::new() .route("/", get(root)) - .with_state(state.clone()) + .route("/webhooks/gitea", post(gitea_webhook)) + .with_state(state.to_owned()) .layer( TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { // Log the matched route's path (with placeholders not filled in). @@ -38,3 +54,85 @@ pub async fn serve_axum(state: &SharedState, host: &SocketAddr) -> Result<(), an async fn root() -> &'static str { "Hello, contractor!" } + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct GiteaWebhookComment { + body: String, +} +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct GiteaWebhookRepository { + full_name: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(untagged)] +pub enum GiteaWebhook { + Issue { + comment: GiteaWebhookComment, + repository: GiteaWebhookRepository, + }, +} + +pub enum ApiError { + InternalError(anyhow::Error), +} + +impl IntoResponse for ApiError { + fn into_response(self) -> axum::response::Response { + match self { + ApiError::InternalError(e) => { + tracing::error!("failed with internal error: {}", e); + + (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + } + } + .into_response() + } +} + +async fn gitea_webhook( + State(state): State, + Json(json): Json, +) -> Result { + tracing::info!( + "called: {}", + serde_json::to_string(&json) + .context("failed to serialize webhook") + .map_err(ApiError::InternalError)? + ); + + let bot_req: BotRequest = json.try_into().map_err(ApiError::InternalError)?; + + state + .bot() + .handle_request(bot_req) + .await + .map_err(ApiError::InternalError)?; + + Ok("Hello, contractor!") +} + +impl TryFrom for BotRequest { + type Error = anyhow::Error; + fn try_from(value: GiteaWebhook) -> Result { + match value { + GiteaWebhook::Issue { + comment, + repository, + } => { + let (owner, name) = repository.full_name.split_once('/').ok_or(anyhow::anyhow!( + "{} did not contain a valid owner/repository", + &repository.full_name + ))?; + + Ok(BotRequest { + repo: Repository { + owner: owner.into(), + name: name.into(), + }, + command: comment.body, + }) + } + } + } +} diff --git a/crates/contractor/src/schedule.rs b/crates/contractor/src/schedule.rs index 4cdad94..4fdbe15 100644 --- a/crates/contractor/src/schedule.rs +++ b/crates/contractor/src/schedule.rs @@ -7,8 +7,6 @@ pub async fn serve_cron_jobs(state: &SharedState) -> Result<(), anyhow::Error> { loop { tracing::info!("running cronjobs"); - todo!(); - tokio::time::sleep(std::time::Duration::from_secs(10_000)).await; } Ok::<(), anyhow::Error>(()) diff --git a/crates/contractor/src/services.rs b/crates/contractor/src/services.rs index 40a9bcd..6a96e7d 100644 --- a/crates/contractor/src/services.rs +++ b/crates/contractor/src/services.rs @@ -1,2 +1,3 @@ +pub mod bot; pub mod gitea; pub mod reconciler; diff --git a/crates/contractor/src/services/bot.rs b/crates/contractor/src/services/bot.rs new file mode 100644 index 0000000..5c42605 --- /dev/null +++ b/crates/contractor/src/services/bot.rs @@ -0,0 +1,65 @@ +use clap::{Parser, Subcommand}; + +use crate::SharedState; + +use super::gitea::Repository; + +pub struct Bot { + command_name: String, +} + +#[derive(Parser)] +#[command(author, version, about, long_about = None, subcommand_required = true)] +struct BotCommand { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum BotCommands { + Refresh { + #[arg(long)] + all: bool, + }, +} + +impl Bot { + pub fn new() -> Self { + Self { + command_name: std::env::var("CONTRACTOR_COMMAND_NAME").unwrap_or("contractor".into()), + } + } + + pub async fn handle_request(&self, req: impl Into) -> anyhow::Result<()> { + let req: BotRequest = req.into(); + + if !req.command.starts_with(&self.command_name) { + return Ok(()); + } + + let cmd = BotCommand::parse_from(req.command.split_whitespace()); + + match cmd.command { + Some(BotCommands::Refresh { all }) => { + tracing::info!("triggering refresh for: {}, all: {}", req.repo, all); + } + None => { + // TODO: Send back the help menu + } + } + + Ok(()) + } +} + +pub struct BotRequest { + pub repo: Repository, + pub command: String, +} + +pub trait BotState { + fn bot(&self) -> Bot { + Bot::new() + } +} +impl BotState for SharedState {} diff --git a/crates/contractor/src/services/gitea.rs b/crates/contractor/src/services/gitea.rs index b531e94..15a1ea9 100644 --- a/crates/contractor/src/services/gitea.rs +++ b/crates/contractor/src/services/gitea.rs @@ -60,6 +60,8 @@ pub struct GiteaRepository { pub struct DefaultGiteaClient { url: String, token: String, + + webhook_url: String, } impl Default for DefaultGiteaClient { @@ -72,6 +74,10 @@ impl Default for DefaultGiteaClient { token: std::env::var("GITEA_TOKEN") .context("GITEA_TOKEN should be set") .unwrap(), + webhook_url: std::env::var("CONTRACTOR_URL") + .context("CONTRACTOR_URL should be set") + .map(|url| format!("{}/webhooks/gitea", url.trim_end_matches('/'))) + .unwrap(), } } } @@ -287,17 +293,7 @@ impl DefaultGiteaClient { self.url, &repo.owner, &repo.name ); - let val = CreateGiteaWebhook { - active: true, - authorization_header: Some("something".into()), - branch_filter: Some("*".into()), - config: CreateGiteaWebhookConfig { - content_type: "json".into(), - url: "https://url?type=contractor".into(), - }, - events: vec!["pull_request_comment".into(), "issue_comment".into()], - r#type: GiteaWebhookType::Gitea, - }; + let val = self.create_webhook(); tracing::trace!( "calling url: {} with body {}", @@ -325,6 +321,20 @@ impl DefaultGiteaClient { Ok(()) } + fn create_webhook(&self) -> CreateGiteaWebhook { + CreateGiteaWebhook { + active: true, + authorization_header: Some("something".into()), + branch_filter: Some("*".into()), + config: CreateGiteaWebhookConfig { + content_type: "json".into(), + url: format!("{}?type=contractor", self.webhook_url), + }, + events: vec!["pull_request_comment".into(), "issue_comment".into()], + r#type: GiteaWebhookType::Gitea, + } + } + async fn update_webhook(&self, repo: &Repository, webhook: GiteaWebhook) -> anyhow::Result<()> { let client = reqwest::Client::new(); @@ -333,17 +343,7 @@ impl DefaultGiteaClient { self.url, &repo.owner, &repo.name, &webhook.id, ); - let val = CreateGiteaWebhook { - active: true, - authorization_header: Some("something".into()), - branch_filter: Some("*".into()), - config: CreateGiteaWebhookConfig { - content_type: "json".into(), - url: "https://url?type=contractor".into(), - }, - events: vec!["pull_request_comment".into(), "issue_comment".into()], - r#type: GiteaWebhookType::Gitea, - }; + let val = self.create_webhook(); tracing::trace!( "calling url: {} with body {}", @@ -464,7 +464,6 @@ pub mod traits; use anyhow::Context; pub use extensions::*; -use futures::{stream::FuturesUnordered, StreamExt, TryStreamExt}; -use itertools::Itertools; +use futures::{stream::FuturesUnordered, TryStreamExt}; use reqwest::{StatusCode, Url}; use serde::{Deserialize, Serialize};