Add configurable event publishing

This commit is contained in:
Brian Tarricone 2023-09-17 00:56:02 -07:00
parent 4c00f45b1c
commit e3fffe1814
7 changed files with 339 additions and 93 deletions

12
Cargo.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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.

View File

@ -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<Vec<Regex>>,
},
TagPush,
Issues {
actions: Option<Vec<IssueAction>>,
},
MergeRequest {
actions: Option<Vec<MergeRequestAction>>,
},
Pipeline {
statuses: Option<Vec<PipelineStatus>>,
},
}
#[derive(Deserialize)]
pub struct RepoConfig {
pub token: String,
#[serde(default)]
#[serde(deserialize_with = "deser_optional_room_or_alias_id")]
pub room: Option<OwnedRoomOrAliasId>,
pub publish_events: Option<Vec<PublishEvent>>,
}
#[derive(Deserialize)]
@ -39,6 +63,7 @@ pub struct Config {
#[serde(default)]
#[serde(deserialize_with = "deser_optional_room_or_alias_id")]
pub default_room: Option<OwnedRoomOrAliasId>,
pub default_publish_events: Option<Vec<PublishEvent>>,
pub repo_configs: HashMap<String, RepoConfig>, // key is repo url without scheme; e.g.
// gitlab.xfce.org/xfce/xfdesktop
}

View File

@ -14,7 +14,12 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use core::fmt;
use crate::config::PublishEvent;
pub trait GitlabEventExt {
fn should_publish(&self, publish_events: &Vec<PublishEvent>) -> 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<PublishEvent>) -> 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<GitlabEvent> {
@ -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(())
}
}

View File

@ -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 {