Compare commits

..

7 Commits

Author SHA1 Message Date
1163d2a3f3 Add script to add/update webhook on gitlab 2023-09-16 03:16:18 -07:00
fcdb10a7ff Make messages look better and publish multiple for push events
For push events, publish one message per commit pushed, up to a limit of
15.
2023-09-16 02:35:56 -07:00
33d3313927 Process events on a separate thread
GitLab really wants you to respond in 10 seconds, and if we're doing a
bit of work (e.g. sending more than one matrix message per event), then
we can easily blow through that.
2023-09-16 02:17:49 -07:00
eb5bdc3fd5 Better refname parsing 2023-09-16 01:04:11 -07:00
d74c30132c Better tag_push message 2023-09-16 00:51:39 -07:00
11be50d89f Remove some unused fields 2023-09-16 00:50:18 -07:00
1dcba64095 Speed up docker rebuilds
This should keep all the dependency build artifacts in a cached layer
below the layer that builds our app and links the final binary.
2023-09-16 00:06:13 -07:00
4 changed files with 180 additions and 59 deletions

View File

@ -2,7 +2,15 @@ FROM rust:1.72-slim-bullseye AS builder
WORKDIR /bebot-build
COPY . ./
# Build and cache dependencies
COPY Cargo.toml Cargo.lock ./
RUN mkdir -p src && echo 'fn main() {}' > src/main.rs
RUN cargo build --release
# Build and link our app
RUN rm -rf src
COPY src ./src
RUN touch src/main.rs
RUN cargo build --release
FROM debian:bookworm-slim

58
scripts/set-webook.py Executable file
View File

@ -0,0 +1,58 @@
#!/usr/bin/env python3
import re
import os
import requests
import secrets
import sys
def die(s):
print(s, file=sys.stderr)
sys.exit(1)
gitlab_token = os.getenv('GITLAB_TOKEN')
if gitlab_token is None:
die("GITLAB_TOKEN must be set in the environment")
if len(sys.argv) < 4:
die("Usage: {} GITLAB_INSTANCE HOOK_URL_BASE NAMESPACE/REPO\n\nExample: {} gitlab.example.com https://bot.example.com/bebot stuff/myrepo")
gitlab_instance = sys.argv[1]
hook_url = f"{sys.argv[2]}/hooks/gitlab"
repo_name = sys.argv[3]
gl_base = 'https://{}/api/v4/projects/{}/hooks'.format(
gitlab_instance,
re.sub('/', '%2F', repo_name)
)
headers = {
'authorization': 'Bearer {}'.format(gitlab_token),
}
payload = {
"merge_requests_events": True,
"pipeline_events": True,
"push_events": True,
"tag_push_events": True,
}
resp = requests.get(gl_base, headers=headers)
if resp.status_code != 200:
resp.raise_for_status()
existing = resp.json()
for hook in existing:
if hook['url'] == hook_url:
upd_url = '{}/{}'.format(gl_base, hook['id'])
resp = requests.put(upd_url, headers=headers, json=payload)
print("Updated existing hook")
sys.exit(0)
token = secrets.token_urlsafe(32)
payload["url"] = hook_url
payload["token"] = token
resp = requests.post(gl_base, headers=headers, json=payload)
resp.raise_for_status()
print(' "{}/{}":'.format(gitlab_instance, repo_name))
print(' token: "{}"'.format(token))

View File

