use super::database::OperationQueue;
use super::webhook::{self, WebhookConfig};
pub use super::webhook::{HookActor, HookPayload, MergeRequestAction, WebhookDelivery, WebhookOperation, WebhookOperationHandler};
use super::{events, EventDetails, EventsResponse, Options, WebhookOptions};
#[cfg(feature = "analysis")]
use crate::analyzer::CheckOptions;
use crate::io::api::Configuration;
use crate::io::ApiResult;
use crate::param;
use crate::prelude::Mutex;
use crate::util::Label;
use alloc::sync::Arc;
use axum::extract::State as ServerState;
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Extension, Json, Router};
use bon::Builder;
use color_eyre::eyre::{eyre, WrapErr};
use core::{fmt, net::SocketAddr, time::Duration};
use serde::Serialize;
use tokio::net::TcpListener;
use tracing::{error, info};
pub type MergeRequestNoteHandler = Arc<dyn Fn(MergeRequestNoteEvent) -> ApiResult<()> + Send + Sync + 'static>;
type SharedState = Arc<Mutex<State>>;
#[derive(Clone)]
pub struct Config {
address: SocketAddr,
after: String,
pub(super) options: Options,
poll_interval: Duration,
handler: MergeRequestNoteHandler,
polling_enabled: bool,
webhook_token: Option<String>,
webhook_signing_token: Option<String>,
project_id: Option<u64>,
operation_queue: OperationQueue,
pub(super) operation_handler: Option<WebhookOperationHandler>,
#[cfg(feature = "analysis")]
pub(super) analysis_options: CheckOptions,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct MergeRequestNoteEvent {
pub event_id: u64,
pub project_id: u64,
pub merge_request_iid: u64,
pub note_id: u64,
pub created_at: String,
pub body: String,
pub author_username: String,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub struct PollSummary {
pub event_count: usize,
pub processed_count: usize,
pub latest_after: Option<String>,
}
#[derive(Builder, Clone)]
#[builder(builder_type(vis = ""), start_fn(name = init, vis = ""))]
pub struct Server {
config: Config,
state: SharedState,
}
#[derive(Clone, Debug)]
struct State {
after: String,
poll_count: u64,
processed_count: u64,
last_error: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct StateSnapshot {
pub after: String,
pub poll_count: u64,
pub processed_count: u64,
pub last_error: Option<String>,
}
impl Config {
pub fn new(options: Options, address: SocketAddr) -> Self {
Self {
address,
after: String::new(),
options,
poll_interval: Duration::from_secs(30),
handler: Arc::new(default_merge_request_note_handler),
polling_enabled: true,
webhook_token: None,
webhook_signing_token: None,
project_id: None,
operation_queue: OperationQueue::configured(),
operation_handler: None,
#[cfg(feature = "analysis")]
analysis_options: CheckOptions::default(),
}
}
pub fn with_after(self, after: impl Into<String>) -> Self {
Self { after: after.into(), ..self }
}
pub fn with_poll_interval(self, poll_interval: Duration) -> Self {
Self { poll_interval, ..self }
}
pub fn with_handler(self, handler: MergeRequestNoteHandler) -> Self {
Self { handler, ..self }
}
pub fn with_polling_enabled(self, polling_enabled: bool) -> Self {
Self { polling_enabled, ..self }
}
pub fn with_webhook_token(self, token: impl Into<String>) -> Self {
Self {
webhook_token: Some(token.into()),
..self
}
}
pub fn with_webhook_signing_token(self, token: impl Into<String>) -> Self {
Self {
webhook_signing_token: Some(token.into()),
..self
}
}
pub fn with_project_id(self, id: u64) -> Self {
Self {
project_id: Some(id),
..self
}
}
pub fn with_operation_queue(self, operation_queue: OperationQueue) -> Self {
Self { operation_queue, ..self }
}
pub fn with_operation_handler(self, operation_handler: WebhookOperationHandler) -> Self {
Self {
operation_handler: Some(operation_handler),
..self
}
}
#[cfg(feature = "analysis")]
pub fn with_analysis_options(self, analysis_options: CheckOptions) -> Self {
Self { analysis_options, ..self }
}
pub fn with_webhook_options(self, options: &WebhookOptions, project_id: Option<u64>) -> Self {
Self {
webhook_token: options.webhook_token.clone(),
webhook_signing_token: options.signing_token.clone(),
project_id,
..self
}
}
}
impl fmt::Display for Config {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}", self.address)
}
}
impl<'a> TryFrom<&'a EventDetails> for MergeRequestNoteEvent {
type Error = &'static str;
fn try_from(event: &'a EventDetails) -> Result<Self, Self::Error> {
match (
event.note.as_ref(),
event.target_type.is_note(),
event.action_name.is_commented() || event.action_name.is_commented_on(),
) {
| (Some(note), true, true) if note.noteable_type.eq_ignore_ascii_case("MergeRequest") => match note.noteable_iid {
| Some(merge_request_iid) => Ok(Self {
event_id: event.identifier,
project_id: event.project_id,
merge_request_iid,
note_id: note.identifier,
created_at: event.created_at.clone(),
body: note.body.clone(),
author_username: note.author.username.clone(),
}),
| None => Err("Merge request note is missing noteable_iid"),
},
| (None, _, _) => Err("Event has no note"),
| (_, false, _) => Err("Event is not a note event"),
| (_, _, false) => Err("Event action is not a comment"),
| (Some(_), true, true) => Err("Note is not on a merge request"),
}
}
}
impl Server {
pub fn new(config: Config) -> Self {
let state = State {
after: config.after.clone(),
poll_count: 0,
processed_count: 0,
last_error: None,
};
Self::init().config(config).state(Arc::new(Mutex::new(state))).build()
}
pub fn router(&self) -> Router {
let Server { config, state } = self;
let webhook_config = Arc::new(
WebhookConfig::init()
.maybe_webhook_token(config.webhook_token.as_deref())
.maybe_webhook_signing_token(config.webhook_signing_token.as_deref())
.maybe_project_id(config.project_id)
.operation_queue(config.operation_queue.clone())
.build(),
);
Router::new()
.route("/health", get(Self::health))
.route("/state", get(Self::state_handler))
.route("/webhooks/gitlab", post(webhook::receive))
.with_state(Arc::clone(state))
.layer(Extension(webhook_config))
}
pub async fn run(self) -> ApiResult<()> {
let Server { config, state } = self;
match TcpListener::bind(config.address)
.await
.wrap_err_with(|| format!("Failed to bind GitLab bot server to {config}"))
{
| Ok(listener) => {
let server = Arc::new(Self::init().config(config).state(state).build());
if server.config.polling_enabled {
let poller = Arc::clone(&server);
tokio::spawn(async move { poller.poll_forever().await });
}
let worker = Arc::clone(&server);
tokio::spawn(async move { worker.work_forever().await });
info!("GitLab bot server listening on {}", server.config);
axum::serve(listener, server.router()).await.wrap_err("GitLab bot server failed")
}
| Err(why) => Err(why),
}
}
pub async fn poll_once(&self) -> ApiResult<PollSummary> {
let Server { config, .. } = self;
match self.snapshot() {
| Ok(snapshot) => {
let params = if snapshot.after.trim().is_empty() {
vec![param!(KeyValuePair, "target_type", "note")]
} else {
vec![
param!(KeyValuePair, "target_type", "note"),
param!(KeyValuePair, "after", snapshot.after.as_str()),
]
};
let options = config.options.clone().with_params(params);
match events(&options).await {
| Ok(response) => self.process_events(response),
| Err(why) => Err(why),
}
}
| Err(why) => Err(why),
}
}
pub fn snapshot(&self) -> ApiResult<StateSnapshot> {
let Server { state, .. } = self;
state
.lock()
.map(|s| s.snapshot())
.map_err(|why| eyre!("GitLab bot state lock is poisoned: {why}"))
}
async fn poll_forever(&self) {
let Server { config, .. } = &self;
let mut interval = tokio::time::interval(config.poll_interval);
loop {
interval.tick().await;
match self.poll_once().await {
| Ok(summary) => {
println!("=> [WIP] GitLab bot poll summary: {summary:?}")
}
| Err(why) => {
error!("GitLab bot polling failed: {why}");
if let Err(lock_error) = self.record_error(why.to_string()) {
error!("=> {} Failed to record GitLab bot error — {lock_error}", Label::fail());
}
}
}
}
}
async fn work_forever(&self) {
let mut interval = tokio::time::interval(Duration::from_secs(1));
loop {
interval.tick().await;
if let Err(why) = self.process_next().await {
error!("=> {} GitLab bot worker — {why}", Label::fail());
if let Err(lock_error) = self.record_error(why.to_string()) {
error!("=> {} Failed to record GitLab bot worker error — {lock_error}", Label::fail());
}
}
}
}
pub async fn process_next(&self) -> ApiResult<bool> {
let Server { config, .. } = self;
match config.operation_queue.claim_next(chrono::Duration::minutes(5)) {
| Ok(Some(operation)) => {
let result = match serde_json::from_str::<WebhookDelivery>(&operation.event_json) {
| Ok(delivery) => match delivery.process(config).await {
| Ok(()) => config.operation_queue.succeed(&operation.operation_key),
| Err(why) => Err(why),
},
| Err(why) => Err(eyre!("Failed to decode normalized webhook operation — {why}")),
};
result
.map(|_| true)
.or_else(|why| config.operation_queue.fail(&operation, &why.to_string()).and_then(|_| Err(why)))
}
| Ok(None) => Ok(false),
| Err(why) => Err(why),
}
}
pub(crate) fn process_events(&self, response: EventsResponse) -> ApiResult<PollSummary> {
let Server { config, .. } = self;
let latest_after = response.iter().map(|event| event.created_at.as_str()).max().map(str::to_string);
let events: Vec<_> = response.iter().filter_map(|e| MergeRequestNoteEvent::try_from(e).ok()).collect();
let summary = PollSummary {
event_count: response.len(),
processed_count: events.len(),
latest_after,
};
match events.iter().cloned().try_for_each(|event| (config.handler)(event)) {
| Ok(()) => match self.record_success(&summary) {
| Ok(()) => Ok(summary),
| Err(why) => Err(why),
},
| Err(why) => Err(why),
}
}
fn record_success(&self, summary: &PollSummary) -> ApiResult<()> {
let Server { state, .. } = self;
match state.lock() {
| Ok(mut guard) => {
let after = summary.latest_after.clone().unwrap_or_else(|| guard.after.clone());
*guard = State {
after,
poll_count: guard.poll_count.saturating_add(1),
processed_count: guard.processed_count.saturating_add(summary.processed_count as u64),
last_error: None,
};
Ok(())
}
| Err(why) => Err(eyre!("GitLab bot state lock is poisoned: {why}")),
}
}
fn record_error(&self, error: String) -> ApiResult<()> {
let Server { state, .. } = self;
match state.lock() {
| Ok(mut guard) => {
*guard = State {
last_error: Some(error),
..guard.clone()
};
Ok(())
}
| Err(why) => Err(eyre!("GitLab bot state lock is poisoned: {why}")),
}
}
async fn health() -> &'static str {
"ok"
}
async fn state_handler(ServerState(state): ServerState<SharedState>) -> Result<Json<StateSnapshot>, (StatusCode, String)> {
state
.lock()
.map(|s| Json(s.snapshot()))
.map_err(|why| (StatusCode::INTERNAL_SERVER_ERROR, format!("GitLab bot state lock is poisoned — {why}")))
}
}
impl State {
fn snapshot(&self) -> StateSnapshot {
let State {
after,
poll_count,
processed_count,
last_error,
} = self;
StateSnapshot {
after: after.clone(),
poll_count: *poll_count,
processed_count: *processed_count,
last_error: last_error.clone(),
}
}
}
fn default_merge_request_note_handler(event: MergeRequestNoteEvent) -> ApiResult<()> {
info!(
"Processing GitLab merge request note event {} for MR !{}",
event.event_id, event.merge_request_iid
);
Ok(())
}