feat: add bot
All checks were successful
continuous-integration/drone/push Build is passing

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
Kasper Juul Hermansen 2024-04-13 01:17:09 +02:00
parent 7f73220753
commit fdda383f5a
Signed by: kjuulh
GPG Key ID: 9AA7BC13CE474394
5 changed files with 191 additions and 30 deletions

View File

@ -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 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> { pub async fn serve_axum(state: &SharedState, host: &SocketAddr) -> Result<(), anyhow::Error> {
tracing::info!("running webhook server"); tracing::info!("running webhook server");
let app = Router::new() let app = Router::new()
.route("/", get(root)) .route("/", get(root))
.with_state(state.clone()) .route("/webhooks/gitea", post(gitea_webhook))
.with_state(state.to_owned())
.layer( .layer(
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
// Log the matched route's path (with placeholders not filled in). // 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 { async fn root() -> &'static str {
"Hello, contractor!" "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<SharedState>,
Json(json): Json<GiteaWebhook>,
) -> Result<impl IntoResponse, ApiError> {
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<GiteaWebhook> for BotRequest {
type Error = anyhow::Error;
fn try_from(value: GiteaWebhook) -> Result<Self, Self::Error> {
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,
})
}
}
}
}

View File

@ -7,8 +7,6 @@ pub async fn serve_cron_jobs(state: &SharedState) -> Result<(), anyhow::Error> {
loop { loop {
tracing::info!("running cronjobs"); tracing::info!("running cronjobs");
todo!();
tokio::time::sleep(std::time::Duration::from_secs(10_000)).await; tokio::time::sleep(std::time::Duration::from_secs(10_000)).await;
} }
Ok::<(), anyhow::Error>(()) Ok::<(), anyhow::Error>(())

View File

@ -1,2 +1,3 @@
pub mod bot;
pub mod gitea; pub mod gitea;
pub mod reconciler; pub mod reconciler;

View File

@ -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<BotCommands>,
}
#[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<BotRequest>) -> 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 {}

View File

@ -60,6 +60,8 @@ pub struct GiteaRepository {
pub struct DefaultGiteaClient { pub struct DefaultGiteaClient {
url: String, url: String,
token: String, token: String,
webhook_url: String,
} }
impl Default for DefaultGiteaClient { impl Default for DefaultGiteaClient {
@ -72,6 +74,10 @@ impl Default for DefaultGiteaClient {
token: std::env::var("GITEA_TOKEN") token: std::env::var("GITEA_TOKEN")
.context("GITEA_TOKEN should be set") .context("GITEA_TOKEN should be set")
.unwrap(), .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 self.url, &repo.owner, &repo.name
); );
let val = CreateGiteaWebhook { let val = self.create_webhook();
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,
};
tracing::trace!( tracing::trace!(
"calling url: {} with body {}", "calling url: {} with body {}",
@ -325,6 +321,20 @@ impl DefaultGiteaClient {
Ok(()) 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<()> { async fn update_webhook(&self, repo: &Repository, webhook: GiteaWebhook) -> anyhow::Result<()> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
@ -333,17 +343,7 @@ impl DefaultGiteaClient {
self.url, &repo.owner, &repo.name, &webhook.id, self.url, &repo.owner, &repo.name, &webhook.id,
); );
let val = CreateGiteaWebhook { let val = self.create_webhook();
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,
};
tracing::trace!( tracing::trace!(
"calling url: {} with body {}", "calling url: {} with body {}",
@ -464,7 +464,6 @@ pub mod traits;
use anyhow::Context; use anyhow::Context;
pub use extensions::*; pub use extensions::*;
use futures::{stream::FuturesUnordered, StreamExt, TryStreamExt}; use futures::{stream::FuturesUnordered, TryStreamExt};
use itertools::Itertools;
use reqwest::{StatusCode, Url}; use reqwest::{StatusCode, Url};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};