1use crate::io::api::{
4 require_non_empty_secret, Configuration, DatabasePersistence, EmptyField, Endpoint, Fallback, Identifier, Param, Params, RemoteResource,
5 RepositoryFileMetadata, ResponseContent, TreeEntry, ValueValidator, INCLUDED_ENDPOINTS,
6};
7use crate::io::config::{RunnerDetails, RunnerStatus, RunnerType};
8use crate::io::database::schema::{ProgrammingLanguageRow, Table};
9use crate::io::database::{Database, Operations};
10use crate::io::{first_env_var, with_progress, ApiResult, ProgressType};
11use crate::prelude::var;
12use crate::prelude::HashMap;
13use crate::schema::validate::is_iso_date_or_rfc3339_timestamp;
14use crate::util::constants::env::GITLAB_TOKEN_VARIABLE_NAMES;
15use crate::util::{Label, Searchable, SemanticVersion};
16use async_trait::async_trait;
17use bon::Builder;
18use color_eyre::eyre::{self, eyre};
19use core::fmt;
20use data_encoding::BASE64;
21use derive_more::Display;
22use futures::future::BoxFuture;
23use futures::FutureExt;
24use serde::{Deserialize, Serialize};
25use serde_with::skip_serializing_none;
26use strum::EnumIs;
27use tracing::debug;
28use validator::Validate;
29
30pub mod bot;
31pub mod database;
32#[cfg(feature = "analysis")]
33pub mod intake;
34#[cfg(feature = "analysis")]
35pub mod review;
36pub mod service;
37pub mod webhook;
38
39pub use service::*;
40pub use webhook::{HookActor, HookPayload, MergeRequestAction, WebhookDelivery, WebhookOperation, WebhookOperationHandler};
41
42pub type EventsResponse = Vec<EventDetails>;
44pub type GitLabIdentity = Identifier<u64>;
46pub type GroupsResponse = Vec<GroupDetails>;
48pub type MergeRequestDiffsResponse = Vec<MergeRequestDiff>;
50pub type NotesResponse = Vec<Note>;
52pub type ProgrammingLanguageEntries = Vec<ProgrammingLanguageMetadata>;
54pub type ProgrammingLanguageUseEntries = Vec<ProgrammingLanguageUseMetadata>;
56pub type ProjectWebhooksResponse = Vec<ProjectWebhook>;
58pub type RunnersResponse = Vec<RunnerMetadata>;
60pub trait Create {
62 fn create(_options: &Options) -> ApiResult<Self>
64 where
65 Self: Sized,
66 {
67 Err(eyre!("GitLab struct creation is not implemented"))
68 }
69 fn register(self) -> ApiResult<Self>
71 where
72 Self: Sized,
73 {
74 Err(eyre!("GitLab struct registration is not implemented"))
75 }
76}
77#[derive(Clone, Debug, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum AccessLevel {
81 NotProtected,
83 RefProtected,
85}
86#[derive(Clone, Copy, Debug, Display, Eq, PartialEq)]
88pub enum CommitStatusState {
89 #[display("running")]
91 Running,
92 #[display("success")]
94 Success,
95 #[display("failed")]
97 Failed,
98}
99#[derive(Clone, Debug, Display, Serialize, Deserialize)]
103pub enum Emoji {
104 #[display(":seedling:")]
106 Seedling,
107}
108#[derive(Clone, Debug, EnumIs, Serialize, Deserialize)]
112pub enum EventAction {
113 #[serde(rename = "approved")]
115 Approved,
116 #[serde(rename = "closed")]
118 Closed,
119 #[serde(rename = "commented")]
121 Commented,
122 #[serde(rename = "commented on")]
124 CommentedOn,
125 #[serde(rename = "created")]
127 Created,
128 #[serde(rename = "destroyed")]
130 Destroyed,
131 #[serde(rename = "expired")]
133 Expired,
134 #[serde(rename = "joined")]
136 Joined,
137 #[serde(rename = "left")]
139 Left,
140 #[serde(rename = "merged")]
142 Merged,
143 #[serde(rename = "pushed")]
145 Pushed,
146 #[serde(rename = "pushed to")]
148 PushedTo,
149 #[serde(rename = "reopened")]
151 Reopened,
152 #[serde(rename = "updated")]
154 Updated,
155 #[serde(rename = "deleted")]
157 Deleted,
158 #[serde(rename = "accepted")]
160 Accepted,
161 #[serde(other)]
163 Unknown,
164}
165#[derive(Clone, Debug, Serialize, Deserialize)]
169#[serde(rename_all = "snake_case")]
170pub enum EventFilterKey {
171 Action,
175 TargetType,
177 After,
179 Before,
181 Sort,
183}
184#[derive(Clone, Debug, Default, Serialize, Deserialize)]
186#[serde(rename_all = "snake_case")]
187pub enum GroupVisibility {
188 #[default]
190 Public,
191 Internal,
193 Private,
195}
196#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
198#[serde(untagged)]
199pub enum Note {
200 WorkItem {
202 #[serde(rename = "id")]
204 identifier: u64,
205 body: String,
207 author: WorkItemUser,
209 #[serde(default)]
211 system: bool,
212 #[serde(default)]
214 confidential: bool,
215 #[serde(default)]
217 internal: bool,
218 },
219 MergeRequest {
221 #[serde(rename = "id")]
223 identifier: u64,
224 body: String,
226 author: Option<GitLabIdentity>,
228 },
229}
230#[derive(Clone, Debug, Serialize, Deserialize)]
260#[serde(rename_all = "snake_case")]
261pub enum OrderByValue {
262 CreatedAt,
264 FullName,
266 #[serde(rename = "id")]
268 Identifier,
269 LabelPriority,
271 LastActivityAt,
273 MilestoneDue,
275 Name,
277 Path,
279 Popularity,
281 DueDate,
283 Priority,
285 RelativePosition,
287 Similarity,
289 Title,
291 UpdatedAt,
293 Weight,
295}
296#[derive(Clone, Debug, Serialize, Deserialize)]
300#[serde(rename_all = "snake_case")]
301pub enum PaginationKey {
302 OrderBy,
304 Page,
306 Pagination,
308 PerPage,
310 Sort,
312}
313#[derive(Clone, Debug, Default, Serialize, Deserialize)]
315#[serde(rename_all = "snake_case")]
316pub enum SortValue {
317 #[default]
319 #[serde(rename = "desc")]
320 Descending,
321 #[serde(rename = "asc")]
323 Ascending,
324}
325#[derive(Clone, Debug, EnumIs, Serialize, Deserialize)]
327#[serde(rename_all = "PascalCase")]
328pub enum TargetType {
329 Epic,
331 Issue,
333 MergeRequest,
335 Milestone,
337 Note,
339 Project,
341 Snippet,
343 User,
345 #[serde(other)]
347 Unknown,
348}
349#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
351pub struct CommitStatus {
352 pub name: String,
354 pub sha: String,
356 pub status: String,
358 pub description: Option<String>,
360 pub target_url: Option<String>,
362}
363#[derive(Clone, Debug, Serialize, Deserialize)]
368pub struct ErrorResponse {
369 message: Option<serde_json::Value>,
371 error: Option<String>,
373 error_description: Option<String>,
375}
376#[skip_serializing_none]
380#[derive(Clone, Debug, Serialize, Deserialize)]
381pub struct EventDetails {
382 #[serde(rename = "id")]
384 pub identifier: u64,
385 pub project_id: u64,
387 pub action_name: EventAction,
389 pub target_id: Option<u64>,
391 pub target_iid: Option<u64>,
393 pub target_type: TargetType,
395 pub author_id: u64,
397 pub target_title: String,
399 pub created_at: String,
401 pub author: UserMetadata,
403 pub imported: bool,
405 pub imported_from: String,
407 pub push_data: Option<PushData>,
409 pub author_username: String,
411 pub note: Option<NoteMetadata>,
413}
414#[skip_serializing_none]
416#[derive(Clone, Debug, Serialize, Deserialize)]
417pub struct GroupDetails {
418 #[serde(rename = "id")]
420 pub identifier: u64,
421 #[serde(rename = "web_url")]
423 pub url: String,
424 pub name: String,
426 pub path: Option<String>,
428 pub description: Option<String>,
430 #[serde(default)]
432 pub emails_disabled: bool,
433 #[serde(default)]
435 pub emails_enabled: bool,
436 #[serde(default)]
438 pub show_diff_preview_in_email: bool,
439 pub visibility: Option<GroupVisibility>,
441 #[serde(default)]
443 pub share_with_group_lock: bool,
444 #[serde(default)]
446 pub require_two_factor_authentication: bool,
447 #[serde(default)]
449 pub lfs_enabled: bool,
450 #[serde(default)]
452 pub archived: bool,
453 #[serde(default)]
455 pub duo_features_enabled: bool,
456 #[serde(default)]
458 pub lock_duo_features_enabled: bool,
459 #[serde(default)]
461 pub auto_duo_code_review_enabled: bool,
462 #[serde(default)]
464 pub math_rendering_limits_enabled: bool,
465 #[serde(default)]
467 pub lock_math_rendering_limits_enabled: bool,
468 #[serde(default)]
470 pub request_access_enabled: bool,
471 pub two_factor_grace_period: Option<u64>,
473 pub project_creation_level: Option<String>,
475 pub auto_devops_enabled: Option<bool>,
477 pub subgroup_creation_level: Option<String>,
479 pub mentions_disabled: Option<bool>,
481 pub default_branch: Option<String>,
483 pub default_branch_protection: Option<u64>,
485 pub default_branch_protection_defaults: Option<RunnerGroupBranchProtectionDefaults>,
487 #[serde(rename = "avatar_url")]
489 pub avatar_url: Option<String>,
490 pub full_name: Option<String>,
492 pub full_path: Option<String>,
494 pub created_at: Option<String>,
496 pub parent_id: Option<u64>,
498 pub organization_id: Option<u64>,
500 pub shared_runners_setting: Option<String>,
502 pub max_artifacts_size: Option<u64>,
504 pub marked_for_deletion_on: Option<String>,
506 #[serde(rename = "ldap_cn")]
508 pub ldap_common_name: Option<String>,
509 pub ldap_access: Option<String>,
511 pub file_template_project_id: Option<u64>,
513 pub wiki_access_level: Option<String>,
515 pub duo_core_features_enabled: Option<bool>,
517}
518#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
520pub struct InstanceVersion {
521 pub version: String,
523 pub revision: Option<String>,
525}
526#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
528pub struct MergeRequestDetails {
529 pub iid: u64,
531 pub project_id: u64,
533 pub source_project_id: Option<u64>,
535 pub sha: String,
537 pub title: String,
539 #[serde(default)]
541 pub description: String,
542 pub web_url: String,
544}
545#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
547pub struct MergeRequestDiff {
548 pub old_path: String,
550 pub new_path: String,
552 #[serde(default)]
554 pub new_file: bool,
555 #[serde(default)]
557 pub renamed_file: bool,
558 #[serde(default)]
560 pub deleted_file: bool,
561 #[serde(default)]
563 pub generated_file: bool,
564 #[serde(default)]
566 pub collapsed: bool,
567 #[serde(default)]
569 pub too_large: bool,
570}
571#[skip_serializing_none]
605#[derive(Clone, Debug, Serialize, Deserialize)]
606pub struct NoteMetadata {
607 #[serde(rename = "id")]
609 pub identifier: u64,
610 #[serde(rename = "type")]
612 pub note_type: Option<String>,
613 pub body: String,
615 pub author: UserMetadata,
617 pub created_at: String,
619 pub updated_at: String,
621 pub system: bool,
623 pub noteable_id: Option<u64>,
625 pub noteable_iid: Option<u64>,
627 pub noteable_type: String,
629 pub project_id: u64,
631 pub resolvable: bool,
633 pub confidential: bool,
635 pub internal: bool,
637 pub imported: bool,
639 pub imported_from: String,
641 pub commands_changes: serde_json::Value,
643}
644#[derive(Builder, Clone, Debug)]
646#[builder(start_fn = with_token, on(String, into))]
647pub struct Options {
648 #[builder(start_fn)]
650 pub token: String,
651 pub body: Option<String>,
653 #[builder(default = String::from("gitlab.com"))]
655 pub domain: String,
656 pub identifier: Option<String>,
658 pub path: Option<String>,
660 #[builder(default = 1)]
662 pub page: u32,
663 pub internal_identifier: Option<String>,
665 pub sha: Option<String>,
667 #[builder(default = RunnerMetadata::default())]
669 pub runner_metadata: RunnerMetadata,
670 #[builder(default = vec![])]
672 pub custom_params: Vec<Param>,
673}
674#[skip_serializing_none]
676#[derive(Clone, Debug, Default, Serialize, Deserialize)]
677pub struct ProgrammingLanguageDetails {
678 pub language_id: Option<u64>,
680 #[serde(rename = "type")]
682 pub language_type: Option<String>,
683 pub color: Option<String>,
685 pub group: Option<String>,
687}
688#[skip_serializing_none]
690#[derive(Clone, Debug, Default, Serialize, Deserialize)]
691pub struct ProgrammingLanguageMetadata {
692 pub name: String,
694 pub language_id: Option<u64>,
696 pub language_type: Option<String>,
698 pub color: Option<String>,
700 pub group: Option<String>,
702}
703#[derive(Clone, Debug, Default, Serialize)]
705pub struct ProgrammingLanguagesResponse {
706 pub languages: ProgrammingLanguageEntries,
708}
709#[derive(Clone, Debug, Default, Serialize, Deserialize)]
711pub struct ProgrammingLanguageUseMetadata {
712 pub name: String,
714 pub percentage: f64,
716}
717#[derive(Clone, Debug, Default, Serialize)]
719pub struct ProgrammingLanguageUseResponse {
720 pub languages: ProgrammingLanguageUseEntries,
722}
723#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
725pub struct ProjectMember {
726 #[serde(rename = "id")]
728 pub identifier: u64,
729 pub access_level: u64,
731}
732#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
734pub struct ProjectWebhook {
735 pub id: u64,
737 pub url: String,
739 #[serde(default)]
741 pub merge_requests_events: bool,
742 #[serde(default)]
744 pub note_events: bool,
745 #[serde(default)]
747 pub enable_ssl_verification: bool,
748 #[serde(default)]
750 pub token_present: bool,
751 #[serde(default)]
753 pub signing_token_present: bool,
754}
755#[derive(Serialize)]
756struct ProjectWebhookRequest<'a> {
757 url: &'a str,
758 name: &'static str,
759 description: &'static str,
760 merge_requests_events: bool,
761 note_events: bool,
762 enable_ssl_verification: bool,
763 #[serde(skip_serializing_if = "Option::is_none")]
764 token: Option<&'a str>,
765 #[serde(skip_serializing_if = "Option::is_none")]
766 signing_token: Option<&'a str>,
767}
768#[skip_serializing_none]
770#[derive(Clone, Debug, Serialize, Deserialize)]
771pub struct PushData {
772 pub commit_count: u64,
774 pub action: EventAction,
776 pub ref_type: String,
778 pub commit_from: Option<String>,
780 pub commit_to: Option<String>,
782 #[serde(rename = "ref")]
784 pub ref_name: String,
785 pub commit_title: Option<String>,
787 pub ref_count: Option<u64>,
789}
790#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
792pub struct RepositoryFile {
793 pub file_path: String,
795 pub size: u64,
797 pub encoding: String,
799 pub content: String,
801}
802#[skip_serializing_none]
812#[derive(Clone, Debug, Serialize, Deserialize)]
813pub struct RunnerCreationResponse {
814 #[serde(default)]
816 #[serde(rename = "id")]
817 pub identifier: u64,
818 pub token: Option<String>,
820 pub token_expires_at: Option<String>,
822}
823#[skip_serializing_none]
825#[derive(Clone, Debug, Serialize, Deserialize)]
826pub struct RunnerGroupAccessLevel {
827 pub access_level: u64,
829}
830#[skip_serializing_none]
832#[derive(Clone, Debug, Serialize, Deserialize)]
833pub struct RunnerGroupBranchProtectionDefaults {
834 pub allowed_to_push: Vec<RunnerGroupAccessLevel>,
836 pub allow_force_push: bool,
838 pub allowed_to_merge: Vec<RunnerGroupAccessLevel>,
840}
841#[skip_serializing_none]
879#[derive(Clone, Debug, Builder, Serialize, Deserialize)]
880#[builder(start_fn = init, on(String, into), on(&str, into))]
881pub struct RunnerMetadata {
882 #[serde(rename = "id")]
884 pub identifier: Option<u64>,
885 #[builder(default)]
887 #[serde(default)]
888 pub active: bool,
889 #[serde(default)]
893 pub online: Option<bool>,
894 #[builder(default)]
896 #[serde(default)]
897 pub paused: bool,
898 #[builder(default)]
900 #[serde(default)]
901 pub run_untagged: bool,
902 #[builder(default)]
904 #[serde(default, rename = "is_shared")]
905 pub shared: bool,
906 pub architecture: Option<String>,
908 pub description: Option<String>,
910 pub ip_address: Option<String>,
912 #[builder(with = |value: &str| RunnerType::from(value))]
914 #[builder(default = RunnerType::Project)]
915 pub runner_type: RunnerType,
916 pub created_by: Option<UserMetadata>,
918 pub created_at: Option<String>,
920 pub contacted_at: Option<String>,
922 pub maintenance_note: Option<String>,
924 pub name: Option<String>,
926 pub status: Option<RunnerStatus>,
928 pub job_execution_status: Option<String>,
930 pub platform: Option<String>,
932 pub projects: Option<Vec<RunnerScope>>,
934 pub groups: Option<Vec<RunnerScope>>,
936 pub revision: Option<String>,
938 #[builder(with = |values: &[&str]| values.iter().map(|s| s.to_string()).collect::<Vec<String>>())]
940 #[serde(rename = "tag_list")]
941 pub tags: Option<Vec<String>>,
942 pub version: Option<String>,
944 pub access_level: Option<AccessLevel>,
946 pub maximum_timeout: Option<u64>,
948}
949#[skip_serializing_none]
951#[derive(Clone, Debug, Serialize, Deserialize)]
952pub struct RunnerScope {
953 #[serde(rename = "id")]
955 pub identifier: u64,
956 pub name: String,
958 pub path: Option<String>,
960 pub name_with_namespace: Option<String>,
962 pub path_with_namespace: Option<String>,
964 #[serde(rename = "web_url")]
966 pub url: Option<String>,
967}
968#[derive(Clone, Debug, Default, Serialize)]
969pub struct TreeResponse {
971 pub paths: Vec<String>,
973 pub(crate) entry_count: usize,
974 #[serde(skip_serializing)]
976 pub(crate) error: Option<ErrorResponse>,
977}
978#[skip_serializing_none]
993#[derive(Clone, Debug, Serialize, Deserialize)]
994pub struct UserMetadata {
995 pub avatar_url: String,
997 #[serde(rename = "id")]
999 pub identifier: u64,
1000 pub locked: bool,
1002 pub name: String,
1004 #[serde(rename = "public_email")]
1006 pub email: Option<String>,
1007 pub state: String,
1010 pub username: String,
1012 #[serde(rename = "web_url")]
1014 pub url: String,
1015}
1016#[derive(Clone, Debug, Default, Eq, PartialEq, Validate)]
1018pub struct WebhookOptions {
1019 #[validate(url)]
1020 public_url: Option<String>,
1021 #[validate(required)]
1022 webhook_token: Option<String>,
1023 #[validate(required)]
1024 signing_token: Option<String>,
1025}
1026#[derive(Clone, Debug, Eq, PartialEq)]
1028pub struct WebhookRegistration {
1029 pub hook: ProjectWebhook,
1031 pub created: bool,
1033}
1034#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1036pub struct WorkItem {
1037 #[serde(rename = "id")]
1039 pub identifier: u64,
1040 pub iid: u64,
1042 pub project_id: u64,
1044 pub title: String,
1046 #[serde(default)]
1048 pub description: String,
1049 pub author: WorkItemUser,
1051 #[serde(default)]
1053 pub issue_type: String,
1054 #[serde(default)]
1056 pub confidential: bool,
1057}
1058#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1060pub struct WorkItemUser {
1061 #[serde(rename = "id")]
1063 pub identifier: u64,
1064 pub username: String,
1066 #[serde(default)]
1068 pub bot: bool,
1069}
1070impl From<bool> for CommitStatusState {
1071 fn from(success: bool) -> Self {
1072 if success {
1073 Self::Success
1074 } else {
1075 Self::Failed
1076 }
1077 }
1078}
1079impl ErrorResponse {
1080 fn is_terminal_pagination_message(message: &str) -> bool {
1081 let message = message.to_lowercase();
1082 let invalid_page =
1083 message.contains("page") && (message.contains("invalid") || message.contains("out of range") || message.contains("not found"));
1084 let forbidden_page = message.contains("403") && message.contains("forbidden");
1085 invalid_page || forbidden_page
1086 }
1087 fn is_terminal_pagination_error(&self) -> bool {
1088 Self::is_terminal_pagination_message(&self.message())
1089 }
1090 fn message(&self) -> String {
1091 let message = self
1092 .message
1093 .as_ref()
1094 .and_then(|value| serde_json::to_string(value).ok())
1095 .unwrap_or_default();
1096 let error = self.error.clone().unwrap_or_default();
1097 let description = self.error_description.clone().unwrap_or_default();
1098 [message, error, description]
1099 .into_iter()
1100 .filter(|value| !value.trim().is_empty())
1101 .collect::<Vec<_>>()
1102 .join(" ")
1103 }
1104}
1105impl fmt::Display for EventAction {
1106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1107 let s = match self {
1108 | EventAction::Approved => "approved",
1109 | EventAction::Closed => "closed",
1110 | EventAction::Commented => "commented",
1111 | EventAction::CommentedOn => "commented on",
1112 | EventAction::Created => "created",
1113 | EventAction::Destroyed => "destroyed",
1114 | EventAction::Expired => "expired",
1115 | EventAction::Joined => "joined",
1116 | EventAction::Left => "left",
1117 | EventAction::Merged => "merged",
1118 | EventAction::Pushed => "pushed",
1119 | EventAction::PushedTo => "pushed to",
1120 | EventAction::Reopened => "reopened",
1121 | EventAction::Updated => "updated",
1122 | EventAction::Deleted => "deleted",
1123 | EventAction::Accepted => "accepted",
1124 | EventAction::Unknown => "unknown",
1125 };
1126 write!(f, "{}", s)
1127 }
1128}
1129impl core::str::FromStr for EventAction {
1130 type Err = String;
1131
1132 fn from_str(value: &str) -> Result<Self, Self::Err> {
1133 match value {
1134 | "approved" => Ok(EventAction::Approved),
1135 | "closed" => Ok(EventAction::Closed),
1136 | "commented" => Ok(EventAction::Commented),
1137 | "commented on" => Ok(EventAction::CommentedOn),
1138 | "created" => Ok(EventAction::Created),
1139 | "destroyed" => Ok(EventAction::Destroyed),
1140 | "expired" => Ok(EventAction::Expired),
1141 | "joined" => Ok(EventAction::Joined),
1142 | "left" => Ok(EventAction::Left),
1143 | "merged" => Ok(EventAction::Merged),
1144 | "pushed" => Ok(EventAction::Pushed),
1145 | "pushed to" => Ok(EventAction::PushedTo),
1146 | "reopened" => Ok(EventAction::Reopened),
1147 | "updated" => Ok(EventAction::Updated),
1148 | "deleted" => Ok(EventAction::Deleted),
1149 | "accepted" => Ok(EventAction::Accepted),
1150 | _ => Err(format!("Invalid GitLab event action value: {value}")),
1151 }
1152 }
1153}
1154impl TryFrom<&str> for EventAction {
1155 type Error = String;
1156
1157 fn try_from(value: &str) -> Result<Self, Self::Error> {
1158 value.parse()
1159 }
1160}
1161impl fmt::Display for EventFilterKey {
1162 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1163 let s = match self {
1164 | EventFilterKey::Action => "action",
1165 | EventFilterKey::TargetType => "target_type",
1166 | EventFilterKey::After => "after",
1167 | EventFilterKey::Before => "before",
1168 | EventFilterKey::Sort => "sort",
1169 };
1170 write!(f, "{}", s)
1171 }
1172}
1173impl TryFrom<&str> for EventFilterKey {
1174 type Error = String;
1175
1176 fn try_from(value: &str) -> Result<Self, Self::Error> {
1177 match value {
1178 | "action" => Ok(EventFilterKey::Action),
1179 | "target_type" => Ok(EventFilterKey::TargetType),
1180 | "after" => Ok(EventFilterKey::After),
1181 | "before" => Ok(EventFilterKey::Before),
1182 | "sort" => Ok(EventFilterKey::Sort),
1183 | _ => Err(format!("Invalid EventFilterKey: {}", value)),
1184 }
1185 }
1186}
1187impl ValueValidator for EventFilterKey {
1188 fn is_valid(&self, value: &str) -> bool {
1190 match self {
1191 | EventFilterKey::Action => EventAction::try_from(value).is_ok(),
1192 | EventFilterKey::TargetType => TargetType::try_from(value).is_ok(),
1193 | EventFilterKey::After | EventFilterKey::Before => is_iso_date_or_rfc3339_timestamp(value),
1194 | EventFilterKey::Sort => SortValue::try_from(value).is_ok(),
1195 }
1196 }
1197}
1198impl InstanceVersion {
1199 pub fn supports_signing_tokens(&self) -> bool {
1201 SemanticVersion::from(self.version.as_str()).major >= 19
1202 }
1203}
1204impl From<SemanticVersion> for InstanceVersion {
1205 fn from(version: SemanticVersion) -> Self {
1206 Self {
1207 version: version.to_string(),
1208 revision: None,
1209 }
1210 }
1211}
1212impl Note {
1213 pub fn identifier(&self) -> u64 {
1215 match self {
1216 | Self::WorkItem { identifier, .. } | Self::MergeRequest { identifier, .. } => *identifier,
1217 }
1218 }
1219 pub fn body(&self) -> &str {
1221 match self {
1222 | Self::WorkItem { body, .. } | Self::MergeRequest { body, .. } => body,
1223 }
1224 }
1225 pub fn author_id(&self) -> Option<u64> {
1227 match self {
1228 | Self::WorkItem { author, .. } => Some(author.identifier),
1229 | Self::MergeRequest { author, .. } => author.as_ref().map(|author| author.identifier),
1230 }
1231 }
1232}
1233impl Options {
1234 pub fn with_internal_identifier(self, value: impl Into<String>) -> Self {
1236 Self {
1237 internal_identifier: Some(value.into()),
1238 ..self
1239 }
1240 }
1241 pub fn with_sha(self, value: impl Into<String>) -> Self {
1243 Self {
1244 sha: Some(value.into()),
1245 ..self
1246 }
1247 }
1248 pub fn internal_identifier(&self) -> ApiResult<&str> {
1250 self.internal_identifier
1251 .as_deref()
1252 .filter(|value| !value.trim().is_empty())
1253 .ok_or_else(|| eyre!("GitLab internal resource identifier is required"))
1254 }
1255 pub fn sha(&self) -> ApiResult<&str> {
1257 self.sha
1258 .as_deref()
1259 .filter(|value| !value.trim().is_empty())
1260 .ok_or_else(|| eyre!("GitLab commit SHA is required"))
1261 }
1262 pub fn path(&self) -> ApiResult<&str> {
1264 self.path
1265 .as_deref()
1266 .filter(|value| !value.trim().is_empty())
1267 .ok_or_else(|| eyre!("GitLab repository path is required"))
1268 }
1269 pub fn with_page(self, value: u32) -> Self {
1271 Self { page: value, ..self }
1272 }
1273 pub fn with_path(self, value: impl Into<String>) -> Self {
1275 Self {
1276 path: Some(value.into()),
1277 ..self
1278 }
1279 }
1280 pub fn with_runner(self, metadata: RunnerMetadata) -> Self {
1282 Self {
1283 runner_metadata: metadata,
1284 ..self
1285 }
1286 }
1287}
1288impl Configuration for Options {
1289 fn from_env() -> Self {
1297 if let Err(why) = dotenvy::from_filename(".env") {
1298 debug!("=> {} Load .env — {why}", Label::skip());
1299 }
1300 Self {
1301 token: first_env_var(&GITLAB_TOKEN_VARIABLE_NAMES).unwrap_or_default(),
1302 identifier: var("CI_PROJECT_ID").ok(),
1303 internal_identifier: var("CI_MERGE_REQUEST_IID").ok(),
1304 sha: var("CI_COMMIT_SHA").ok(),
1305 domain: var("CI_SERVER_HOST").unwrap_or_else(|_| "gitlab.com".to_string()),
1306 body: None,
1307 path: None,
1308 page: 1,
1309 runner_metadata: RunnerMetadata::default(),
1310 custom_params: vec![],
1311 }
1312 }
1313 fn with_body(self, value: impl Into<String>) -> Self {
1315 Self {
1316 body: Some(value.into()),
1317 ..self
1318 }
1319 }
1320 fn with_domain(self, value: impl Into<String>) -> Self {
1322 Self {
1323 domain: value.into(),
1324 ..self
1325 }
1326 }
1327 fn with_identifier(self, value: impl Into<String>) -> Self {
1329 Self {
1330 identifier: Some(value.into()),
1331 ..self
1332 }
1333 }
1334 fn token(&self) -> &str {
1336 &self.token
1337 }
1338 fn domain(&self) -> &str {
1340 &self.domain
1341 }
1342 fn identifier(&self) -> Option<&str> {
1344 self.identifier.as_deref()
1345 }
1346 fn with_params(self, params: Vec<Param>) -> Self {
1348 Self {
1349 custom_params: params,
1350 ..self
1351 }
1352 }
1353 fn params(&self) -> &[Param] {
1355 &self.custom_params
1356 }
1357}
1358impl Default for Options {
1359 fn default() -> Self {
1360 Self::from_env()
1361 }
1362}
1363impl TryFrom<&str> for OrderByValue {
1364 type Error = String;
1365
1366 fn try_from(value: &str) -> eyre::Result<Self, Self::Error> {
1367 match value {
1368 | "created_at" => Ok(OrderByValue::CreatedAt),
1369 | "due_date" => Ok(OrderByValue::DueDate),
1370 | "full_name" => Ok(OrderByValue::FullName),
1371 | "id" => Ok(OrderByValue::Identifier),
1372 | "label_priority" => Ok(OrderByValue::LabelPriority),
1373 | "last_activity_at" => Ok(OrderByValue::LastActivityAt),
1374 | "milestone_due" => Ok(OrderByValue::MilestoneDue),
1375 | "name" => Ok(OrderByValue::Name),
1376 | "path" => Ok(OrderByValue::Path),
1377 | "popularity" => Ok(OrderByValue::Popularity),
1378 | "priority" => Ok(OrderByValue::Priority),
1379 | "relative_position" => Ok(OrderByValue::RelativePosition),
1380 | "similarity" => Ok(OrderByValue::Similarity),
1381 | "title" => Ok(OrderByValue::Title),
1382 | "updated_at" => Ok(OrderByValue::UpdatedAt),
1383 | "weight" => Ok(OrderByValue::Weight),
1384 | _ => Err(format!("Invalid GitLab order_by value: {value}")),
1385 }
1386 }
1387}
1388impl fmt::Display for PaginationKey {
1389 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1390 let s = match self {
1391 | PaginationKey::OrderBy => "order_by",
1392 | PaginationKey::Page => "page",
1393 | PaginationKey::Pagination => "pagination",
1394 | PaginationKey::PerPage => "per_page",
1395 | PaginationKey::Sort => "sort",
1396 };
1397 write!(f, "{}", s)
1398 }
1399}
1400impl TryFrom<&str> for PaginationKey {
1401 type Error = String;
1402
1403 fn try_from(value: &str) -> eyre::Result<Self, Self::Error> {
1404 match value {
1405 | "order_by" => Ok(PaginationKey::OrderBy),
1406 | "page" => Ok(PaginationKey::Page),
1407 | "pagination" => Ok(PaginationKey::Pagination),
1408 | "per_page" => Ok(PaginationKey::PerPage),
1409 | "sort" => Ok(PaginationKey::Sort),
1410 | _ => Err(format!("Invalid GitLab pagination field: {value}")),
1411 }
1412 }
1413}
1414impl ValueValidator for PaginationKey {
1415 fn is_valid(&self, value: &str) -> bool {
1417 match self {
1418 | PaginationKey::OrderBy => OrderByValue::try_from(value).is_ok(),
1419 | PaginationKey::Page | PaginationKey::PerPage => value.parse::<u64>().is_ok(),
1420 | PaginationKey::Sort => SortValue::try_from(value).is_ok(),
1421 | _ => true,
1422 }
1423 }
1424}
1425impl From<ProgrammingLanguageMetadata> for ProgrammingLanguageRow {
1426 fn from(value: ProgrammingLanguageMetadata) -> Self {
1427 let ProgrammingLanguageMetadata {
1428 name,
1429 language_id,
1430 language_type,
1431 color,
1432 group,
1433 } = value;
1434 ProgrammingLanguageRow::init()
1435 .name(name)
1436 .maybe_language_id(language_id.and_then(|value| i64::try_from(value).ok()))
1437 .maybe_language_type(language_type)
1438 .maybe_color(color)
1439 .maybe_group_name(group)
1440 .build()
1441 }
1442}
1443impl ProgrammingLanguagesResponse {
1444 pub fn parse(data: HashMap<String, ProgrammingLanguageDetails>) -> Self {
1446 let languages = data
1447 .into_iter()
1448 .filter_map(|(name, details)| {
1449 details
1450 .language_type
1451 .as_ref()
1452 .map(|kind| kind.eq_ignore_ascii_case("programming"))
1453 .filter(|is_programming| *is_programming)
1454 .map(|_| ProgrammingLanguageMetadata {
1455 name,
1456 language_id: details.language_id,
1457 language_type: details.language_type,
1458 color: details.color,
1459 group: details.group,
1460 })
1461 })
1462 .collect();
1463 Self { languages }
1464 }
1465}
1466#[async_trait]
1467impl DatabasePersistence for ProgrammingLanguagesResponse {
1468 async fn persist(self, database: Database<Table>) -> ApiResult<usize> {
1470 let Self { languages } = self;
1471 let message: fn(&ProgrammingLanguageMetadata) -> String = |item| format!("Saving \"{}\" language metadata", item.name);
1472 let operation = |item| async { database.insert(ProgrammingLanguageRow::from(item)) };
1473 let finish = |count| format!("{}Saved metadata for {count} programming languages", Label::CHECKMARK);
1474 with_progress(languages, message, operation, finish, None, ProgressType::Bar)
1475 .await
1476 .map(|counts| counts.into_iter().sum())
1477 .map_err(eyre::Report::msg)
1478 }
1479}
1480impl<'de> serde::Deserialize<'de> for ProgrammingLanguagesResponse {
1481 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1482 HashMap::<String, ProgrammingLanguageDetails>::deserialize(deserializer).map(Self::parse)
1483 }
1484}
1485impl ProgrammingLanguageUseResponse {
1486 pub fn parse(data: HashMap<String, f64>) -> Self {
1488 let mut languages = data
1489 .into_iter()
1490 .map(|(name, percentage)| ProgrammingLanguageUseMetadata { name, percentage })
1491 .collect::<ProgrammingLanguageUseEntries>();
1492 languages.sort_by(|a, b| a.name.cmp(&b.name));
1493 Self { languages }
1494 }
1495 pub fn entries(&self) -> Vec<(String, f64)> {
1497 let mut entries = self
1498 .languages
1499 .iter()
1500 .map(|ProgrammingLanguageUseMetadata { name, percentage }| (name.clone(), *percentage))
1501 .collect::<Vec<_>>();
1502 entries.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(core::cmp::Ordering::Equal));
1503 entries
1504 }
1505 pub fn names(&self) -> Vec<String> {
1507 self.entries().into_iter().map(|(name, _)| name).collect()
1508 }
1509}
1510impl<'de> serde::Deserialize<'de> for ProgrammingLanguageUseResponse {
1511 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1512 HashMap::<String, f64>::deserialize(deserializer).map(Self::parse)
1513 }
1514}
1515impl RepositoryFile {
1516 pub fn decoded_content(&self) -> ApiResult<Vec<u8>> {
1518 if self.encoding.eq_ignore_ascii_case("base64") {
1519 let content = self.content.chars().filter(|character| !character.is_whitespace()).collect::<String>();
1520 BASE64
1521 .decode(content.as_bytes())
1522 .map_err(|why| eyre!("Failed to decode GitLab repository file {} — {why}", self.file_path))
1523 } else {
1524 Ok(self.content.as_bytes().to_vec())
1525 }
1526 }
1527}
1528impl RepositoryFileMetadata for RepositoryFile {
1529 fn path(&self) -> &str {
1530 &self.file_path
1531 }
1532 fn size(&self) -> Option<u64> {
1533 Some(self.size)
1534 }
1535}
1536impl RunnerMetadata {
1537 pub fn is_available(&self) -> bool {
1539 let Self { active, online, paused, .. } = self;
1540 *active && online.unwrap_or(false) && !*paused
1541 }
1542 pub fn with_identifier(self, value: u64) -> Self {
1544 Self {
1545 identifier: Some(value),
1546 ..self
1547 }
1548 }
1549}
1550impl Default for RunnerMetadata {
1551 fn default() -> Self {
1552 Self::init().build()
1553 }
1554}
1555impl From<RunnerDetails> for RunnerMetadata {
1556 fn from(value: RunnerDetails) -> Self {
1557 let RunnerDetails {
1558 name,
1559 runner_type,
1560 description,
1561 tags,
1562 ..
1563 } = value;
1564 Self {
1565 access_level: None,
1566 active: false,
1567 architecture: None,
1568 contacted_at: None,
1569 created_at: None,
1570 created_by: None,
1571 description,
1572 groups: None,
1573 identifier: None,
1574 ip_address: None,
1575 job_execution_status: None,
1576 paused: false,
1577 maintenance_note: None,
1578 maximum_timeout: None,
1579 name,
1580 online: Some(false),
1581 platform: None,
1582 projects: None,
1583 revision: None,
1584 shared: matches!(runner_type, RunnerType::Instance),
1585 runner_type,
1586 run_untagged: false,
1587 status: None,
1588 tags,
1589 version: None,
1590 }
1591 }
1592}
1593impl TryFrom<&str> for SortValue {
1594 type Error = String;
1595
1596 fn try_from(value: &str) -> eyre::Result<Self, Self::Error> {
1597 match value {
1598 | "asc" => Ok(SortValue::Ascending),
1599 | "desc" => Ok(SortValue::Descending),
1600 | _ => Err(format!("Invalid GitLab sort order: {value}")),
1601 }
1602 }
1603}
1604impl fmt::Display for TargetType {
1605 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1606 let s = match self {
1607 | TargetType::Epic => "epic",
1608 | TargetType::Issue => "issue",
1609 | TargetType::MergeRequest => "merge_request",
1610 | TargetType::Milestone => "milestone",
1611 | TargetType::Note => "note",
1612 | TargetType::Project => "project",
1613 | TargetType::Snippet => "snippet",
1614 | TargetType::User => "user",
1615 | TargetType::Unknown => "unknown",
1616 };
1617 write!(f, "{}", s)
1618 }
1619}
1620impl core::str::FromStr for TargetType {
1621 type Err = String;
1622
1623 fn from_str(value: &str) -> Result<Self, Self::Err> {
1624 match value.to_lowercase().as_str() {
1625 | "epic" => Ok(TargetType::Epic),
1626 | "issue" => Ok(TargetType::Issue),
1627 | "merge_request" | "mergerequest" => Ok(TargetType::MergeRequest),
1628 | "milestone" => Ok(TargetType::Milestone),
1629 | "note" => Ok(TargetType::Note),
1630 | "project" => Ok(TargetType::Project),
1631 | "snippet" => Ok(TargetType::Snippet),
1632 | "user" => Ok(TargetType::User),
1633 | _ => Err(format!("Invalid GitLab target type value: {value}")),
1634 }
1635 }
1636}
1637impl TryFrom<&str> for TargetType {
1638 type Error = String;
1639
1640 fn try_from(value: &str) -> Result<Self, Self::Error> {
1641 value.parse()
1642 }
1643}
1644impl<'de> serde::Deserialize<'de> for TreeResponse {
1645 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1646 #[derive(Deserialize)]
1647 #[serde(untagged)]
1648 enum TreeResponseValue {
1649 Entries(Vec<TreeEntry>),
1650 Error(ErrorResponse),
1651 }
1652
1653 match TreeResponseValue::deserialize(deserializer)? {
1654 | TreeResponseValue::Entries(entries) => {
1655 let entry_count = entries.len();
1656 Ok(Self {
1657 paths: entries.into_iter().filter(TreeEntry::is_file).map(TreeEntry::path).collect(),
1658 entry_count,
1659 error: None,
1660 })
1661 }
1662 | TreeResponseValue::Error(why) => Ok(Self {
1663 paths: vec![],
1664 entry_count: 0,
1665 error: Some(why),
1666 }),
1667 }
1668 }
1669}
1670impl WebhookOptions {
1671 pub fn from_env(public_url: Option<&str>) -> Self {
1673 Self {
1674 public_url: public_url.map(str::to_string),
1675 webhook_token: var("GITLAB_WEBHOOK_TOKEN").ok().filter(|value| !value.trim().is_empty()),
1676 signing_token: var("GITLAB_WEBHOOK_SIGNING_TOKEN").ok().filter(|value| !value.trim().is_empty()),
1677 }
1678 }
1679 pub fn new(public_url: Option<&str>, webhook_token: Option<&str>, signing_token: Option<&str>) -> Self {
1681 Self {
1682 public_url: public_url.map(str::to_string),
1683 webhook_token: webhook_token.map(str::to_string),
1684 signing_token: signing_token.map(str::to_string),
1685 }
1686 }
1687 fn url(&self) -> Option<String> {
1688 self.public_url
1689 .as_deref()
1690 .map(|public_url| format!("{}/webhooks/gitlab", public_url.trim_end_matches('/')))
1691 }
1692 fn credentials(&self, supports_signing: bool) -> (Option<&str>, Option<&str>) {
1693 let signing_token = supports_signing.then_some(self.signing_token.as_deref()).flatten();
1694 let webhook_token = signing_token.is_none().then_some(self.webhook_token.as_deref()).flatten();
1695 (webhook_token, signing_token)
1696 }
1697
1698 fn for_registration(&self, supports_signing: bool) -> Self {
1699 let (webhook_token, signing_token) = self.credentials(supports_signing);
1700 Self {
1701 public_url: self.url(),
1702 webhook_token: webhook_token.map(str::to_string),
1703 signing_token: signing_token.map(str::to_string),
1704 }
1705 }
1706}
1707impl WorkItemUser {
1708 pub fn new(identifier: u64, username: impl Into<String>, bot: bool) -> Self {
1710 Self {
1711 identifier,
1712 username: username.into(),
1713 bot,
1714 }
1715 }
1716}
1717#[cfg(test)]
1718mod tests;