gitlab 0.1801.0

Gitlab API client.
Documentation
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.

use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
use log::warn;
use serde::de::{Error, Unexpected};
use serde::{Deserialize, Deserializer};
use serde_json::Value;

/// Kinds of webhooks sent by GitLab.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum HookKind {
    /// System hooks (from the installation).
    System(Option<SystemHookType>),
    /// Web hooks (from projects).
    Web(Option<WebHookType>),
}

impl HookKind {
    /// Determine the kind of hook payload for a JSON object.
    // Ignore `clippy` complaining about a "manual map" so that future hook kinds can be easily
    // added in the future.
    #[allow(clippy::manual_map)]
    pub fn kind_of(hook: &Value) -> Option<Self> {
        if let Some(object_kind) = hook.pointer("/object_kind") {
            Some(Self::Web(WebHookType::hook_type_of_kind(object_kind)))
        } else if let Some(event_name) = hook.pointer("/event_name") {
            Some(Self::System(SystemHookType::hook_type_of_name(event_name)))
        } else {
            None
        }
    }
}

/// Types for system hooks.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SystemHookType {
    /// Hooks for project events.
    Project,
    /// Hooks for project membership events.
    ProjectMember,
    /// Hooks for user events.
    User,
    /// Hooks for blocked user login events.
    UserFailedLogin,
    /// Hooks for user key events.
    Key,
    /// Hooks for group events.
    Group,
    /// Hooks for group membership events.
    GroupMember,
    /// Hooks for push events.
    Push,
    /// Hooks for repository updates (one per push batch).
    RepositoryUpdate,
}

impl SystemHookType {
    /// Compute the type of a system hook.
    ///
    /// Unknown types will raise a warning, but return `None`.
    pub fn type_of(hook: &Value) -> Option<Self> {
        match hook.pointer("/event_name") {
            Some(Value::String(name)) => Self::of(name),
            _ => None,
        }
    }

    fn hook_type_of_name(hook: &Value) -> Option<Self> {
        match hook {
            Value::String(name) => Self::of(name),
            _ => None,
        }
    }

    fn of(event_name: &str) -> Option<Self> {
        match event_name {
            "project_create" | "project_destroy" | "project_rename" | "project_transfer"
            | "project_update" => Some(Self::Project),
            "user_access_request_revoked_for_project"
            | "user_access_request_to_project"
            | "user_add_to_team"
            | "user_remove_from_team"
            | "user_update_for_team" => Some(Self::ProjectMember),
            "repository_update" => Some(Self::RepositoryUpdate),
            "user_create" | "user_destroy" | "user_rename" => Some(Self::User),
            "user_failed_login" => Some(Self::UserFailedLogin),
            "key_create" | "key_destroy" => Some(Self::Key),
            "group_create" | "group_destroy" | "group_rename" => Some(Self::Group),
            "user_access_request_revoked_for_group"
            | "user_access_request_to_group"
            | "user_add_to_group"
            | "user_remove_from_group"
            | "user_update_for_group" => Some(Self::GroupMember),
            "push" | "tag_push" => Some(Self::Push),
            event_name => {
                warn!("unrecognized system hook `event_name`: {}", event_name);
                None
            },
        }
    }
}

/// Types for webhooks.
///
/// Webhooks are associated with specific projects.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum WebHookType {
    /// Hooks for push events.
    Push,
    /// Hooks for issue events.
    Issue,
    /// Hooks for merge request events.
    MergeRequest,
    /// Hooks for note events.
    Note,
    /// Hooks for build events.
    Build,
    /// Hooks for pipeline events.
    Pipeline,
    /// Hooks for wiki events.
    WikiPage,
    /// Hooks for deployent events.
    Deployment,
    /// Hooks for feature flag changes.
    FeatureFlag,
    /// Hooks for release events.
    Release,
    /// Hooks for emoji events.
    Emoji,
    /// Hooks for access token events.
    AccessToken,
}

impl WebHookType {
    /// Compute the type of a webhook.
    ///
    /// Unknown types will raise a warning, but return `None`.
    pub fn type_of(hook: &Value) -> Option<Self> {
        match hook.pointer("/object_kind") {
            Some(Value::String(name)) => Self::of(name),
            _ => None,
        }
    }

