pub use super::webhook::{HookActor, HookPayload, MergeRequestAction, WebhookDelivery, WebhookOperation, WebhookOperationHandler};
use super::{
events,
webhook::{self, WebhookConfig},
DeploymentConfig, EventDetails, EventsResponse, Options, Package, PackageLifecycle, PackageOptions, WebhookOptions,
};
#[cfg(feature = "analysis")]
use crate::analyzer::host::context::AnalysisContext;
use crate::{
io::{
api::{
self,
json_rpc::{InvocationContext, InvocationOrigin, MethodName, OperationDefinition, OperationEffects, OperationRegistry},
powerautomate::{FormSubmission, PowerAutomateCallbackResolver, PowerAutomateConfig, PowerAutomateProvider},
webhooks::{
store::{CallbackWorker, OperationQueue, OperationWorker},
ArtifactEvent, WebhookRuntime, WebhookRuntimeState,
},
Configuration,
},
http::ReqwestHttpService,
ApiResult,
},
param,
util::constants::app::WEBHOOK_STALE_CLAIM_AFTER,
};
use acorn_host::terminal::Label;
use alloc::sync::Arc;
use axum::{
extract::State as ServerState,
http::StatusCode,
routing::{get, post},
Extension, Json, Router,
};
use bon::Builder;
use color_eyre::eyre::{eyre, WrapErr};
use core::{fmt, net::SocketAddr, time::Duration};
use serde::Serialize;
use serde_json::Value;
#[cfg(test)]
use std::env::temp_dir;
use std::sync::Mutex;
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,
pub(super) deployment: DeploymentConfig,
poll_interval: Duration,
shutdown_grace: Duration,
handler: MergeRequestNoteHandler,
polling_enabled: bool,
webhook_token: Option<api::Secret>,
webhook_signing_token: Option<api::Secret>,
project_id: Option<u64>,
pub(super) operation_queue: OperationQueue,
pub(super) operation_handler: Option<WebhookOperationHandler>,
pub(super) powerautomate: Option<PowerAutomateProvider>,
#[cfg(feature = "analysis")]
pub(super) analysis_options: AnalysisContext,
}
#[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(Clone)]
struct RouterState {
bot: SharedState,
webhook: WebhookRuntime,
}
#[derive(Builder, Clone)]
#[builder(builder_type(vis = ""), start_fn(name = init, vis = ""))]
pub struct Server {
config: Config,
runtime: WebhookRuntime,
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>,
pub webhook: WebhookRuntimeState,
}
impl Config {
pub fn new(options: Options, address: SocketAddr) -> Self {
Self {
address,
options,
..DeploymentConfig::default().into()
}
}
pub fn with_address(self, address: SocketAddr) -> Self {
Self { address, ..self }
}
fn registry(&self) -> ApiResult<OperationRegistry> {
let effects = OperationEffects {
filesystem_read: true,
filesystem_write: true,
mutation: true,
network_read: true,
..OperationEffects::default()
};
let registry = [
MethodName::from(["webhooks", "gitlab", "merge-request"]),
MethodName::from(["webhooks", "gitlab", "note"]),
]
.into_iter()
.try_fold(OperationRegistry::default(), |registry, method| {
OperationDefinition::new::<Value, Value>(method, effects).and_then(|definition| {
let config = self.clone();
registry.register(definition, move |params: Value, _| {
let config = config.clone();
async move {
match serde_json::from_value::<WebhookDelivery>(params) {
| Ok(delivery) => delivery.process(&config).await.map(|()| Value::Null),
| Err(why) => Err(eyre!("Failed to decode normalized GitLab webhook operation — {why}")),
}
}
})
})
});
match (registry, self.powerautomate.clone()) {
| (Ok(registry), Some(provider)) => {
let definition = OperationDefinition::new::<Value, Value>(MethodName::from(["webhooks", "powerautomate", "form"]), effects);
match definition {
| Ok(definition) => {
let options = self.options.clone().with_identifier(provider.config().gitlab_project.clone());
let queue = self.operation_queue.clone();
registry.register(definition, move |params: Value, _| {
let options = options.clone();
let provider = provider.clone();
let queue = queue.clone();
async move {
match serde_json::from_value::<FormSubmission>(params) {
| Ok(submission) => {
let latest = match Package::new(
options.clone(),
PackageOptions::new(&provider.config().package, &submission.project_id),
) {
| Ok(package) => package.download().await,
| Err(why) => Err(why),
};
let prepared = match latest {
| Ok(latest) => {
let cutoff = latest.as_ref().map(|manifest| manifest.through.as_str());
let project = provider.config().project(&submission.project_id).cloned();
let entry = submission.entry_after(provider.config(), cutoff);
let branch = submission.intake_branch();
match (project, entry, branch) {
| (Ok(project), Ok(entry), Ok(branch)) => Ok((project, entry, branch)),
| (Err(why), _, _) | (_, Err(why), _) | (_, _, Err(why)) => Err(why),
}
}
| Err(why) => Err(why),
};
match prepared {
| Ok((project, entry, branch)) => options
.clone()
.with_branch(branch)
.with_workflow("logbook-intake")
.publish_logbook_entry(entry, &queue, &project.path)
.await
.and_then(|result| {
let merge_request_url = result.publication.merge_request.web_url.clone();
let created = ArtifactEvent::MergeRequestCreated {
merge_request_url: merge_request_url.clone(),
project_id: submission.project_id.clone(),
}
.enqueue(
&queue,
&provider.config().callback_destination,
&submission.submission_id,
);
let review = match result.candidates.0.is_empty() {
| true => Ok(false),
| false => ArtifactEvent::ReviewNeeded {
merge_request_url,
project_id: submission.project_id.clone(),
}
.enqueue(
&queue,
&provider.config().callback_destination,
&format!("{}:review", submission.submission_id),
),
};
created.and(review).map(|_| Value::Null)
}),
| Err(why) => Err(why),
}
}
| Err(why) => Err(eyre!("Failed to decode normalized PowerAutomate form operation — {why}")),
}
}
})
}
| Err(why) => Err(why),
}
}
| (Ok(registry), None) => Ok(registry),
| (Err(why), _) => Err(why),
}
}
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_shutdown_grace(self, shutdown_grace: Duration) -> Self {
Self { shutdown_grace, ..self }
}
pub fn with_handler(self, handler: MergeRequestNoteHandler) -> Self {
Self { handler, ..self }
}
pub fn with_deployment(self, deployment: DeploymentConfig) -> ApiResult<Self> {
deployment.validate().map(|()| Self { deployment, ..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(api::Secret::from(token.into())),
..self
}
}
pub fn with_webhook_signing_token(self, token: impl Into<String>) -> Self {
Self {
webhook_signing_token: Some(api::Secret::from(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
}
}
pub fn with_powerautomate(self, config: PowerAutomateConfig) -> ApiResult<Self> {
PowerAutomateProvider::from_config(config).map(|powerautomate| Self {
powerautomate: Some(powerautomate),
..self
})
}
#[cfg(feature = "analysis")]
pub fn with_analysis_options(self, analysis_options: AnalysisContext) -> Self {
Self { analysis_options, ..self }
}
pub fn with_options(self, options: Options) -> Self {
Self { 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 From<DeploymentConfig> for Config {
fn from(deployment: DeploymentConfig) -> Self {
Self {
address: SocketAddr::from(([127, 0, 0, 1], 3000)),
after: String::new(),
options: Options::default(),
deployment,
poll_interval: Duration::from_secs(30),
shutdown_grace: 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: default_operation_queue(),
operation_handler: None,
powerautomate: None,
#[cfg(feature = "analysis")]
analysis_options: AnalysisContext::default(),
}
}
}
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 async fn drain(&self) -> ApiResult<WebhookRuntimeState> {
self.runtime.drain(self.config.shutdown_grace).await
}
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)
.runtime(WebhookRuntime::default())
.state(Arc::new(Mutex::new(state)))
.build()
}
pub fn router(&self) -> Router {
let Server { config, runtime, state } = self;
if let Err(why) = runtime.initialize(&config.operation_queue, WEBHOOK_STALE_CLAIM_AFTER) {
error!("Webhook runtime initialization failed: {why}");
}
let webhook_config = Arc::new(
WebhookConfig::init()
.maybe_webhook_token(config.webhook_token.clone())
.maybe_webhook_signing_token(config.webhook_signing_token.clone())
.maybe_project_id(config.project_id)
.operation_queue(config.operation_queue.clone())
.runtime(runtime.clone())
.build(),
);
let router = Router::new()
.route("/health", get(Self::health))
.route("/ready", get(Self::ready))
.route("/state", get(Self::state_handler))
.route("/webhooks/gitlab", post(webhook::receive))
.with_state(RouterState {
bot: Arc::clone(state),
webhook: runtime.clone(),
})
.layer(Extension(webhook_config));
match &config.powerautomate {
| Some(provider) => router.merge(provider.clone().router(config.operation_queue.clone(), runtime.clone())),
| None => router,
}
}
pub async fn run(self) -> ApiResult<()> {
let Server { config, runtime, 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).runtime(runtime).state(state).build());
if let Err(why) = server.runtime.initialize(&server.config.operation_queue, WEBHOOK_STALE_CLAIM_AFTER) {
error!("Webhook runtime initialization failed: {why}");
}
let mut tasks = Vec::new();
if server.config.polling_enabled {
let poller = Arc::clone(&server);
tasks.push(tokio::spawn(async move { poller.poll_forever().await }));
}
let worker = Arc::clone(&server);
tasks.push(tokio::spawn(async move { worker.work_forever().await }));
info!("GitLab bot server listening on {}", server.config);
let shutdown = Arc::clone(&server);
let result = axum::serve(listener, server.router())
.with_graceful_shutdown(async move {
let _ = tokio::signal::ctrl_c().await;
let _ = shutdown.runtime.drain(shutdown.config.shutdown_grace).await;
})
.await
.wrap_err("GitLab bot server failed");
tasks.into_iter().for_each(|task| task.abort());
result
}
| 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 { runtime, state, .. } = self;
state
.lock()
.map_err(|why| eyre!("GitLab bot state lock is poisoned: {why}"))
.and_then(|state| runtime.state().map(|webhook| state.snapshot(webhook)))
}
async fn poll_forever(&self) {
let Server { config, .. } = &self;
let mut interval = tokio::time::interval(config.poll_interval);
while self.runtime.is_accepting() {
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));
while self.runtime.is_accepting() {
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());
}
}
if let Err(why) = self.process_callback_next().await {
error!("=> {} PowerAutomate callback worker — {why}", Label::fail());
if let Err(lock_error) = self.record_error(why.to_string()) {
error!("=> {} Failed to record callback worker error — {lock_error}", Label::fail());
}
}
}
}
async fn process_callback_next(&self) -> ApiResult<bool> {
let Server { config, .. } = self;
match &config.powerautomate {
| Some(provider) => {
CallbackWorker::new(
config.operation_queue.clone(),
ReqwestHttpService::default(),
PowerAutomateCallbackResolver::new(provider.config().clone()),
WEBHOOK_STALE_CLAIM_AFTER,
)
.with_runtime(self.runtime.clone())
.run_once()
.await
}
| None => Ok(false),
}
}
pub async fn process_next(&self) -> ApiResult<bool> {
let Server { config, .. } = self;
let context = InvocationContext {
allow_mutation: true,
origin: InvocationOrigin::Webhook,
principal: crate::io::api::json_rpc::Principal {
identifier: "gitlab-webhook".to_string(),
},
..InvocationContext::default()
};
match config.registry() {
| Ok(registry) => {
OperationWorker::new(config.operation_queue.clone(), registry, context, WEBHOOK_STALE_CLAIM_AFTER)
.with_runtime(self.runtime.clone())
.run_once()
.await
}
| 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 ready(ServerState(state): ServerState<RouterState>) -> StatusCode {
match state.webhook.is_ready() {
| true => StatusCode::OK,
| false => StatusCode::SERVICE_UNAVAILABLE,
}
}
async fn state_handler(ServerState(state): ServerState<RouterState>) -> Result<Json<StateSnapshot>, (StatusCode, String)> {
state
.bot
.lock()
.map_err(|why| (StatusCode::INTERNAL_SERVER_ERROR, format!("GitLab bot state lock is poisoned — {why}")))
.and_then(|bot| {
state
.webhook
.state()
.map(|webhook| Json(bot.snapshot(webhook)))
.map_err(|why| (StatusCode::INTERNAL_SERVER_ERROR, why.to_string()))
})
}
}
impl State {
fn snapshot(&self, webhook: WebhookRuntimeState) -> 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(),
webhook,
}
}
}
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(())
}
#[cfg(not(test))]
fn default_operation_queue() -> OperationQueue {
OperationQueue::configured()
}
#[cfg(test)]
fn default_operation_queue() -> OperationQueue {
OperationQueue::from(temp_dir().join(format!("acorn-gitlab-bot-{}.db", nanoid::nanoid!())))
}