1use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
8use log::warn;
9use serde::de::{Error, Unexpected};
10use serde::{Deserialize, Deserializer};
11use serde_json::Value;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum HookKind {
17 System(Option<SystemHookType>),
19 Web(Option<WebHookType>),
21}
22
23impl HookKind {
24 #[allow(clippy::manual_map)]
28 pub fn kind_of(hook: &Value) -> Option<Self> {
29 if let Some(object_kind) = hook.pointer("/object_kind") {
30 Some(Self::Web(WebHookType::hook_type_of_kind(object_kind)))
31 } else if let Some(event_name) = hook.pointer("/event_name") {
32 Some(Self::System(SystemHookType::hook_type_of_name(event_name)))
33 } else {
34 None
35 }
36 }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41#[non_exhaustive]
42pub enum SystemHookType {
43 Project,
45 ProjectMember,
47 User,
49 UserFailedLogin,
51 Key,
53 Group,
55 GroupMember,
57 Push,
59 RepositoryUpdate,
61}
62
63impl SystemHookType {
64 pub fn type_of(hook: &Value) -> Option<Self> {
68 match hook.pointer("/event_name") {
69 Some(Value::String(name)) => Self::of(name),
70 _ => None,
71 }
72 }
73
74 fn hook_type_of_name(hook: &Value) -> Option<Self> {
75 match hook {
76 Value::String(name) => Self::of(name),
77 _ => None,
78 }
79 }
80
81 fn of(event_name: &str) -> Option<Self> {
82 match event_name {
83 "project_create" | "project_destroy" | "project_rename" | "project_transfer"
84 | "project_update" => Some(Self::Project),
85 "user_access_request_revoked_for_project"
86 | "user_access_request_to_project"
87 | "user_add_to_team"
88 | "user_remove_from_team"
89 | "user_update_for_team" => Some(Self::ProjectMember),
90 "repository_update" => Some(Self::RepositoryUpdate),
91 "user_create" | "user_destroy" | "user_rename" => Some(Self::User),
92 "user_failed_login" => Some(Self::UserFailedLogin),
93 "key_create" | "key_destroy" => Some(Self::Key),
94 "group_create" | "group_destroy" | "group_rename" => Some(Self::Group),
95 "user_access_request_revoked_for_group"
96 | "user_access_request_to_group"
97 | "user_add_to_group"
98 | "user_remove_from_group"
99 | "user_update_for_group" => Some(Self::GroupMember),
100 "push" | "tag_push" => Some(Self::Push),
101 event_name => {
102 warn!("unrecognized system hook `event_name`: {}", event_name);
103 None
104 },
105 }
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113#[non_exhaustive]
114pub enum WebHookType {
115 Push,
117 Issue,
119 MergeRequest,
121 Note,
123 Build,
125 Pipeline,
127 WikiPage,
129 Deployment,
131 FeatureFlag,
133 Release,
135 Emoji,
137 AccessToken,
139}
140
141impl WebHookType {
142 pub fn type_of(hook: &Value) -> Option<Self> {
146 match hook.pointer("/object_kind") {
147 Some(Value::String(name)) => Self::of(name),
148 _ => None,
149 }
150 }
151
152 fn hook_type_of_kind(hook: &Value) -> Option<Self> {
153 match hook {
154 Value::String(name) => Self::of(name),
155 _ => None,
156 }
157 }
158
159 fn of(object_kind: &str) -> Option<Self> {
160 match object_kind {
161 "push" | "tag_push" => Some(Self::Push),
162 "issue" | "work_item" => Some(Self::Issue),
163 "merge_request" => Some(Self::MergeRequest),
164 "note" => Some(Self::Note),
165 "build" => Some(Self::Build),
166 "pipeline" => Some(Self::Pipeline),
167 "wiki_page" => Some(Self::WikiPage),
168 "deployment" => Some(Self::Deployment),
169 "feature_flag" => Some(Self::FeatureFlag),
170 "release" => Some(Self::Release),
171 "emoji" => Some(Self::Emoji),
172 "access_token" => Some(Self::AccessToken),
173 object_kind => {
174 warn!("unrecognized web hook `object_kind`: {}", object_kind);
175 None
176 },
177 }
178 }
179}
180
181#[derive(Debug, Clone, Copy)]
186#[repr(transparent)]
187pub struct HookDate(DateTime<Utc>);
188
189impl<'de> Deserialize<'de> for HookDate {
190 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
191 where
192 D: Deserializer<'de>,
193 {
194 let val = String::deserialize(deserializer)?;
195
196 NaiveDateTime::parse_from_str(&val, "%Y-%m-%d %H:%M:%S UTC")
197 .map(|dt| Utc.from_utc_datetime(&dt))
199 .or_else(|_| DateTime::parse_from_rfc3339(&val).map(|dt| dt.with_timezone(&Utc)))
200 .or_else(|_| {
201 DateTime::parse_from_str(&val, "%Y-%m-%d %H:%M:%S %z")
202 .map(|dt| dt.with_timezone(&Utc))
203 })
204 .map_err(|err| {
205 D::Error::invalid_value(
206 Unexpected::Other("hook date"),
207 &format!("Unsupported format: {} {:?}", val, err).as_str(),
208 )
209 })
210 .map(HookDate)
211 }
212}
213
214impl AsRef<DateTime<Utc>> for HookDate {
215 fn as_ref(&self) -> &DateTime<Utc> {
216 &self.0
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use std::fmt;
223
224 use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
225 use serde::de::value::StrDeserializer;
226 use serde::Deserialize;
227 use serde_json::json;
228 use thiserror::Error;
229
230 use crate::hooktypes::{HookDate, HookKind, SystemHookType, WebHookType};
231
232 #[derive(Debug, Error)]
233 struct TestError {
234 msg: String,
235 }
236
237 impl fmt::Display for TestError {
238 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
239 write!(f, "TestError ({})", self.msg)
240 }
241 }
242
243 impl serde::de::Error for TestError {
244 fn custom<T>(msg: T) -> Self
245 where
246 T: fmt::Display,
247 {
248 Self {
249 msg: msg.to_string(),
250 }
251 }
252 }
253
254 fn expected_datetime() -> DateTime<Utc> {
255 let d = NaiveDate::from_ymd_opt(2024, 5, 6).unwrap();
256 let t = NaiveTime::from_hms_opt(10, 34, 38).unwrap();
257 let dt = d.and_time(t);
258 Utc.from_utc_datetime(&dt)
260 }
261
262 fn test_hookdate_parse(input: &str) {
263 let hd = HookDate::deserialize(StrDeserializer::<TestError>::new(input)).unwrap();
264 assert_eq!(hd.as_ref(), &expected_datetime());
265 }
266
267 #[test]
268 fn test_hookdate_literal_utc() {
269 test_hookdate_parse("2024-05-06 10:34:38 UTC");
270 }
271
272 #[test]
273 fn test_hookdate_literal_local() {
274 test_hookdate_parse("2024-05-06 05:34:38 -0500");
275 }
276
277 #[test]
278 fn test_hookdate_rfc3339() {
279 test_hookdate_parse("2024-05-06T10:34:38Z");
280 test_hookdate_parse("2024-05-06T10:34:38+00:00");
281 test_hookdate_parse("2024-05-06T06:34:38-04:00");
282 }
283
284 #[test]
285 fn test_hookdate_invalid() {
286 let err =
287 HookDate::deserialize(StrDeserializer::<TestError>::new("invalid_date")).unwrap_err();
288 assert_eq!(err.msg, "invalid value: hook date, expected Unsupported format: invalid_date ParseError(Invalid)");
289 }
290
291 const SYSTEM_ITEMS: &[(&str, Option<SystemHookType>)] = &[
292 ("group_create", Some(SystemHookType::Group)),
293 ("group_destroy", Some(SystemHookType::Group)),
294 ("group_rename", Some(SystemHookType::Group)),
295 ("key_create", Some(SystemHookType::Key)),
296 ("key_destroy", Some(SystemHookType::Key)),
297 ("project_create", Some(SystemHookType::Project)),
298 ("project_destroy", Some(SystemHookType::Project)),
299 ("project_rename", Some(SystemHookType::Project)),
300 ("project_transfer", Some(SystemHookType::Project)),
301 ("project_update", Some(SystemHookType::Project)),
302 ("push", Some(SystemHookType::Push)),
303 ("repository_update", Some(SystemHookType::RepositoryUpdate)),
304 ("tag_push", Some(SystemHookType::Push)),
305 (
306 "user_access_request_revoked_for_group",
307 Some(SystemHookType::GroupMember),
308 ),
309 (
310 "user_access_request_revoked_for_project",
311 Some(SystemHookType::ProjectMember),
312 ),
313 (
314 "user_access_request_to_group",
315 Some(SystemHookType::GroupMember),
316 ),
317 (
318 "user_access_request_to_project",
319 Some(SystemHookType::ProjectMember),
320 ),
321 ("user_add_to_group", Some(SystemHookType::GroupMember)),
322 ("user_add_to_team", Some(SystemHookType::ProjectMember)),
323 ("user_create", Some(SystemHookType::User)),
324 ("user_destroy", Some(SystemHookType::User)),
325 ("user_failed_login", Some(SystemHookType::UserFailedLogin)),
326 ("user_remove_from_group", Some(SystemHookType::GroupMember)),
327 ("user_remove_from_team", Some(SystemHookType::ProjectMember)),
328 ("user_rename", Some(SystemHookType::User)),
329 ("user_update_for_group", Some(SystemHookType::GroupMember)),
330 ("user_update_for_team", Some(SystemHookType::ProjectMember)),
331 ("NOT_A_SYSTEM_HOOK", None),
332 ];
333 const WEB_ITEMS: &[(&str, Option<WebHookType>)] = &[
334 ("access_token", Some(WebHookType::AccessToken)),
335 ("build", Some(WebHookType::Build)),
336 ("deployment", Some(WebHookType::Deployment)),
337 ("emoji", Some(WebHookType::Emoji)),
338 ("feature_flag", Some(WebHookType::FeatureFlag)),
339 ("issue", Some(WebHookType::Issue)),
340 ("merge_request", Some(WebHookType::MergeRequest)),
341 ("note", Some(WebHookType::Note)),
342 ("pipeline", Some(WebHookType::Pipeline)),
343 ("push", Some(WebHookType::Push)),
344 ("release", Some(WebHookType::Release)),
345 ("tag_push", Some(WebHookType::Push)),
346 ("wiki_page", Some(WebHookType::WikiPage)),
347 ("work_item", Some(WebHookType::Issue)),
348 ("NOT_A_HOOK", None),
349 ];
350
351 #[test]
352 fn test_hook_kind_classification() {
353 for (n, k) in SYSTEM_ITEMS {
354 assert_eq!(
355 HookKind::kind_of(&json!({
356 "event_name": n,
357 })),
358 Some(HookKind::System(*k)),
359 );
360 }
361
362 for (n, k) in WEB_ITEMS {
363 assert_eq!(
364 HookKind::kind_of(&json!({
365 "object_kind": n,
366 })),
367 Some(HookKind::Web(*k)),
368 );
369 }
370
371 assert!(HookKind::kind_of(&json!({})).is_none());
372 }
373
374 #[test]
375 fn test_system_hook_classification() {
376 for (n, k) in SYSTEM_ITEMS {
377 assert_eq!(
378 SystemHookType::type_of(&json!({
379 "event_name": n,
380 })),
381 *k,
382 );
383 }
384 }
385
386 #[test]
387 fn test_web_hook_classification() {
388 for (n, k) in WEB_ITEMS {
389 assert_eq!(
390 WebHookType::type_of(&json!({
391 "object_kind": n,
392 })),
393 *k,
394 );
395 }
396 }
397}