    fn hook_type_of_kind(hook: &Value) -> Option<Self> {
        match hook {
            Value::String(name) => Self::of(name),
            _ => None,
        }
    }

    fn of(object_kind: &str) -> Option<Self> {
        match object_kind {
            "push" | "tag_push" => Some(Self::Push),
            "issue" | "work_item" => Some(Self::Issue),
            "merge_request" => Some(Self::MergeRequest),
            "note" => Some(Self::Note),
            "build" => Some(Self::Build),
            "pipeline" => Some(Self::Pipeline),
            "wiki_page" => Some(Self::WikiPage),
            "deployment" => Some(Self::Deployment),
            "feature_flag" => Some(Self::FeatureFlag),
            "release" => Some(Self::Release),
            "emoji" => Some(Self::Emoji),
            "access_token" => Some(Self::AccessToken),
            object_kind => {
                warn!("unrecognized web hook `object_kind`: {}", object_kind);
                None
            },
        }
    }
}

/// A wrapper struct for dates in web hooks.
///
/// Gitlab does not use a standard date format for dates in web hooks. This structure supports
/// deserializing the formats that have been observed.
#[derive(Debug, Clone, Copy)]
#[repr(transparent)]
pub struct HookDate(DateTime<Utc>);

impl<'de> Deserialize<'de> for HookDate {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let val = String::deserialize(deserializer)?;

        NaiveDateTime::parse_from_str(&val, "%Y-%m-%d %H:%M:%S UTC")
            // XXX(chrono-0.4.25): `dt.and_utc()`
            .map(|dt| Utc.from_utc_datetime(&dt))
            .or_else(|_| DateTime::parse_from_rfc3339(&val).map(|dt| dt.with_timezone(&Utc)))
            .or_else(|_| {
                DateTime::parse_from_str(&val, "%Y-%m-%d %H:%M:%S %z")
                    .map(|dt| dt.with_timezone(&Utc))
            })
            .map_err(|err| {
                D::Error::invalid_value(
                    Unexpected::Other("hook date"),
                    &format!("Unsupported format: {} {:?}", val, err).as_str(),
                )
            })
            .map(HookDate)
    }
}

impl AsRef<DateTime<Utc>> for HookDate {
    fn as_ref(&self) -> &DateTime<Utc> {
        &self.0
    }
}

#[cfg(test)]
mod tests {
    use std::fmt;

    use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
    use serde::de::value::StrDeserializer;
    use serde::Deserialize;
    use serde_json::json;
    use thiserror::Error;

    use crate::hooktypes::{HookDate, HookKind, SystemHookType, WebHookType};

    #[derive(Debug, Error)]
    struct TestError {
        msg: String,
    }