@ -2,8 +2,7 @@ pub trait GitlabEventExt {
fn project(&self) -> &Project;
fn r#ref(&self) -> &str;
fn user(&self) -> &str;
fn url(&self) -> String;
fn title(&self) -> String;
fn titles(&self) -> Vec<String>;
}
#[derive(Debug, Deserialize)]
@ -14,9 +13,7 @@ pub struct User {
#[derive(Debug, Deserialize)]
pub struct Project {
pub name: String,
pub description: String,
pub web_url: String,
pub avatar_url: Option<String>,
pub namespace: String,
pub path_with_namespace: String,
pub default_branch: String,
@ -26,7 +23,6 @@ pub struct Project {
pub struct Repository {
pub name: String,
pub url: String,
pub description: String,
}
#[derive(Debug, Deserialize)]
@ -73,6 +69,7 @@ impl MergeRequestAction {
#[derive(Debug, Deserialize)]
pub struct MergeRequestObjectAttributes {
pub iid: u64,
pub target_branch: String,
pub source_branch: String,
pub title: String,
@ -161,7 +158,7 @@ pub enum GitlabEvent {
#[serde(rename = "pipeline")]
Pipeline {
object_attributes: PipelineObjectAttributes,
merge_request: PipelineMergeRequest,
merge_request: Option<PipelineMergeRequest>,
user: User,
project: Project,
},
@ -195,54 +192,101 @@ impl GitlabEventExt for GitlabEvent {
}
}
fn url(&self) -> String {
let url = match self {
GitlabEvent::Push { after, project, .. } => format!("{}/-/commits/{}", project.web_url, after),
GitlabEvent::TagPush {
r#ref,
checkout_sha,
project,
..
} => {
let refname = r#ref.split('/').into_iter().last().unwrap_or(checkout_sha);
format!("{}/-/tags/{}", project.web_url, refname)
}
GitlabEvent::MergeRequest { object_attributes, .. } => object_attributes.url.clone(),
GitlabEvent::Pipeline { object_attributes, .. } => object_attributes.url.clone(),
};
url.replace("http://", "https://").to_string()
}
fn title(&self) -> String {
fn titles(&self) -> Vec<String> {
fn find_commit<'a>(commits: &'a Vec<Commit>, sha: &str) -> Option<&'a Commit> {
commits.iter().find(|commit| commit.id == sha)
}
match self {
GitlabEvent::Push { after, commits, .. } => find_commit(commits, &after)
.map(|commit| commit.title.clone())
.unwrap_or_else(|| "New commit(s) pushed".to_string()),
GitlabEvent::Push {
after,
project,
commits,
..
} => {
const MAX_COMMITS: usize = 15; // TODO: make configurable
commits.iter().fold(Vec::new(), |mut titles, commit| {
if titles.len() < MAX_COMMITS {
titles.push(format!("[**pushed** {}]({})", commit.title, commit.url));
if titles.len() == MAX_COMMITS {
titles.push(format!(
"[**pushed** {} more commits]({}/-/compare/{}...{})",
commits.len() - MAX_COMMITS,
project.web_url,
commit.id,
after
));
}
}
titles
})
}
GitlabEvent::TagPush {
checkout_sha, commits, ..
} => find_commit(commits, &checkout_sha)
.map(|commit| commit.title.clone())
.unwrap_or_else(|| "New tag pushed".to_string()),
r#ref,
checkout_sha,
project,
commits,
..
} => {
let title = format!(
"**tagged** {}",
find_commit(commits, &checkout_sha)
.map(|commit| &commit.title)
.unwrap_or(checkout_sha)
);
let url = format!("{}/-/tags/{}", project.web_url, parse_ref(r#ref));
vec![markdown_link(&title, &url)]
}
GitlabEvent::MergeRequest { object_attributes, .. } => {
format!("MR {}: {}", object_attributes.action.as_str(), object_attributes.title)
let title = format!(
"MR !{} **{}**: {}",
object_attributes.iid,
object_attributes.action.as_str(),
object_attributes.title
);
vec![markdown_link(&title, &object_attributes.url)]
}
GitlabEvent::Pipeline {
object_attributes,
merge_request,
..
} => {
let title = object_attributes.name.as_ref().unwrap_or(&merge_request.title);
format!("Pipeline {}: {}", object_attributes.status.as_str(), title)
let title = object_attributes
.name
.as_ref()
.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),
);
vec![markdown_link(&title, &object_attributes.url)]
}
}
}
}
#[inline]
fn markdown_link(title: &String, url: &String) -> String {
format!("[{}]({})", title, url)
}
pub fn parse_ref(r#ref: &str) -> String {
if r#ref.starts_with("refs/") {
let parts = r#ref.split('/').collect::<Vec<_>>();
if parts.len() > 2 {
parts.into_iter().skip(2).collect::<Vec<_>>().join("/").to_string()
} else {
r#ref.to_string()
}
} else {
r#ref.to_string()
}
}
#[cfg(test)]
mod test {
use super::*;

View File

@ -20,6 +20,7 @@ use matrix_sdk::{
ruma::{events::room::message::RoomMessageEventContent, OwnedRoomOrAliasId},
BaseRoom, Client,
};
use tokio::sync::mpsc;
use warp::Filter;
use crate::event::{MergeRequestAction, PipelineStatus};
@ -104,22 +105,22 @@ async fn ensure_matrix_room_joined(matrix_client: &Client, room_id: &OwnedRoomOr
room.ok_or_else(|| anyhow!("Unable to join room {}", room_id))
}
fn build_gitlab_message(event: &GitlabEvent) -> String {
fn build_gitlab_messages(event: &GitlabEvent) -> Vec<String> {
let project = event.project();
let refname = event
.r#ref()
.split('/')
.last()
.unwrap_or_else(|| event.r#ref())
.to_string();
format!(
"*{}* {} **{}** [{}]({})",
project.path_with_namespace,
refname,
event.user(),
event.title(),
event.url()
)
let refname = event::parse_ref(event.r#ref());
event
.titles()
.iter()
.map(|title| {
format!(
"\\[{}\\] `{}` *{}* {}",
project.path_with_namespace,
refname,
event.user(),
title,
)
})
.collect()
}
async fn handle_gitlab_event(
@ -138,10 +139,11 @@ async fn handle_gitlab_event(
}
let room = ensure_matrix_room_joined(matrix_client, room_id).await?;
let msg = build_gitlab_message(&event);
debug!("Sending message to {}: {}", room_id, msg);
let msg_content = RoomMessageEventContent::text_markdown(&msg);
room.send(msg_content, None).await?;
for msg in build_gitlab_messages(&event) {
debug!("Sending message to {}: {}", room_id, msg);
let msg_content = RoomMessageEventContent::text_markdown(&msg);
room.send(msg_content, None).await?;
}
Ok(())
}
@ -161,6 +163,15 @@ async fn run() -> anyhow::Result<()> {
let matrix_client = matrix_connect(&config).await.context("Failed to connect to Matrix")?;
let (event_tx, mut event_rx) = mpsc::channel::<(GitlabEvent, OwnedRoomOrAliasId)>(100);
tokio::spawn(async move {
while let Some((event, room)) = event_rx.recv().await {
if let Err(err) = handle_gitlab_event(event, &room, &matrix_client).await {
warn!("Failed to handle payload: {}", err);
}
}
});
let gitlab_root_path = if let Some(url_prefix) = config.url_prefix.as_ref() {
url_prefix.split('/').fold(warp::any().boxed(), |last, segment| {
if segment.is_empty() {
@ -180,7 +191,7 @@ async fn run() -> anyhow::Result<()> {
.and(warp::body::json())
.then(move |token: String, event: event::GitlabEvent| {
let config = Arc::clone(&config);
let matrix_client = matrix_client.clone();
let event_tx = event_tx.clone();
async move {
let project = event.project();
@ -192,8 +203,8 @@ 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) = handle_gitlab_event(event, &room, &matrix_client).await {
warn!("Failed to handle payload: {}", err);
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 {