From 98070a5c5d6f1c293e201c6248bb068d2571d5e2 Mon Sep 17 00:00:00 2001 From: backwardspy Date: Sun, 22 Sep 2024 13:05:52 +0100 Subject: [PATCH] feat: add error hook (#27) --- Cargo.lock | 55 ++++++++++++--- Cargo.toml | 5 +- src/embed_builder.rs | 21 ++++-- src/errors.rs | 24 +++++++ src/events.rs | 70 ++++++++++++++++--- src/events/commit_comment.rs | 24 +++++-- src/events/discussion.rs | 56 +++++++++++---- src/events/discussion_comment.rs | 56 +++++++++++---- src/events/issue_comment.rs | 16 +++-- src/events/issues.rs | 32 +++++---- src/events/membership.rs | 54 +++++++++----- src/events/pull_request.rs | 53 +++++++++----- src/events/pull_request_review.rs | 34 ++++++--- src/events/release.rs | 26 ++++--- src/events/repository.rs | 51 ++++++++++---- src/main.rs | 112 ++++++++++++++++-------------- 16 files changed, 489 insertions(+), 200 deletions(-) create mode 100644 src/errors.rs diff --git a/Cargo.lock b/Cargo.lock index a6d45b9..f0a92d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,9 +69,9 @@ checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "axum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf" +checksum = "8f43644eed690f5374f1af436ecd6aea01cd201f6fbdf0178adaf6907afb2cec" dependencies = [ "async-trait", "axum-core", @@ -95,7 +95,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper 1.0.1", "tokio", - "tower", + "tower 0.5.1", "tower-layer", "tower-service", "tracing", @@ -103,9 +103,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15c63fd72d41492dc4f497196f5da1fb04fb7529e631d73630d1b491e47a2e3" +checksum = "5e6b8ba012a258d63c9adfa28b9ddcf66149da6f986c5b5452e629d5ee64bf00" dependencies = [ "async-trait", "bytes", @@ -116,7 +116,7 @@ dependencies = [ "mime", "pin-project-lite", "rustversion", - "sync_wrapper 0.1.2", + "sync_wrapper 1.0.1", "tower-layer", "tower-service", "tracing", @@ -207,8 +207,9 @@ dependencies = [ "reqwest", "serde", "serde_json", + "thiserror", "tokio", - "tower-http", + "tower-http 0.6.0", "tracing", "tracing-subscriber", "yare", @@ -665,7 +666,7 @@ dependencies = [ "pin-project-lite", "socket2", "tokio", - "tower", + "tower 0.4.13", "tower-service", "tracing", ] @@ -962,8 +963,8 @@ dependencies = [ "serde_urlencoded", "snafu", "tokio", - "tower", - "tower-http", + "tower 0.4.13", + "tower-http 0.5.2", "tracing", "url", ] @@ -1718,6 +1719,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2873938d487c3cfb9aed7546dc9f2711d867c9f90c46b889989a2cb84eba6b4f" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 0.1.2", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower-http" version = "0.5.2" @@ -1732,7 +1749,23 @@ dependencies = [ "http-body-util", "iri-string", "pin-project-lite", - "tower", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41515cc9e193536d93fd0dbbea0c73819c08eca76e0b30909a325c3ec90985bb" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "pin-project-lite", "tower-layer", "tower-service", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 0508e5c..74dab38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ cargo = "warn" [dependencies] anyhow = "1.0.86" -axum = "0.7.5" +axum = "0.7.6" axum-github-webhook-extract = "0.2.0" catppuccin = "2.4.0" envy = "0.4.2" @@ -36,8 +36,9 @@ octocrab = { git = "https://github.com/backwardspy/octocrab", branch = "feat/web reqwest = { version = "0.12.4", features = ["json"] } serde = "1.0.203" serde_json = "1.0.117" +thiserror = "1.0.63" tokio = { version = "1.38.0", features = ["full"] } -tower-http = { version = "0.5.2", features = ["trace"] } +tower-http = { version = "0.6.0", features = ["trace"] } tracing = "0.1.40" tracing-subscriber = "0.3.18" diff --git a/src/embed_builder.rs b/src/embed_builder.rs index 4fb1183..fbfe6a9 100644 --- a/src/embed_builder.rs +++ b/src/embed_builder.rs @@ -1,5 +1,6 @@ use octocrab::models::Author; use serde_json::json; +use thiserror::Error; const MAX_TITLE_LENGTH: usize = 100; const MAX_DESCRIPTION_LENGTH: usize = 640; @@ -14,6 +15,18 @@ pub struct EmbedBuilder { color: Option, } +#[derive(Debug, Error)] +pub enum Error { + #[error("missing title")] + Title, + #[error("missing url")] + Url, + #[error("missing author")] + Author, +} + +pub type Result = std::result::Result; + impl EmbedBuilder { pub fn title(&mut self, title: &str) -> &Self { self.title = Some(limit_text_length(title, MAX_TITLE_LENGTH)); @@ -41,14 +54,14 @@ impl EmbedBuilder { self } - pub fn try_build(self) -> anyhow::Result { + pub fn try_build(self) -> Result { Ok(json!({ "embeds": [{ - "title": self.title.ok_or_else(|| anyhow::anyhow!("missing title"))?, - "url": self.url.ok_or_else(|| anyhow::anyhow!("missing url"))?, + "title": self.title.ok_or(Error::Title)?, + "url": self.url.ok_or(Error::Url)?, "description": self.description, "color": self.color, - "author": embed_author(&self.author.ok_or_else(|| anyhow::anyhow!("missing author"))?), + "author": embed_author(&self.author.ok_or(Error::Author)?), }], })) } diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..516a7f9 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,24 @@ +use octocrab::models::webhook_events::WebhookEventType; +use thiserror::Error; + +use crate::embed_builder; + +#[derive(Debug, Error)] +pub enum RockdoveError { + #[error("missing field in event: {event_type:?}::{field}")] + MissingField { + event_type: WebhookEventType, + field: &'static str, + }, + + #[error("invalid field in event: {event_type:?}::{field}")] + InvalidField { + event_type: WebhookEventType, + field: &'static str, + }, + + #[error(transparent)] + EmbedBuilder(#[from] embed_builder::Error), +} + +pub type RockdoveResult = Result; diff --git a/src/events.rs b/src/events.rs index af90352..be7343f 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,10 +1,60 @@ -pub mod commit_comment; -pub mod discussion; -pub mod discussion_comment; -pub mod issue_comment; -pub mod issues; -pub mod membership; -pub mod pull_request; -pub mod pull_request_review; -pub mod release; -pub mod repository; +use octocrab::models::webhook_events::{WebhookEvent, WebhookEventPayload}; +use tracing::info; + +use crate::{ + embed_builder::EmbedBuilder, + errors::{RockdoveError, RockdoveResult}, +}; + +mod commit_comment; +mod discussion; +mod discussion_comment; +mod issue_comment; +mod issues; +mod membership; +mod pull_request; +mod pull_request_review; +mod release; +mod repository; + +pub fn make_embed(event: WebhookEvent) -> RockdoveResult> { + let sender = event + .sender + .clone() + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "sender", + })?; + + let Some(mut embed) = begin_embed(event)? else { + info!("ignoring event"); + return Ok(None); + }; + + embed.author(sender); + Ok(Some(embed.try_build()?)) +} + +fn begin_embed(event: WebhookEvent) -> RockdoveResult> { + match event.specific.clone() { + WebhookEventPayload::Repository(specifics) => repository::make_embed(event, &specifics), + WebhookEventPayload::Discussion(specifics) => discussion::make_embed(event, &specifics), + WebhookEventPayload::DiscussionComment(specifics) => { + discussion_comment::make_embed(event, &specifics) + } + WebhookEventPayload::Issues(specifics) => issues::make_embed(event, &specifics), + WebhookEventPayload::PullRequest(specifics) => pull_request::make_embed(event, &specifics), + WebhookEventPayload::IssueComment(specifics) => { + issue_comment::make_embed(event, &specifics) + } + WebhookEventPayload::CommitComment(specifics) => { + commit_comment::make_embed(event, &specifics) + } + WebhookEventPayload::PullRequestReview(specifics) => { + pull_request_review::make_embed(event, &specifics) + } + WebhookEventPayload::Release(specifics) => release::make_embed(event, &specifics), + WebhookEventPayload::Membership(specifics) => membership::make_embed(event, &specifics), + _ => Ok(None), + } +} diff --git a/src/events/commit_comment.rs b/src/events/commit_comment.rs index fd64018..5783331 100644 --- a/src/events/commit_comment.rs +++ b/src/events/commit_comment.rs @@ -1,14 +1,21 @@ use octocrab::models::webhook_events::{payload::CommitCommentWebhookEventPayload, WebhookEvent}; -use crate::{colors::COMMIT_COLOR, embed_builder::EmbedBuilder}; +use crate::{ + colors::COMMIT_COLOR, + embed_builder::EmbedBuilder, + errors::{RockdoveError, RockdoveResult}, +}; -pub fn make_commit_comment_embed( +pub fn make_embed( event: WebhookEvent, specifics: &CommitCommentWebhookEventPayload, -) -> EmbedBuilder { +) -> RockdoveResult> { let repo = event .repository - .expect("commit comment events should always have a repository"); + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "repository", + })?; let mut embed = EmbedBuilder::default(); @@ -24,18 +31,21 @@ pub fn make_commit_comment_embed( .comment .body .as_ref() - .expect("commit comment should always have a body") + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "comment.body", + })? .as_str(), ); embed.color(COMMIT_COLOR); - embed + Ok(Some(embed)) } #[cfg(test)] mod tests { use crate::{ - make_embed, + events::make_embed, tests::{embed_context, TestConfig}, }; diff --git a/src/events/discussion.rs b/src/events/discussion.rs index 4f1f92e..b8cbada 100644 --- a/src/events/discussion.rs +++ b/src/events/discussion.rs @@ -3,18 +3,25 @@ use octocrab::models::webhook_events::{ WebhookEvent, }; -use crate::{colors::DISCUSSION_COLOR, embed_builder::EmbedBuilder}; +use crate::{ + colors::DISCUSSION_COLOR, + embed_builder::EmbedBuilder, + errors::{RockdoveError, RockdoveResult}, +}; // TODO: Create a PR to upstream (octocrab) to add typed events so that we don't // need to use `.get()`, `.as_str()`, etc. -pub fn make_discussion_embed( +pub fn make_embed( event: WebhookEvent, specifics: &DiscussionWebhookEventPayload, -) -> Option { +) -> RockdoveResult> { let repo = event .repository - .expect("discussion events should always have a repository"); + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "repository", + })?; let mut embed = EmbedBuilder::default(); @@ -28,28 +35,43 @@ pub fn make_discussion_embed( DiscussionWebhookEventAction::Closed => "closed".to_string(), DiscussionWebhookEventAction::Reopened => "reopened".to_string(), _ => { - return None; + return Ok(None); } }, specifics .discussion .get("number") - .expect("discussion should always have a number"), + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "discussion.number", + })?, specifics .discussion .get("title") - .expect("discussion should always have a title") + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "discussion.title", + })? .as_str() - .expect("discussion title should always be a string"), + .ok_or_else(|| RockdoveError::InvalidField { + event_type: event.kind.clone(), + field: "discussion.title", + })?, )); embed.url( specifics .discussion .get("html_url") - .expect("discussion should always have an html url") + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "discussion.html_url", + })? .as_str() - .expect("discussion html url should always be a string"), + .ok_or_else(|| RockdoveError::InvalidField { + event_type: event.kind.clone(), + field: "discussion.html_url", + })?, ); if matches!(specifics.action, DiscussionWebhookEventAction::Created) { @@ -57,21 +79,27 @@ pub fn make_discussion_embed( specifics .discussion .get("body") - .expect("discussion should always have a body") + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "discussion.body", + })? .as_str() - .expect("discussion body should always be a string"), + .ok_or_else(|| RockdoveError::InvalidField { + event_type: event.kind.clone(), + field: "discussion.body", + })?, ); } embed.color(DISCUSSION_COLOR); - Some(embed) + Ok(Some(embed)) } #[cfg(test)] mod tests { use crate::{ - make_embed, + events::make_embed, tests::{embed_context, TestConfig}, }; use std::fs; diff --git a/src/events/discussion_comment.rs b/src/events/discussion_comment.rs index fc612fc..6a2e701 100644 --- a/src/events/discussion_comment.rs +++ b/src/events/discussion_comment.rs @@ -3,25 +3,32 @@ use octocrab::models::webhook_events::{ WebhookEvent, }; -use crate::{colors::DISCUSSION_COLOR, embed_builder::EmbedBuilder}; +use crate::{ + colors::DISCUSSION_COLOR, + embed_builder::EmbedBuilder, + errors::{RockdoveError, RockdoveResult}, +}; // TODO: Create a PR to upstream (octocrab) to add typed events so that we don't // need to use `.get()`, `.as_str()`, etc. -pub fn make_discussion_comment_embed( +pub fn make_embed( event: WebhookEvent, specifics: &DiscussionCommentWebhookEventPayload, -) -> Option { +) -> RockdoveResult> { if !matches!( specifics.action, DiscussionCommentWebhookEventAction::Created ) { - return None; + return Ok(None); } let repo = event .repository - .expect("discussion comment events should always have a repository"); + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "repository", + })?; let mut embed = EmbedBuilder::default(); @@ -33,42 +40,63 @@ pub fn make_discussion_comment_embed( specifics .discussion .get("number") - .expect("discussion comment should always have a number"), + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "discussion.number", + })?, specifics .discussion .get("title") - .expect("discussion should always have a title") + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "discussion.title", + })? .as_str() - .expect("discussion title should always be a string"), + .ok_or_else(|| RockdoveError::InvalidField { + event_type: event.kind.clone(), + field: "discussion.title", + })?, )); embed.url( specifics .comment .get("html_url") - .expect("discussion should always have an html url") + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "comment.html_url", + })? .as_str() - .expect("discussion html url should always be a string"), + .ok_or_else(|| RockdoveError::InvalidField { + event_type: event.kind.clone(), + field: "comment.html_url", + })?, ); embed.description( specifics .comment .get("body") - .expect("discussion should always have a body") + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "comment.body", + })? .as_str() - .expect("discussion body should always be a string"), + .ok_or_else(|| RockdoveError::InvalidField { + event_type: event.kind.clone(), + field: "comment.body", + })?, ); embed.color(DISCUSSION_COLOR); - Some(embed) + Ok(Some(embed)) } #[cfg(test)] mod tests { use crate::{ - make_embed, + events::make_embed, tests::{embed_context, TestConfig}, }; diff --git a/src/events/issue_comment.rs b/src/events/issue_comment.rs index becfc11..2f890c8 100644 --- a/src/events/issue_comment.rs +++ b/src/events/issue_comment.rs @@ -6,19 +6,23 @@ use octocrab::models::webhook_events::{ use crate::{ colors::{ISSUE_COLOR, PULL_REQUEST_COLOR}, embed_builder::EmbedBuilder, + errors::{RockdoveError, RockdoveResult}, }; -pub fn make_issue_comment_embed( +pub fn make_embed( event: WebhookEvent, specifics: &IssueCommentWebhookEventPayload, -) -> Option { +) -> RockdoveResult> { if !matches!(specifics.action, IssueCommentWebhookEventAction::Created) { - return None; + return Ok(None); } let repo = event .repository - .expect("issue comment events should always have a repository"); + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "repository", + })?; let mut embed = EmbedBuilder::default(); @@ -46,13 +50,13 @@ pub fn make_issue_comment_embed( ISSUE_COLOR }); - Some(embed) + Ok(Some(embed)) } #[cfg(test)] mod tests { use crate::{ - make_embed, + events::make_embed, tests::{embed_context, TestConfig}, }; use std::fs; diff --git a/src/events/issues.rs b/src/events/issues.rs index b711b96..4ab416b 100644 --- a/src/events/issues.rs +++ b/src/events/issues.rs @@ -3,15 +3,22 @@ use octocrab::models::webhook_events::{ WebhookEvent, }; -use crate::{colors::ISSUE_COLOR, embed_builder::EmbedBuilder}; +use crate::{ + colors::ISSUE_COLOR, + embed_builder::EmbedBuilder, + errors::{RockdoveError, RockdoveResult}, +}; -pub fn make_issues_embed( +pub fn make_embed( event: WebhookEvent, specifics: &IssuesWebhookEventPayload, -) -> Option { +) -> RockdoveResult> { let repo = event .repository - .expect("issue events should always have a repository"); + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "repository", + })?; let mut embed = EmbedBuilder::default(); @@ -22,11 +29,12 @@ pub fn make_issues_embed( repo_name, match specifics.action { IssuesWebhookEventAction::Assigned => { - let assignee = specifics - .issue - .assignee - .as_ref() - .expect("issue assigned events should always have an assignee"); + let assignee = specifics.issue.assignee.as_ref().ok_or_else(|| { + RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "issue.assignee", + } + })?; format!("assigned to {}", assignee.login) } IssuesWebhookEventAction::Closed => "closed".to_string(), @@ -39,7 +47,7 @@ pub fn make_issues_embed( // todo!() // } _ => { - return None; + return Ok(None); } }, specifics.issue.number, @@ -56,13 +64,13 @@ pub fn make_issues_embed( embed.color(ISSUE_COLOR); - Some(embed) + Ok(Some(embed)) } #[cfg(test)] mod tests { use crate::{ - make_embed, + events::make_embed, tests::{embed_context, TestConfig}, }; use std::fs; diff --git a/src/events/membership.rs b/src/events/membership.rs index 48e5412..1cea912 100644 --- a/src/events/membership.rs +++ b/src/events/membership.rs @@ -2,35 +2,52 @@ use octocrab::models::webhook_events::{ payload::{MembershipWebhookEventAction, MembershipWebhookEventPayload}, WebhookEvent, }; -use tracing::warn; -use crate::{colors::MEMBERSHIP_COLOR, embed_builder::EmbedBuilder}; +use crate::{ + colors::MEMBERSHIP_COLOR, + embed_builder::EmbedBuilder, + errors::{RockdoveError, RockdoveResult}, +}; -pub fn make_membership_embed( +pub fn make_embed( event: WebhookEvent, specifics: &MembershipWebhookEventPayload, -) -> Option { - let Some(team_name) = specifics.team.get("name").and_then(|v| v.as_str()) else { - warn!(?specifics.team, "missing team name"); - return None; - }; +) -> RockdoveResult> { + let team_name = specifics + .team + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "team.name", + })?; - let Some(member_login) = specifics.member.get("login").and_then(|v| v.as_str()) else { - warn!(?specifics.member, "missing member login"); - return None; - }; + let member_login = specifics + .member + .get("login") + .and_then(|v| v.as_str()) + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "member.login", + })?; let mut embed = EmbedBuilder::default(); embed.title(&format!( "[{}] {} {} {} team", - event.organization?.login, + event + .organization + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "organization" + })? + .login, member_login, match specifics.action { MembershipWebhookEventAction::Added => "added to", MembershipWebhookEventAction::Removed => "removed from", _ => { - return None; + return Ok(None); } }, team_name @@ -41,18 +58,21 @@ pub fn make_membership_embed( .team .get("html_url") .and_then(|v| v.as_str()) - .expect("team should always have an html url"), + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "team.html_url", + })?, ); embed.color(MEMBERSHIP_COLOR); - Some(embed) + Ok(Some(embed)) } #[cfg(test)] mod tests { use crate::{ - make_embed, + events::make_embed, tests::{embed_context, TestConfig}, }; use std::fs; diff --git a/src/events/pull_request.rs b/src/events/pull_request.rs index 24a3097..82fbee8 100644 --- a/src/events/pull_request.rs +++ b/src/events/pull_request.rs @@ -3,15 +3,22 @@ use octocrab::models::webhook_events::{ WebhookEvent, }; -use crate::{colors::PULL_REQUEST_COLOR, embed_builder::EmbedBuilder}; +use crate::{ + colors::PULL_REQUEST_COLOR, + embed_builder::EmbedBuilder, + errors::{RockdoveError, RockdoveResult}, +}; -pub fn make_pull_request_embed( +pub fn make_embed( event: WebhookEvent, specifics: &PullRequestWebhookEventPayload, -) -> Option { +) -> RockdoveResult> { let repo = event .repository - .expect("pull request events should always have a repository"); + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "repository", + })?; let mut embed = EmbedBuilder::default(); @@ -22,10 +29,14 @@ pub fn make_pull_request_embed( repo_name, match specifics.action { PullRequestWebhookEventAction::Assigned => { - let assignee = specifics - .assignee - .as_ref() - .expect("pull request assigned events should always have an assignee"); + let assignee = + specifics + .assignee + .as_ref() + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "assignee", + })?; format!("assigned to {}", assignee.login) } PullRequestWebhookEventAction::Closed => { @@ -40,14 +51,16 @@ pub fn make_pull_request_embed( PullRequestWebhookEventAction::ReadyForReview => "ready for review".to_string(), PullRequestWebhookEventAction::Reopened => "reopened".to_string(), PullRequestWebhookEventAction::ReviewRequested => { - let reviewer = specifics - .requested_reviewer - .as_ref() - .expect("pull request review requested events should always have a reviewer"); + let reviewer = specifics.requested_reviewer.as_ref().ok_or_else(|| { + RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "requested_reviewer", + } + })?; format!("review requested from {}", reviewer.login) } _ => { - return None; + return Ok(None); } }, specifics.number, @@ -55,7 +68,10 @@ pub fn make_pull_request_embed( .pull_request .title .as_ref() - .expect("pull request should always have a title") + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "pull_request.title", + })? )); embed.url( @@ -63,7 +79,10 @@ pub fn make_pull_request_embed( .pull_request .html_url .as_ref() - .expect("pull request should always have an html url") + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "pull_request.html_url", + })? .as_str(), ); @@ -75,13 +94,13 @@ pub fn make_pull_request_embed( embed.color(PULL_REQUEST_COLOR); - Some(embed) + Ok(Some(embed)) } #[cfg(test)] mod tests { use crate::{ - make_embed, + events::make_embed, tests::{embed_context, TestConfig}, }; use std::fs; diff --git a/src/events/pull_request_review.rs b/src/events/pull_request_review.rs index 180211b..c7a74d7 100644 --- a/src/events/pull_request_review.rs +++ b/src/events/pull_request_review.rs @@ -6,22 +6,29 @@ use octocrab::models::{ }, }; -use crate::{colors::PULL_REQUEST_COLOR, embed_builder::EmbedBuilder}; +use crate::{ + colors::PULL_REQUEST_COLOR, + embed_builder::EmbedBuilder, + errors::{RockdoveError, RockdoveResult}, +}; -pub fn make_pull_request_review_embed( +pub fn make_embed( event: WebhookEvent, specifics: &PullRequestReviewWebhookEventPayload, -) -> Option { +) -> RockdoveResult> { if !matches!( specifics.action, PullRequestReviewWebhookEventAction::Submitted ) { - return None; + return Ok(None); } let repo = event .repository - .expect("pull request review events should always have a repository"); + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "repository", + })?; let mut embed = EmbedBuilder::default(); @@ -33,19 +40,24 @@ pub fn make_pull_request_review_embed( match specifics .review .state - .expect("pull request review should always have a state") - { + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "review.state", + })? { ReviewState::Approved => "approved", ReviewState::ChangesRequested => "changes requested", ReviewState::Commented => "reviewed", - _ => return None, + _ => return Ok(None), }, specifics.pull_request.number, specifics .pull_request .title .as_ref() - .expect("pull request should always have a title"), + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "pull_request.title", + })? )); embed.url(specifics.review.html_url.as_str()); @@ -56,13 +68,13 @@ pub fn make_pull_request_review_embed( embed.color(PULL_REQUEST_COLOR); - Some(embed) + Ok(Some(embed)) } #[cfg(test)] mod tests { use crate::{ - make_embed, + events::make_embed, tests::{embed_context, TestConfig}, }; use std::fs; diff --git a/src/events/release.rs b/src/events/release.rs index 9fee4a4..4cdad8b 100644 --- a/src/events/release.rs +++ b/src/events/release.rs @@ -3,19 +3,26 @@ use octocrab::models::webhook_events::{ WebhookEvent, }; -use crate::{colors::RELEASE_COLOR, embed_builder::EmbedBuilder}; +use crate::{ + colors::RELEASE_COLOR, + embed_builder::EmbedBuilder, + errors::{RockdoveError, RockdoveResult}, +}; -pub fn make_release_embed( +pub fn make_embed( event: WebhookEvent, specifics: &ReleaseWebhookEventPayload, -) -> Option { +) -> RockdoveResult> { if !matches!(specifics.action, ReleaseWebhookEventAction::Released) { - return None; + return Ok(None); } let repo = event .repository - .expect("release events should always have a repository"); + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "repository", + })?; let mut embed = EmbedBuilder::default(); @@ -36,7 +43,10 @@ pub fn make_release_embed( .release .get("html_url") .and_then(|v| v.as_str()) - .expect("release should always have an html url"), + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "release.html_url", + })?, ); if let Some(body) = specifics.release.get("body").and_then(|v| v.as_str()) { @@ -45,13 +55,13 @@ pub fn make_release_embed( embed.color(RELEASE_COLOR); - Some(embed) + Ok(Some(embed)) } #[cfg(test)] mod tests { use crate::{ - make_embed, + events::make_embed, tests::{embed_context, TestConfig}, }; diff --git a/src/events/repository.rs b/src/events/repository.rs index 74816e5..a7cfe77 100644 --- a/src/events/repository.rs +++ b/src/events/repository.rs @@ -6,15 +6,19 @@ use octocrab::models::webhook_events::{ use crate::{ colors::{COLORS, REPO_COLOR}, embed_builder::EmbedBuilder, + errors::{RockdoveError, RockdoveResult}, }; -pub fn make_repository_embed( +pub fn make_embed( event: WebhookEvent, specifics: &RepositoryWebhookEventPayload, -) -> Option { +) -> RockdoveResult> { let repo = event .repository - .expect("repository events should always have a repository"); + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "repository", + })?; let mut embed = EmbedBuilder::default(); @@ -33,13 +37,22 @@ pub fn make_repository_embed( specifics .changes .as_ref() - .expect("repository renamed event should always have changes") + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "changes", + })? .repository .as_ref() - .expect("repository renamed event changes should always have a repository") + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "changes.repository", + })? .name .as_ref() - .expect("repository renamed event changes should always have a name") + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "changes.repository.name", + })? .from, repo.name, ) @@ -50,28 +63,40 @@ pub fn make_repository_embed( specifics .changes .as_ref() - .expect("repository transferred event should always have changes") + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "changes", + })? .owner .as_ref() - .expect("repository transferred event changes should always have an owner") + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "changes.owner", + })? .from .user .login, repo.owner - .expect("repository should always have an owner") + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "repository.owner", + })? .login ) } RepositoryWebhookEventAction::Unarchived => "unarchived".to_string(), _ => { - return None; + return Ok(None); } } )); embed.url( repo.html_url - .expect("repository should always have an html url") + .ok_or_else(|| RockdoveError::MissingField { + event_type: event.kind.clone(), + field: "repository.html_url", + })? .as_str(), ); @@ -81,13 +106,13 @@ pub fn make_repository_embed( _ => REPO_COLOR, }); - Some(embed) + Ok(Some(embed)) } #[cfg(test)] mod tests { use crate::{ - make_embed, + events::make_embed, tests::{embed_context, TestConfig}, }; use std::fs; diff --git a/src/main.rs b/src/main.rs index ac8a342..0d55d99 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,25 +9,23 @@ use axum::{ Router, }; use axum_github_webhook_extract::{GithubEvent, GithubToken}; -use events::{ - commit_comment::make_commit_comment_embed, discussion::make_discussion_embed, - discussion_comment::make_discussion_comment_embed, issue_comment::make_issue_comment_embed, - issues::make_issues_embed, membership::make_membership_embed, - pull_request::make_pull_request_embed, pull_request_review::make_pull_request_review_embed, - release::make_release_embed, repository::make_repository_embed, -}; -use octocrab::models::webhook_events::{WebhookEvent, WebhookEventPayload}; +use colors::COLORS; +use embed_builder::EmbedBuilder; +use errors::RockdoveError; +use octocrab::models::{webhook_events::WebhookEvent, Author}; use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer}; use tracing::{error, info, Level}; mod colors; mod embed_builder; +mod errors; #[derive(serde::Deserialize)] struct Config { github_webhook_secret: String, discord_webhook: String, discord_bot_webhook: String, + discord_error_webhook: String, #[serde(default = "default_port")] port: u16, } @@ -40,6 +38,7 @@ const fn default_port() -> u16 { struct DiscordHooks { normal: String, bot: String, + error: String, } #[derive(Clone)] @@ -73,6 +72,7 @@ async fn main() -> anyhow::Result<()> { discord_hooks: DiscordHooks { normal: config.discord_webhook, bot: config.discord_bot_webhook, + error: config.discord_error_webhook, }, github_token: GithubToken(Arc::new(config.github_webhook_secret)), }); @@ -130,46 +130,31 @@ async fn webhook( } }; - match make_embed(event) { + match events::make_embed(event) { Ok(Some(msg)) => send_hook(&msg, hook).await, Ok(None) => info!("no embed created - ignoring event"), - Err(e) => error!(%e, "failed to make discord message"), + Err(e) => { + error!(%e, "failed to make discord message"); + send_error_hook(&e, &app_state.discord_hooks.error).await; + } } } -fn make_embed(event: WebhookEvent) -> anyhow::Result> { - let sender = event - .sender - .clone() - .expect("event should always have a sender"); - - let Some(mut embed) = (match event.specific.clone() { - WebhookEventPayload::Repository(specifics) => make_repository_embed(event, &specifics), - WebhookEventPayload::Discussion(specifics) => make_discussion_embed(event, &specifics), - WebhookEventPayload::DiscussionComment(specifics) => { - make_discussion_comment_embed(event, &specifics) - } - WebhookEventPayload::Issues(specifics) => make_issues_embed(event, &specifics), - WebhookEventPayload::PullRequest(specifics) => make_pull_request_embed(event, &specifics), - WebhookEventPayload::IssueComment(specifics) => make_issue_comment_embed(event, &specifics), - WebhookEventPayload::CommitComment(specifics) => { - Some(make_commit_comment_embed(event, &specifics)) - } - WebhookEventPayload::PullRequestReview(specifics) => { - make_pull_request_review_embed(event, &specifics) +fn hook_target(event: &WebhookEvent) -> HookTarget { + if let Some(sender) = &event.sender { + if sender.r#type == "Bot" { + return HookTarget::Bot; } - WebhookEventPayload::Release(specifics) => make_release_embed(event, &specifics), - WebhookEventPayload::Membership(specifics) => make_membership_embed(event, &specifics), - _ => { - info!(?event.kind, "ignoring event"); - return Ok(None); + } + + if let Some(repository) = &event.repository { + if repository.private.unwrap_or(false) { + info!("ignoring private repository event"); + return HookTarget::None; } - }) else { - return Ok(None); - }; + } - embed.author(sender); - Ok(Some(embed.try_build()?)) + HookTarget::Normal } async fn send_hook(e: &serde_json::Value, hook: &str) { @@ -185,21 +170,40 @@ async fn send_hook(e: &serde_json::Value, hook: &str) { } } -fn hook_target(event: &WebhookEvent) -> HookTarget { - if let Some(sender) = &event.sender { - if sender.r#type == "Bot" { - return HookTarget::Bot; - } - } - - if let Some(repository) = &event.repository { - if repository.private.unwrap_or(false) { - info!("ignoring private repository event"); - return HookTarget::None; - } - } +async fn send_error_hook(e: &RockdoveError, hook: &str) { + let mut embed = EmbedBuilder::default(); + embed.title("Error"); + embed.description(&e.to_string()); + embed.color(COLORS.red); + embed.author(make_hammy()); + let msg = embed + .try_build() + .expect("error embed should always be valid"); + send_hook(&msg, hook).await; +} - HookTarget::Normal +fn make_hammy() -> Author { + serde_json::from_value(serde_json::json!({ + "login": "sgoudham", + "id": 58_985_301, + "node_id": "MDQ6VXNlcjU4OTg1MzAx", + "avatar_url": "https://avatars.githubusercontent.com/u/58985301?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/sgoudham", + "html_url": "https://github.com/sgoudham", + "followers_url": "https://api.github.com/users/sgoudham/followers", + "following_url": "https://api.github.com/users/sgoudham/following{/other_user}", + "gists_url": "https://api.github.com/users/sgoudham/gists{/gist_id}", + "starred_url": "https://api.github.com/users/sgoudham/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/sgoudham/subscriptions", + "organizations_url": "https://api.github.com/users/sgoudham/orgs", + "repos_url": "https://api.github.com/users/sgoudham/repos", + "events_url": "https://api.github.com/users/sgoudham/events{/privacy}", + "received_events_url": "https://api.github.com/users/sgoudham/received_events", + "type": "User", + "site_admin": false + })) + .expect("hammy is always valid :pepe_heart:") } #[cfg(test)]