From e3fffe1814434cb266a3a14bc950cff089b97dee Mon Sep 17 00:00:00 2001 From: "Brian J. Tarricone" Date: Sun, 17 Sep 2023 00:56:02 -0700 Subject: [PATCH] Add configurable event publishing --- Cargo.lock | 12 ++ Cargo.toml | 2 + README.md | 19 +-- sample-config.yaml | 48 ++++++- src/config.rs | 25 ++++ src/event.rs | 316 ++++++++++++++++++++++++++++++++++----------- src/main.rs | 10 +- 7 files changed, 339 insertions(+), 93 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 692a092..4d7e4f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,8 +160,10 @@ dependencies = [ "http", "log", "matrix-sdk", + "regex", "serde", "serde_json", + "serde_regex", "serde_yaml", "tokio", "warp", @@ -1453,6 +1455,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_regex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" +dependencies = [ + "regex", + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" diff --git a/Cargo.toml b/Cargo.toml index 15bb534..5181278 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,10 @@ env_logger = "0.10" http = "0.2" log = { version = "0.4", features = ["std"] } matrix-sdk = { version = "0.6", features = ["anyhow", "markdown", "rustls-tls"], default-features = false } +regex = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_regex = "1" serde_yaml = "0.9" tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "macros"] } warp = "0.3" diff --git a/README.md b/README.md index 0fee872..cef4cd2 100644 --- a/README.md +++ b/README.md @@ -23,23 +23,8 @@ running `cargo install bebot`. ## Setup Bebot requires a configuration file in YAML format. See -`sample-config.yaml` for all existing configuration options. They -should hopefully be fairly self-explanatory, but a few clarifications: - -1. `bind_address` and `bind_port` determine what IP/interface and port - the webhook handler will listen on. -2. Bebot's webhook will be served from `/hooks/gitlab`. You can use - `url_prefix` to prepend further path segments in front of that. -3. `user_id` and `password` are for the Matrix user that the bot will - sign into Matrix as. Ensure that `user_id` is the full username, - including the homeserver. -4. `default_room` is the room that Bebot will publish to if not - specified in the per-repo configuration. -5. `repo_configs` is a map where the key is of the form - `$GITLAB_INSTANCE/$NAMESPACE/$REPO`. It is recommended that you use - a unique, randomly-generated `token` for each repository. You can - specify `room` here as well if you don't want messages to go to - `default_room`. +`sample-config.yaml` for all existing configuration options, as well as +documentation on what each option does. When setting up the webhook in Gitlab, use the same `token` from the configuration file in the webhook's "Secret token" field. You should diff --git a/sample-config.yaml b/sample-config.yaml index 3fd9a74..ee2ef20 100644 --- a/sample-config.yaml +++ b/sample-config.yaml @@ -1,12 +1,58 @@ +# Address/interface the webhook listener should bind to (default is 127.0.0.1). bind_address: 127.0.0.1 +# Port the webhook listener should bind to (default is 3000). bind_port: 3000 +# Optional prefix to serve the webhook path under (default is empty string). url_prefix: "/bebot" +# Matrix user to sign in as. user_id: "@mybebot:example.com" +# Password for Matrix user. password: "secret-matrix-account-password" +# Default Matrix room to publish events to. default_room: "#my-project-commits:example.com" +# Default set of events to publish. If left out, all events will be published. +default_publish_events: + - name: push + # Regexes of branch names. Leave out entirely for "all branches". + branches: + - '^main$' + - '^xfce-.+' + - name: tag_push + - name: issues + # See the Gitlab docs for a full list of actions. If left out, all actions + # will be published. + actions: + - open + - close + - name: merge_request + # See the Gitlab docs for a full list of actions. If left out, all actions + # will be published. + actions: + - open + - merge + - name: pipeline + # See the Gitlab docs for a full list of statuses. If left out, all + # actions will be published. + statuses: + - failed +# Key-value configuration for repositories. repo_configs: + # Keys are the instance name / namespace / repository name "gitlab.example.com/myorg/my-cool-app": + # Each repository should use a unique, randomly-generated token. Enter + # this token in the webhook configuration's "Secret token" on Gitlab. token: "abcdefg12345" + # You can override the default_room above. Leave out to use the default. + room: "#my-cool-app-events:example.com" + # You can override default_events above. Leave out this section to + # use the defaults. + publish_events: + - name: push + branches: + - main + - name: pipeline + statuses: + - failed "gitlab.example.com/myuser/some-other-less-cool-app": - room: "#my-other-room:example.com" token: "kljaslkdjaklsdjalksd" + # This repo uses the default events and room. diff --git a/src/config.rs b/src/config.rs index 4069092..0a856e3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,14 +18,38 @@ use std::{collections::HashMap, fmt, fs::File, io::BufReader}; use anyhow::Context; use matrix_sdk::ruma::{OwnedRoomOrAliasId, OwnedUserId, RoomOrAliasId, UserId}; +use regex::Regex; use serde::de; +use crate::event::{IssueAction, MergeRequestAction, PipelineStatus}; + +#[derive(Deserialize)] +#[serde(tag = "name", rename_all = "snake_case")] +pub enum PublishEvent { + Push { + #[serde(default)] + #[serde(with = "serde_regex")] + branches: Option>, + }, + TagPush, + Issues { + actions: Option>, + }, + MergeRequest { + actions: Option>, + }, + Pipeline { + statuses: Option>, + }, +} + #[derive(Deserialize)] pub struct RepoConfig { pub token: String, #[serde(default)] #[serde(deserialize_with = "deser_optional_room_or_alias_id")] pub room: Option, + pub publish_events: Option>, } #[derive(Deserialize)] @@ -39,6 +63,7 @@ pub struct Config { #[serde(default)] #[serde(deserialize_with = "deser_optional_room_or_alias_id")] pub default_room: Option, + pub default_publish_events: Option>, pub repo_configs: HashMap, // key is repo url without scheme; e.g. // gitlab.xfce.org/xfce/xfdesktop } diff --git a/src/event.rs b/src/event.rs index 3f8b38e..377a286 100644 --- a/src/event.rs +++ b/src/event.rs @@ -14,7 +14,12 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use core::fmt; + +use crate::config::PublishEvent; + pub trait GitlabEventExt { + fn should_publish(&self, publish_events: &Vec) -> bool; fn project(&self) -> &Project; fn r#ref(&self) -> Option<&str>; fn user(&self) -> &str; @@ -49,37 +54,39 @@ pub struct Commit { } #[derive(PartialEq, Debug, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum MergeRequestAction { - #[serde(rename = "open")] - Opened, - #[serde(rename = "close")] - Closed, - #[serde(rename = "reopen")] - Reopened, - #[serde(rename = "update")] - Updated, - #[serde(rename = "approved")] - Approved, - #[serde(rename = "unapproved")] - Unapproved, - #[serde(rename = "merge")] + Open, + Close, + Reopen, + Update, + Approve, + Unapprove, Merged, + Approval, + Unapproval, #[serde(other)] Other, } -impl MergeRequestAction { - pub fn as_str(&self) -> &str { - match self { - MergeRequestAction::Opened => "opened", - MergeRequestAction::Closed => "closed", - MergeRequestAction::Reopened => "reopened", - MergeRequestAction::Updated => "updated", - MergeRequestAction::Approved => "approved", - MergeRequestAction::Unapproved => "unapproved", - MergeRequestAction::Merged => "merged", - MergeRequestAction::Other => "other", - } +impl fmt::Display for MergeRequestAction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + MergeRequestAction::Open => "opened", + MergeRequestAction::Close => "closed", + MergeRequestAction::Reopen => "reopened", + MergeRequestAction::Update => "updated", + MergeRequestAction::Approve => "approved", + MergeRequestAction::Unapprove => "unapproved", + MergeRequestAction::Approval => "approval", + MergeRequestAction::Unapproval => "unapproval", + MergeRequestAction::Merged => "merged", + MergeRequestAction::Other => "other", + } + ) } } @@ -96,19 +103,43 @@ pub struct MergeRequestObjectAttributes { } #[derive(PartialEq, Debug, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum PipelineStatus { - #[serde(rename = "failed")] + Created, + WaitingForResource, + Preparing, + Pending, + Running, + Success, Failed, + Canceled, + Skipped, + Manual, + Scheduled, #[serde(other)] Other, } -impl PipelineStatus { - pub fn as_str(&self) -> &str { - match self { - PipelineStatus::Failed => "failed", - PipelineStatus::Other => "other", - } +impl fmt::Display for PipelineStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + PipelineStatus::Created => "created", + PipelineStatus::WaitingForResource => "waiting for resource", + PipelineStatus::Preparing => "preparing", + PipelineStatus::Pending => "pending", + PipelineStatus::Running => "running", + PipelineStatus::Success => "succeeded", + PipelineStatus::Failed => "failed", + PipelineStatus::Canceled => "canceled", + PipelineStatus::Skipped => "skipped", + PipelineStatus::Manual => "manual", + PipelineStatus::Scheduled => "scheduled", + PipelineStatus::Other => "other", + } + ) } } @@ -126,37 +157,38 @@ pub struct PipelineMergeRequest { } #[derive(PartialEq, Debug, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum IssueAction { - #[serde(rename = "open")] - Opened, - #[serde(rename = "close")] - Closed, - #[serde(rename = "reopen")] - Reopened, - #[serde(rename = "update")] - Updated, + Open, + Close, + Reopen, + Update, #[serde(other)] Other, } -impl IssueAction { - pub fn as_str(&self) -> &str { - match self { - IssueAction::Opened => "opened", - IssueAction::Closed => "closed", - IssueAction::Reopened => "reopened", - IssueAction::Updated => "updated", - IssueAction::Other => "other", - } +impl fmt::Display for IssueAction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + IssueAction::Open => "opened", + IssueAction::Close => "closed", + IssueAction::Reopen => "reopened", + IssueAction::Update => "updated", + IssueAction::Other => "other", + } + ) } } #[derive(Debug, Deserialize)] pub struct IssueObjectAttributes { - id: u32, - title: String, - action: IssueAction, - url: String, + pub id: u32, + pub title: String, + pub action: IssueAction, + pub url: String, } #[derive(Debug, Deserialize)] @@ -222,7 +254,74 @@ pub enum GitlabEvent { Other, } +macro_rules! find_publish_event { + ($cfgs:expr, $case:pat) => { + $cfgs.iter().find(|cfg| match cfg { + $case => true, + _ => false, + }) + }; +} + impl GitlabEventExt for GitlabEvent { + fn should_publish(&self, publish_events: &Vec) -> bool { + match self { + GitlabEvent::Push { r#ref, .. } => { + if let Some(PublishEvent::Push { branches }) = + find_publish_event!(publish_events, PublishEvent::Push { .. }) + { + match branches { + None => true, + Some(branches) => { + let refname = parse_ref(r#ref); + branches.iter().find(|branch| branch.find(&refname).is_some()).is_some() + } + } + } else { + false + } + } + GitlabEvent::TagPush { .. } => find_publish_event!(publish_events, PublishEvent::TagPush { .. }).is_some(), + GitlabEvent::Issue { object_attributes, .. } => { + if let Some(PublishEvent::Issues { actions }) = + find_publish_event!(publish_events, PublishEvent::Issues { .. }) + { + match actions { + None => true, + Some(actions) => actions.contains(&object_attributes.action), + } + } else { + false + } + } + GitlabEvent::MergeRequest { object_attributes, .. } => { + if let Some(PublishEvent::MergeRequest { actions }) = + find_publish_event!(publish_events, PublishEvent::MergeRequest { .. }) + { + match actions { + None => true, + Some(actions) => actions.contains(&object_attributes.action), + } + } else { + false + } + } + GitlabEvent::Pipeline { object_attributes, .. } => { + if let Some(PublishEvent::Pipeline { statuses }) = + find_publish_event!(publish_events, PublishEvent::Pipeline { .. }) + { + match statuses { + None => true, + Some(statuses) => statuses.contains(&object_attributes.status), + } + } else { + false + } + } + GitlabEvent::Other => false, + } + } + fn project(&self) -> &Project { match self { GitlabEvent::Push { project, .. } => &project, @@ -307,9 +406,7 @@ impl GitlabEventExt for GitlabEvent { if object_attributes.action != IssueAction::Other { let title = format!( "Issue #{} **{}**: {}", - object_attributes.id, - object_attributes.action.as_str(), - object_attributes.title + object_attributes.id, object_attributes.action, object_attributes.title ); vec![markdown_link(&title, &object_attributes.url)] } else { @@ -320,9 +417,7 @@ impl GitlabEventExt for GitlabEvent { if object_attributes.action != MergeRequestAction::Other { let title = format!( "MR !{} **{}**: {}", - object_attributes.iid, - object_attributes.action.as_str(), - object_attributes.title + object_attributes.iid, object_attributes.action, object_attributes.title ); vec![markdown_link(&title, &object_attributes.url)] } else { @@ -341,10 +436,9 @@ impl GitlabEventExt for GitlabEvent { .map(|n| n.clone()) .or(merge_request.as_ref().map(|mr| mr.title.clone())) .iter() - .fold( - format!("Pipeline **{}**", object_attributes.status.as_str()), - |accum, title| format!("{}: {}", accum, title), - ); + .fold(format!("Pipeline **{}**", object_attributes.status), |accum, title| { + format!("{}: {}", accum, title) + }); vec![markdown_link(&title, &object_attributes.url)] } else { vec![] @@ -376,6 +470,7 @@ pub fn parse_ref(r#ref: &str) -> String { #[cfg(test)] mod test { use super::*; + use regex::Regex; use std::{fs::File, io::BufReader}; fn load_test_data(name: &str) -> anyhow::Result { @@ -389,7 +484,7 @@ mod test { pub fn parse_push_event() -> anyhow::Result<()> { let event = load_test_data("push-event")?; - match event { + match &event { GitlabEvent::Push { event_name, before, @@ -412,11 +507,32 @@ mod test { assert_eq!(project.namespace, "Mike"); assert_eq!(repository.name, "Diaspora"); assert_eq!(repository.url, "git@example.com:mike/diaspora.git"); - assert_eq!(total_commits_count, 4); + assert_eq!(*total_commits_count, 4); } _ => panic!("not a push event"), }; + let publish_events = vec![PublishEvent::Push { branches: None }]; + assert!(event.should_publish(&publish_events)); + + let publish_events = vec![PublishEvent::Push { + branches: Some(vec![Regex::new(r"^master$").unwrap()]), + }]; + assert!(event.should_publish(&publish_events)); + + let publish_events = vec![PublishEvent::Push { + branches: Some(vec![Regex::new(r"^mas").unwrap()]), + }]; + assert!(event.should_publish(&publish_events)); + + let publish_events = vec![PublishEvent::Push { + branches: Some(vec![Regex::new(r"^foobar$").unwrap()]), + }]; + assert!(!event.should_publish(&publish_events)); + + let publish_events = vec![PublishEvent::Pipeline { statuses: None }]; + assert!(!event.should_publish(&publish_events)); + Ok(()) } @@ -424,7 +540,7 @@ mod test { pub fn parse_tag_push_event() -> anyhow::Result<()> { let event = load_test_data("tag-push-event")?; - match event { + match &event { GitlabEvent::TagPush { event_name, before, @@ -446,11 +562,17 @@ mod test { assert_eq!(project.name, "Example"); assert_eq!(project.namespace, "Jsmith"); assert_eq!(repository.name, "Example"); - assert_eq!(total_commits_count, 0); + assert_eq!(*total_commits_count, 0); } _ => panic!("not a tag push event"), }; + let publish_events = vec![PublishEvent::TagPush]; + assert!(event.should_publish(&publish_events)); + + let publish_events = vec![PublishEvent::Push { branches: None }]; + assert!(!event.should_publish(&publish_events)); + Ok(()) } @@ -458,7 +580,7 @@ mod test { pub fn parse_issue_event() -> anyhow::Result<()> { let event = load_test_data("issue-event")?; - match event { + match &event { GitlabEvent::Issue { user, object_attributes, @@ -466,11 +588,27 @@ mod test { } => { assert_eq!(user.name, "Administrator"); assert_eq!(object_attributes.id, 301); - assert_eq!(object_attributes.action, IssueAction::Opened); + assert_eq!(object_attributes.action, IssueAction::Open); } _ => panic!("not an issue event"), } + let publish_events = vec![PublishEvent::Issues { actions: None }]; + assert!(event.should_publish(&publish_events)); + + let publish_events = vec![PublishEvent::Issues { + actions: Some(vec![IssueAction::Open]), + }]; + assert!(event.should_publish(&publish_events)); + + let publish_events = vec![PublishEvent::Issues { + actions: Some(vec![IssueAction::Close]), + }]; + assert!(!event.should_publish(&publish_events)); + + let publish_events = vec![PublishEvent::Push { branches: None }]; + assert!(!event.should_publish(&publish_events)); + Ok(()) } @@ -478,19 +616,35 @@ mod test { pub fn parse_merge_request_event() -> anyhow::Result<()> { let event = load_test_data("merge-request-event")?; - match event { + match &event { GitlabEvent::MergeRequest { user, object_attributes, .. } => { assert_eq!(user.name, "Administrator"); - assert_eq!(object_attributes.action, MergeRequestAction::Opened); + assert_eq!(object_attributes.action, MergeRequestAction::Open); assert_eq!(object_attributes.title, "MS-Viewport"); } _ => panic!("not a merge request event"), }; + let publish_events = vec![PublishEvent::MergeRequest { actions: None }]; + assert!(event.should_publish(&publish_events)); + + let publish_events = vec![PublishEvent::MergeRequest { + actions: Some(vec![MergeRequestAction::Open]), + }]; + assert!(event.should_publish(&publish_events)); + + let publish_events = vec![PublishEvent::MergeRequest { + actions: Some(vec![MergeRequestAction::Close]), + }]; + assert!(!event.should_publish(&publish_events)); + + let publish_events = vec![PublishEvent::Push { branches: None }]; + assert!(!event.should_publish(&publish_events)); + Ok(()) } @@ -498,7 +652,7 @@ mod test { pub fn parse_pipeline_event() -> anyhow::Result<()> { let event = load_test_data("pipeline-event")?; - match event { + match &event { GitlabEvent::Pipeline { object_attributes, merge_request, @@ -508,12 +662,28 @@ mod test { assert_eq!(object_attributes.name, Some("Pipeline for branch: master".to_string())); assert_eq!(object_attributes.r#ref, "master"); assert_eq!(object_attributes.status, PipelineStatus::Failed); - assert_eq!(merge_request.unwrap().title, "Test"); + assert_eq!(merge_request.as_ref().unwrap().title, "Test"); assert_eq!(user.name, "Administrator"); } _ => panic!("not a pipeline event"), }; + let publish_events = vec![PublishEvent::Pipeline { statuses: None }]; + assert!(event.should_publish(&publish_events)); + + let publish_events = vec![PublishEvent::Pipeline { + statuses: Some(vec![PipelineStatus::Failed]), + }]; + assert!(event.should_publish(&publish_events)); + + let publish_events = vec![PublishEvent::Pipeline { + statuses: Some(vec![PipelineStatus::Success]), + }]; + assert!(!event.should_publish(&publish_events)); + + let publish_events = vec![PublishEvent::Push { branches: None }]; + assert!(!event.should_publish(&publish_events)); + Ok(()) } } diff --git a/src/main.rs b/src/main.rs index c2f2b9e..a3e19ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -215,8 +215,14 @@ async fn run() -> anyhow::Result<()> { } else { debug!("payload: {:?}", event); if let Some(room) = repo_config.room.as_ref().or(config.default_room.as_ref()) { - if let Err(err) = event_tx.send((event, room.clone())).await { - warn!("Failed to enqueue payload: {}", err); + let publish_events = repo_config + .publish_events + .as_ref() + .or(config.default_publish_events.as_ref()); + if publish_events.map(|ecs| event.should_publish(ecs)).unwrap_or(true) { + if let Err(err) = event_tx.send((event, room.clone())).await { + warn!("Failed to enqueue payload: {}", err); + } } warp::reply::with_status("OK", StatusCode::OK) } else {