    impl fmt::Display for TestError {
        fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
            write!(f, "TestError ({})", self.msg)
        }
    }

    impl serde::de::Error for TestError {
        fn custom<T>(msg: T) -> Self
        where
            T: fmt::Display,
        {
            Self {
                msg: msg.to_string(),
            }
        }
    }

    fn expected_datetime() -> DateTime<Utc> {
        let d = NaiveDate::from_ymd_opt(2024, 5, 6).unwrap();
        let t = NaiveTime::from_hms_opt(10, 34, 38).unwrap();
        let dt = d.and_time(t);
        // XXX(chrono-0.4.25): `dt.and_utc()`
        Utc.from_utc_datetime(&dt)
    }

    fn test_hookdate_parse(input: &str) {
        let hd = HookDate::deserialize(StrDeserializer::<TestError>::new(input)).unwrap();
        assert_eq!(hd.as_ref(), &expected_datetime());
    }

    #[test]
    fn test_hookdate_literal_utc() {
        test_hookdate_parse("2024-05-06 10:34:38 UTC");
    }

    #[test]
    fn test_hookdate_literal_local() {
        test_hookdate_parse("2024-05-06 05:34:38 -0500");
    }

    #[test]
    fn test_hookdate_rfc3339() {
        test_hookdate_parse("2024-05-06T10:34:38Z");
        test_hookdate_parse("2024-05-06T10:34:38+00:00");
        test_hookdate_parse("2024-05-06T06:34:38-04:00");
    }

    #[test]
    fn test_hookdate_invalid() {
        let err =
            HookDate::deserialize(StrDeserializer::<TestError>::new("invalid_date")).unwrap_err();
        assert_eq!(err.msg, "invalid value: hook date, expected Unsupported format: invalid_date ParseError(Invalid)");
    }

    const SYSTEM_ITEMS: &[(&str, Option<SystemHookType>)] = &[
        ("group_create", Some(SystemHookType::Group)),
        ("group_destroy", Some(SystemHookType::Group)),
        ("group_rename", Some(SystemHookType::Group)),
        ("key_create", Some(SystemHookType::Key)),
        ("key_destroy", Some(SystemHookType::Key)),
        ("project_create", Some(SystemHookType::Project)),
        ("project_destroy", Some(SystemHookType::Project)),
        ("project_rename", Some(SystemHookType::Project)),
        ("project_transfer", Some(SystemHookType::Project)),
        ("project_update", Some(SystemHookType::Project)),
        ("push", Some(SystemHookType::Push)),
        ("repository_update", Some(SystemHookType::RepositoryUpdate)),
        ("tag_push", Some(SystemHookType::Push)),
        (
            "user_access_request_revoked_for_group",
            Some(SystemHookType::GroupMember),
        ),
        (
            "user_access_request_revoked_for_project",
            Some(SystemHookType::ProjectMember),
        ),
        (
            "user_access_request_to_group",
            Some(SystemHookType::GroupMember),
        ),
        (
            "user_access_request_to_project",
            Some(SystemHookType::ProjectMember),
        ),
        ("user_add_to_group", Some(SystemHookType::GroupMember)),
        ("user_add_to_team", Some(SystemHookType::ProjectMember)),
        ("user_create", Some(SystemHookType::User)),
        ("user_destroy", Some(SystemHookType::User)),
        ("user_failed_login", Some(SystemHookType::UserFailedLogin)),
        ("user_remove_from_group", Some(SystemHookType::GroupMember)),
        ("user_remove_from_team", Some(SystemHookType::ProjectMember)),
        ("user_rename", Some(SystemHookType::User)),
        ("user_update_for_group", Some(SystemHookType::GroupMember)),
        ("user_update_for_team", Some(SystemHookType::ProjectMember)),
        ("NOT_A_SYSTEM_HOOK", None),
    ];
    const WEB_ITEMS: &[(&str, Option<WebHookType>)] = &[
        ("access_token", Some(WebHookType::AccessToken)),
        ("build", Some(WebHookType::Build)),
        ("deployment", Some(WebHookType::Deployment)),
        ("emoji", Some(WebHookType::Emoji)),
        ("feature_flag", Some(WebHookType::FeatureFlag)),
        ("issue", Some(WebHookType::Issue)),
        ("merge_request", Some(WebHookType::MergeRequest)),
        ("note", Some(WebHookType::Note)),
        ("pipeline", Some(WebHookType::Pipeline)),
        ("push", Some(WebHookType::Push)),
        ("release", Some(WebHookType::Release)),
        ("tag_push", Some(WebHookType::Push)),
        ("wiki_page", Some(WebHookType::WikiPage)),
        ("work_item", Some(WebHookType::Issue)),
        ("NOT_A_HOOK", None),
    ];

    #[test]
    fn test_hook_kind_classification() {
        for (n, k) in SYSTEM_ITEMS {
            assert_eq!(
                HookKind::kind_of(&json!({
                    "event_name": n,
                })),
                Some(HookKind::System(*k)),
            );
        }

        for (n, k) in WEB_ITEMS {
            assert_eq!(
                HookKind::kind_of(&json!({
                    "object_kind": n,
                })),
                Some(HookKind::Web(*k)),
            );
        }

        assert!(HookKind::kind_of(&json!({})).is_none());
    }

    #[test]
    fn test_system_hook_classification() {
        for (n, k) in SYSTEM_ITEMS {
            assert_eq!(
                SystemHookType::type_of(&json!({
                    "event_name": n,
                })),
                *k,
            );
        }
    }

    #[test]
    fn test_web_hook_classification() {
        for (n, k) in WEB_ITEMS {
            assert_eq!(
                WebHookType::type_of(&json!({
                    "object_kind": n,
                })),
                *k,
            );
        }
    }
}