1use crate::io::api::{
6 require_non_empty_secret, Configuration, DatabasePersistence, EmptyField, Endpoint, Fallback, Param, Params, RemoteResource, ResponseContent,
7 TreeEntryType, ValueValidator, INCLUDED_ENDPOINTS,
8};
9use crate::io::config::{RunnerDetails, RunnerStatus, RunnerType};
10use crate::io::database::schema::{ProgrammingLanguageRow, Table};
11use crate::io::database::{Database, Operations};
12use crate::io::{first_env_var, with_progress, ApiResult, ProgressType};
13use crate::prelude::var;
14use crate::prelude::HashMap;
15use crate::schema::validate::is_date;
16use crate::util::constants::env::GITLAB_TOKEN_VARIABLE_NAMES;
17use crate::util::{Label, Searchable};
18use async_trait::async_trait;
19use bon::Builder;
20use color_eyre::eyre::{self, eyre};
21use core::fmt;
22use derive_more::Display;
23use serde::{Deserialize, Serialize};
24use serde_with::skip_serializing_none;
25use tracing::debug;
26
27pub mod bot;
28
29pub type EventsResponse = Vec<EventDetails>;
31pub type GroupsResponse = Vec<GroupDetails>;
33pub type RunnersResponse = Vec<RunnerMetadata>;
35pub type ProgrammingLanguageEntries = Vec<ProgrammingLanguageMetadata>;
37pub type ProgrammingLanguageUseEntries = Vec<ProgrammingLanguageUseMetadata>;
39pub trait Create {
41 fn create(_options: &Options) -> ApiResult<Self>
43 where
44 Self: Sized,
45 {
46 Err(eyre!("GitLab struct creation is not implemented"))
47 }
48 fn register(self) -> ApiResult<Self>
50 where
51 Self: Sized,
52 {
53 Err(eyre!("GitLab struct registration is not implemented"))
54 }
55}
56#[derive(Clone, Debug, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum AccessLevel {
60 NotProtected,
62 RefProtected,
64}
65#[derive(Clone, Debug, Display, Serialize, Deserialize)]
69pub enum Emoji {
70 #[display(":seedling:")]
72 Seedling,
73}
74#[derive(Clone, Debug, Serialize, Deserialize)]
78pub enum EventAction {
79 #[serde(rename = "approved")]
81 Approved,
82 #[serde(rename = "closed")]
84 Closed,
85 #[serde(rename = "commented")]
87 Commented,
88 #[serde(rename = "commented on")]
90 CommentedOn,
91 #[serde(rename = "created")]
93 Created,
94 #[serde(rename = "destroyed")]
96 Destroyed,
97 #[serde(rename = "expired")]
99 Expired,
100 #[serde(rename = "joined")]
102 Joined,
103 #[serde(rename = "left")]
105 Left,
106 #[serde(rename = "merged")]
108 Merged,
109 #[serde(rename = "pushed")]
111 Pushed,
112 #[serde(rename = "pushed to")]
114 PushedTo,
115 #[serde(rename = "reopened")]
117 Reopened,
118 #[serde(rename = "updated")]
120 Updated,
121 #[serde(rename = "deleted")]
123 Deleted,
124 #[serde(rename = "accepted")]
126 Accepted,
127 #[serde(other)]
129 Unknown,
130}
131#[derive(Clone, Debug, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum EventFilterKey {
137 Action,
141 TargetType,
143 After,
145 Before,
147 Sort,
149}
150#[derive(Clone, Debug, Default, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum GroupVisibility {
154 #[default]
156 Public,
157 Internal,
159 Private,
161}
162#[derive(Clone, Debug, Serialize, Deserialize)]
192#[serde(rename_all = "snake_case")]
193pub enum OrderByValue {
194 CreatedAt,
196 FullName,
198 #[serde(rename = "id")]
200 Identifier,
201 LabelPriority,
203 LastActivityAt,
205 MilestoneDue,
207 Name,
209 Path,
211 Popularity,
213 DueDate,
215 Priority,
217 RelativePosition,
219 Similarity,
221 Title,
223 UpdatedAt,
225 Weight,
227}
228#[derive(Clone, Debug, Serialize, Deserialize)]
232#[serde(rename_all = "snake_case")]
233pub enum PaginationKey {
234 OrderBy,
236 Page,
238 Pagination,
240 PerPage,
242 Sort,
244}
245#[derive(Clone, Debug, Default, Serialize, Deserialize)]
247#[serde(rename_all = "snake_case")]
248pub enum SortValue {
249 #[default]
251 #[serde(rename = "desc")]
252 Descending,
253 #[serde(rename = "asc")]
255 Ascending,
256}
257#[derive(Clone, Debug, Serialize, Deserialize)]
259#[serde(rename_all = "PascalCase")]
260pub enum TargetType {
261 Epic,
263 Issue,
265 MergeRequest,
267 Milestone,
269 Note,
271 Project,
273 Snippet,
275 User,
277 #[serde(other)]
279 Unknown,
280}
281#[derive(Clone, Debug, Serialize, Deserialize)]
286pub struct ErrorResponse {
287 message: Option<serde_json::Value>,
289 error: Option<String>,
291 error_description: Option<String>,
293}
294#[skip_serializing_none]
298#[derive(Clone, Debug, Serialize, Deserialize)]
299pub struct EventDetails {
300 #[serde(rename = "id")]
302 pub identifier: u64,
303 pub project_id: u64,
305 pub action_name: EventAction,
307 pub target_id: u64,
309 pub target_iid: u64,
311 pub target_type: TargetType,
313 pub author_id: u64,
315 pub target_title: String,
317 pub created_at: String,
319 pub author: UserMetadata,
321 pub imported: bool,
323 pub imported_from: String,
325 pub push_data: Option<PushData>,
327 pub author_username: String,
329}
330#[skip_serializing_none]
332#[derive(Clone, Debug, Serialize, Deserialize)]
333pub struct GroupDetails {
334 #[serde(rename = "id")]
336 pub identifier: u64,
337 #[serde(rename = "web_url")]
339 pub url: String,
340 pub name: String,
342 pub path: Option<String>,
344 pub description: Option<String>,
346 #[serde(default)]
348 pub emails_disabled: bool,
349 #[serde(default)]
351 pub emails_enabled: bool,
352 #[serde(default)]
354 pub show_diff_preview_in_email: bool,
355 pub visibility: Option<GroupVisibility>,
357 #[serde(default)]
359 pub share_with_group_lock: bool,
360 #[serde(default)]
362 pub require_two_factor_authentication: bool,
363 #[serde(default)]
365 pub lfs_enabled: bool,
366 #[serde(default)]
368 pub archived: bool,
369 #[serde(default)]
371 pub duo_features_enabled: bool,
372 #[serde(default)]
374 pub lock_duo_features_enabled: bool,
375 #[serde(default)]
377 pub auto_duo_code_review_enabled: bool,
378 #[serde(default)]
380 pub math_rendering_limits_enabled: bool,
381 #[serde(default)]
383 pub lock_math_rendering_limits_enabled: bool,
384 #[serde(default)]
386 pub request_access_enabled: bool,
387 pub two_factor_grace_period: Option<u64>,
389 pub project_creation_level: Option<String>,
391 pub auto_devops_enabled: Option<bool>,
393 pub subgroup_creation_level: Option<String>,
395 pub mentions_disabled: Option<bool>,
397 pub default_branch: Option<String>,
399 pub default_branch_protection: Option<u64>,
401 pub default_branch_protection_defaults: Option<RunnerGroupBranchProtectionDefaults>,
403 #[serde(rename = "avatar_url")]
405 pub avatar_url: Option<String>,
406 pub full_name: Option<String>,
408 pub full_path: Option<String>,
410 pub created_at: Option<String>,
412 pub parent_id: Option<u64>,
414 pub organization_id: Option<u64>,
416 pub shared_runners_setting: Option<String>,
418 pub max_artifacts_size: Option<u64>,
420 pub marked_for_deletion_on: Option<String>,
422 #[serde(rename = "ldap_cn")]
424 pub ldap_common_name: Option<String>,
425 pub ldap_access: Option<String>,
427 pub file_template_project_id: Option<u64>,
429 pub wiki_access_level: Option<String>,
431 pub duo_core_features_enabled: Option<bool>,
433}
434#[skip_serializing_none]
468#[derive(Clone, Debug, Serialize, Deserialize)]
469pub struct NoteMetadata {
470 #[serde(rename = "id")]
472 pub identifier: u64,
473 #[serde(rename = "type")]
475 pub note_type: Option<String>,
476 pub body: String,
478 pub author: UserMetadata,
480 pub created_at: String,
482 pub updated_at: String,
484 pub system: bool,
486 pub noteable_id: u64,
488 pub noteable_iid: u64,
490 pub noteable_type: String,
492 pub project_id: u64,
494 pub resolvable: bool,
496 pub confidential: bool,
498 pub internal: bool,
500 pub imported: bool,
502 pub imported_from: String,
504 pub commands_changes: serde_json::Value,
506}
507#[derive(Builder, Clone, Debug)]
509#[builder(start_fn = with_token, on(String, into))]
510pub struct Options {
511 #[builder(start_fn)]
513 pub token: String,
514 pub body: Option<String>,
516 #[builder(default = String::from("gitlab.com"))]
518 pub domain: String,
519 pub identifier: Option<String>,
521 pub path: Option<String>,
523 #[builder(default = 1)]
525 pub page: u32,
526 pub internal_identifier: Option<String>,
528 #[builder(default = RunnerMetadata::default())]
530 pub runner_metadata: RunnerMetadata,
531 #[builder(default = vec![])]
533 pub custom_params: Vec<Param>,
534}
535#[skip_serializing_none]
537#[derive(Clone, Debug, Default, Serialize, Deserialize)]
538pub struct ProgrammingLanguageDetails {
539 pub language_id: Option<u64>,
541 #[serde(rename = "type")]
543 pub language_type: Option<String>,
544 pub color: Option<String>,
546 pub group: Option<String>,
548}
549#[skip_serializing_none]
551#[derive(Clone, Debug, Default, Serialize, Deserialize)]
552pub struct ProgrammingLanguageMetadata {
553 pub name: String,
555 pub language_id: Option<u64>,
557 pub language_type: Option<String>,
559 pub color: Option<String>,
561 pub group: Option<String>,
563}
564#[derive(Clone, Debug, Default, Serialize)]
566pub struct ProgrammingLanguagesResponse {
567 pub languages: ProgrammingLanguageEntries,
569}
570#[derive(Clone, Debug, Default, Serialize, Deserialize)]
572pub struct ProgrammingLanguageUseMetadata {
573 pub name: String,
575 pub percentage: f64,
577}
578#[derive(Clone, Debug, Default, Serialize)]
580pub struct ProgrammingLanguageUseResponse {
581 pub languages: ProgrammingLanguageUseEntries,
583}
584#[skip_serializing_none]
586#[derive(Clone, Debug, Serialize, Deserialize)]
587pub struct PushData {
588 pub commit_count: u64,
590 pub action: EventAction,
592 pub ref_type: String,
594 pub commit_from: Option<String>,
596 pub commit_to: Option<String>,
598 #[serde(rename = "ref")]
600 pub ref_name: String,
601 pub commit_title: Option<String>,
603 pub ref_count: Option<u64>,
605}
606#[skip_serializing_none]
616#[derive(Clone, Debug, Serialize, Deserialize)]
617pub struct RunnerCreationResponse {
618 #[serde(default)]
620 #[serde(rename = "id")]
621 pub identifier: u64,
622 pub token: Option<String>,
624 pub token_expires_at: Option<String>,
626}
627#[skip_serializing_none]
665#[derive(Clone, Debug, Builder, Serialize, Deserialize)]
666#[builder(start_fn = init, on(String, into), on(&str, into))]
667pub struct RunnerMetadata {
668 #[serde(rename = "id")]
670 pub identifier: Option<u64>,
671 #[builder(default)]
673 #[serde(default)]
674 pub active: bool,
675 #[serde(default)]
679 pub online: Option<bool>,
680 #[builder(default)]
682 #[serde(default)]
683 pub paused: bool,
684 #[builder(default)]
686 #[serde(default)]
687 pub run_untagged: bool,
688 #[builder(default)]
690 #[serde(default, rename = "is_shared")]
691 pub shared: bool,
692 pub architecture: Option<String>,
694 pub description: Option<String>,
696 pub ip_address: Option<String>,
698 #[builder(with = |value: &str| RunnerType::from(value))]
700 #[builder(default = RunnerType::Project)]
701 pub runner_type: RunnerType,
702 pub created_by: Option<UserMetadata>,
704 pub created_at: Option<String>,
706 pub contacted_at: Option<String>,
708 pub maintenance_note: Option<String>,
710 pub name: Option<String>,
712 pub status: Option<RunnerStatus>,
714 pub job_execution_status: Option<String>,
716 pub platform: Option<String>,
718 pub projects: Option<Vec<RunnerScope>>,
720 pub groups: Option<Vec<RunnerScope>>,
722 pub revision: Option<String>,
724 #[builder(with = |values: &[&str]| values.iter().map(|s| s.to_string()).collect::<Vec<String>>())]
726 #[serde(rename = "tag_list")]
727 pub tags: Option<Vec<String>>,
728 pub version: Option<String>,
730 pub access_level: Option<AccessLevel>,
732 pub maximum_timeout: Option<u64>,
734}
735#[skip_serializing_none]
737#[derive(Clone, Debug, Serialize, Deserialize)]
738pub struct RunnerGroupAccessLevel {
739 pub access_level: u64,
741}
742#[skip_serializing_none]
744#[derive(Clone, Debug, Serialize, Deserialize)]
745pub struct RunnerGroupBranchProtectionDefaults {
746 pub allowed_to_push: Vec<RunnerGroupAccessLevel>,
748 pub allow_force_push: bool,
750 pub allowed_to_merge: Vec<RunnerGroupAccessLevel>,
752}
753#[skip_serializing_none]
755#[derive(Clone, Debug, Serialize, Deserialize)]
756pub struct RunnerScope {
757 #[serde(rename = "id")]
759 pub identifier: u64,
760 pub name: String,
762 pub path: Option<String>,
764 pub name_with_namespace: Option<String>,
766 pub path_with_namespace: Option<String>,
768 #[serde(rename = "web_url")]
770 pub url: Option<String>,
771}
772#[skip_serializing_none]
776#[derive(Clone, Debug, Serialize, Deserialize)]
777pub struct TreeEntry {
778 pub id: String,
782 pub name: String,
784 #[serde(rename = "type")]
786 pub entry_type: TreeEntryType,
787 pub path: String,
791 pub mode: String,
793}
794#[derive(Clone, Debug, Default, Serialize)]
795pub struct TreeResponse {
797 pub paths: Vec<String>,
799 #[serde(skip_serializing)]
801 pub(crate) error: Option<ErrorResponse>,
802}
803#[skip_serializing_none]
818#[derive(Clone, Debug, Serialize, Deserialize)]
819pub struct UserMetadata {
820 pub avatar_url: String,
822 #[serde(rename = "id")]
824 pub identifier: u64,
825 pub locked: bool,
827 pub name: String,
829 #[serde(rename = "public_email")]
831 pub email: Option<String>,
832 pub state: String,
835 pub username: String,
837 #[serde(rename = "web_url")]
839 pub url: String,
840}
841impl ErrorResponse {
842 fn is_terminal_pagination_message(message: &str) -> bool {
843 let message = message.to_lowercase();
844 let invalid_page =
845 message.contains("page") && (message.contains("invalid") || message.contains("out of range") || message.contains("not found"));
846 let forbidden_page = message.contains("403") && message.contains("forbidden");
847 invalid_page || forbidden_page
848 }
849 fn is_terminal_pagination_error(&self) -> bool {
850 Self::is_terminal_pagination_message(&self.message())
851 }
852 fn message(&self) -> String {
853 let message = self
854 .message
855 .as_ref()
856 .and_then(|value| serde_json::to_string(value).ok())
857 .unwrap_or_default();
858 let error = self.error.clone().unwrap_or_default();
859 let description = self.error_description.clone().unwrap_or_default();
860 [message, error, description]
861 .into_iter()
862 .filter(|value| !value.trim().is_empty())
863 .collect::<Vec<_>>()
864 .join(" ")
865 }
866}
867impl fmt::Display for EventAction {
868 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
869 let s = match self {
870 | EventAction::Approved => "approved",
871 | EventAction::Closed => "closed",
872 | EventAction::Commented => "commented",
873 | EventAction::CommentedOn => "commented on",
874 | EventAction::Created => "created",
875 | EventAction::Destroyed => "destroyed",
876 | EventAction::Expired => "expired",
877 | EventAction::Joined => "joined",
878 | EventAction::Left => "left",
879 | EventAction::Merged => "merged",
880 | EventAction::Pushed => "pushed",
881 | EventAction::PushedTo => "pushed to",
882 | EventAction::Reopened => "reopened",
883 | EventAction::Updated => "updated",
884 | EventAction::Deleted => "deleted",
885 | EventAction::Accepted => "accepted",
886 | EventAction::Unknown => "unknown",
887 };
888 write!(f, "{}", s)
889 }
890}
891impl core::str::FromStr for EventAction {
892 type Err = String;
893
894 fn from_str(value: &str) -> Result<Self, Self::Err> {
895 match value {
896 | "approved" => Ok(EventAction::Approved),
897 | "closed" => Ok(EventAction::Closed),
898 | "commented" => Ok(EventAction::Commented),
899 | "commented on" => Ok(EventAction::CommentedOn),
900 | "created" => Ok(EventAction::Created),
901 | "destroyed" => Ok(EventAction::Destroyed),
902 | "expired" => Ok(EventAction::Expired),
903 | "joined" => Ok(EventAction::Joined),
904 | "left" => Ok(EventAction::Left),
905 | "merged" => Ok(EventAction::Merged),
906 | "pushed" => Ok(EventAction::Pushed),
907 | "pushed to" => Ok(EventAction::PushedTo),
908 | "reopened" => Ok(EventAction::Reopened),
909 | "updated" => Ok(EventAction::Updated),
910 | "deleted" => Ok(EventAction::Deleted),
911 | "accepted" => Ok(EventAction::Accepted),
912 | _ => Err(format!("Invalid GitLab event action value: {value}")),
913 }
914 }
915}
916impl TryFrom<&str> for EventAction {
917 type Error = String;
918
919 fn try_from(value: &str) -> Result<Self, Self::Error> {
920 value.parse()
921 }
922}
923impl fmt::Display for EventFilterKey {
924 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
925 let s = match self {
926 | EventFilterKey::Action => "action",
927 | EventFilterKey::TargetType => "target_type",
928 | EventFilterKey::After => "after",
929 | EventFilterKey::Before => "before",
930 | EventFilterKey::Sort => "sort",
931 };
932 write!(f, "{}", s)
933 }
934}
935impl TryFrom<&str> for EventFilterKey {
936 type Error = String;
937
938 fn try_from(value: &str) -> Result<Self, Self::Error> {
939 match value {
940 | "action" => Ok(EventFilterKey::Action),
941 | "target_type" => Ok(EventFilterKey::TargetType),
942 | "after" => Ok(EventFilterKey::After),
943 | "before" => Ok(EventFilterKey::Before),
944 | "sort" => Ok(EventFilterKey::Sort),
945 | _ => Err(format!("Invalid EventFilterKey: {}", value)),
946 }
947 }
948}
949impl ValueValidator for EventFilterKey {
950 fn is_valid(&self, value: &str) -> bool {
952 match self {
953 | EventFilterKey::Action => EventAction::try_from(value).is_ok(),
954 | EventFilterKey::TargetType => TargetType::try_from(value).is_ok(),
955 | EventFilterKey::After => is_date(value).is_ok(),
956 | EventFilterKey::Before => is_date(value).is_ok(),
957 | EventFilterKey::Sort => SortValue::try_from(value).is_ok(),
958 }
959 }
960}
961impl Configuration for Options {
962 fn from_env() -> Self {
970 if let Err(why) = dotenvy::from_filename(".env") {
971 debug!("=> {} Load .env — {why}", Label::skip());
972 }
973 Self {
974 token: first_env_var(&GITLAB_TOKEN_VARIABLE_NAMES).unwrap_or_default(),
975 identifier: var("CI_PROJECT_ID").ok(),
976 internal_identifier: var("CI_MERGE_REQUEST_IID").ok(),
977 domain: var("CI_SERVER_HOST").unwrap_or_else(|_| "gitlab.com".to_string()),
978 body: None,
979 path: None,
980 page: 1,
981 runner_metadata: RunnerMetadata::default(),
982 custom_params: vec![],
983 }
984 }
985 fn with_body(self, value: impl Into<String>) -> Self {
987 Self {
988 body: Some(value.into()),
989 ..self
990 }
991 }
992 fn with_domain(self, value: impl Into<String>) -> Self {
994 Self {
995 domain: value.into(),
996 ..self
997 }
998 }
999 fn with_identifier(self, value: impl Into<String>) -> Self {
1001 Self {
1002 identifier: Some(value.into()),
1003 ..self
1004 }
1005 }
1006 fn token(&self) -> &str {
1008 &self.token
1009 }
1010 fn domain(&self) -> &str {
1012 &self.domain
1013 }
1014 fn identifier(&self) -> Option<&str> {
1016 self.identifier.as_deref()
1017 }
1018 fn with_params(self, params: Vec<Param>) -> Self {
1020 Self {
1021 custom_params: params,
1022 ..self
1023 }
1024 }
1025 fn params(&self) -> &[Param] {
1027 &self.custom_params
1028 }
1029}
1030impl Options {
1031 pub fn with_page(self, value: u32) -> Self {
1033 Self { page: value, ..self }
1034 }
1035 pub fn with_path(self, value: impl Into<String>) -> Self {
1037 Self {
1038 path: Some(value.into()),
1039 ..self
1040 }
1041 }
1042 pub fn with_runner(self, metadata: RunnerMetadata) -> Self {
1044 Self {
1045 runner_metadata: metadata,
1046 ..self
1047 }
1048 }
1049}
1050impl Default for Options {
1051 fn default() -> Self {
1052 Self::from_env()
1053 }
1054}
1055impl TryFrom<&str> for OrderByValue {
1056 type Error = String;
1057
1058 fn try_from(value: &str) -> eyre::Result<Self, Self::Error> {
1059 match value {
1060 | "created_at" => Ok(OrderByValue::CreatedAt),
1061 | "due_date" => Ok(OrderByValue::DueDate),
1062 | "full_name" => Ok(OrderByValue::FullName),
1063 | "id" => Ok(OrderByValue::Identifier),
1064 | "label_priority" => Ok(OrderByValue::LabelPriority),
1065 | "last_activity_at" => Ok(OrderByValue::LastActivityAt),
1066 | "milestone_due" => Ok(OrderByValue::MilestoneDue),
1067 | "name" => Ok(OrderByValue::Name),
1068 | "path" => Ok(OrderByValue::Path),
1069 | "popularity" => Ok(OrderByValue::Popularity),
1070 | "priority" => Ok(OrderByValue::Priority),
1071 | "relative_position" => Ok(OrderByValue::RelativePosition),
1072 | "similarity" => Ok(OrderByValue::Similarity),
1073 | "title" => Ok(OrderByValue::Title),
1074 | "updated_at" => Ok(OrderByValue::UpdatedAt),
1075 | "weight" => Ok(OrderByValue::Weight),
1076 | _ => Err(format!("Invalid GitLab order_by value: {value}")),
1077 }
1078 }
1079}
1080impl From<ProgrammingLanguageMetadata> for ProgrammingLanguageRow {
1081 fn from(value: ProgrammingLanguageMetadata) -> Self {
1082 let ProgrammingLanguageMetadata {
1083 name,
1084 language_id,
1085 language_type,
1086 color,
1087 group,
1088 } = value;
1089 ProgrammingLanguageRow::init()
1090 .name(name)
1091 .maybe_language_id(language_id.and_then(|value| i64::try_from(value).ok()))
1092 .maybe_language_type(language_type)
1093 .maybe_color(color)
1094 .maybe_group_name(group)
1095 .build()
1096 }
1097}
1098impl ProgrammingLanguagesResponse {
1099 pub fn parse(data: HashMap<String, ProgrammingLanguageDetails>) -> Self {
1101 let languages = data
1102 .into_iter()
1103 .filter_map(|(name, details)| {
1104 details
1105 .language_type
1106 .as_ref()
1107 .map(|kind| kind.eq_ignore_ascii_case("programming"))
1108 .filter(|is_programming| *is_programming)
1109 .map(|_| ProgrammingLanguageMetadata {
1110 name,
1111 language_id: details.language_id,
1112 language_type: details.language_type,
1113 color: details.color,
1114 group: details.group,
1115 })
1116 })
1117 .collect();
1118 Self { languages }
1119 }
1120}
1121impl ProgrammingLanguageUseResponse {
1122 pub fn parse(data: HashMap<String, f64>) -> Self {
1124 let mut languages = data
1125 .into_iter()
1126 .map(|(name, percentage)| ProgrammingLanguageUseMetadata { name, percentage })
1127 .collect::<ProgrammingLanguageUseEntries>();
1128 languages.sort_by(|a, b| a.name.cmp(&b.name));
1129 Self { languages }
1130 }
1131 pub fn entries(&self) -> Vec<(String, f64)> {
1133 let mut entries = self
1134 .languages
1135 .iter()
1136 .map(|ProgrammingLanguageUseMetadata { name, percentage }| (name.clone(), *percentage))
1137 .collect::<Vec<_>>();
1138 entries.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(core::cmp::Ordering::Equal));
1139 entries
1140 }
1141 pub fn names(&self) -> Vec<String> {
1143 self.entries().into_iter().map(|(name, _)| name).collect()
1144 }
1145}
1146impl<'de> serde::Deserialize<'de> for ProgrammingLanguagesResponse {
1147 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1148 HashMap::<String, ProgrammingLanguageDetails>::deserialize(deserializer).map(Self::parse)
1149 }
1150}
1151impl<'de> serde::Deserialize<'de> for ProgrammingLanguageUseResponse {
1152 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1153 HashMap::<String, f64>::deserialize(deserializer).map(Self::parse)
1154 }
1155}
1156#[async_trait]
1157impl DatabasePersistence for ProgrammingLanguagesResponse {
1158 async fn persist(self, database: Database<Table>) -> ApiResult<usize> {
1160 let Self { languages } = self;
1161 let message: fn(&ProgrammingLanguageMetadata) -> String = |item| format!("Saving \"{}\" language metadata", item.name);
1162 let operation = |item| async { database.insert(ProgrammingLanguageRow::from(item)) };
1163 let finish = |count| format!("{}Saved metadata for {count} programming languages", Label::CHECKMARK);
1164 with_progress(languages, message, operation, finish, None, ProgressType::Bar)
1165 .await
1166 .map(|counts| counts.into_iter().sum())
1167 .map_err(eyre::Report::msg)
1168 }
1169}
1170impl fmt::Display for PaginationKey {
1171 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1172 let s = match self {
1173 | PaginationKey::OrderBy => "order_by",
1174 | PaginationKey::Page => "page",
1175 | PaginationKey::Pagination => "pagination",
1176 | PaginationKey::PerPage => "per_page",
1177 | PaginationKey::Sort => "sort",
1178 };
1179 write!(f, "{}", s)
1180 }
1181}
1182impl TryFrom<&str> for PaginationKey {
1183 type Error = String;
1184
1185 fn try_from(value: &str) -> eyre::Result<Self, Self::Error> {
1186 match value {
1187 | "order_by" => Ok(PaginationKey::OrderBy),
1188 | "page" => Ok(PaginationKey::Page),
1189 | "pagination" => Ok(PaginationKey::Pagination),
1190 | "per_page" => Ok(PaginationKey::PerPage),
1191 | "sort" => Ok(PaginationKey::Sort),
1192 | _ => Err(format!("Invalid GitLab pagination field: {value}")),
1193 }
1194 }
1195}
1196impl ValueValidator for PaginationKey {
1197 fn is_valid(&self, value: &str) -> bool {
1199 match self {
1200 | PaginationKey::OrderBy => OrderByValue::try_from(value).is_ok(),
1201 | PaginationKey::Page => value.parse::<u64>().is_ok(),
1202 | PaginationKey::PerPage => value.parse::<u64>().is_ok(),
1203 | PaginationKey::Sort => SortValue::try_from(value).is_ok(),
1204 | _ => true,
1205 }
1206 }
1207}
1208impl Default for RunnerMetadata {
1209 fn default() -> Self {
1210 Self::init().build()
1211 }
1212}
1213impl From<RunnerDetails> for RunnerMetadata {
1214 fn from(value: RunnerDetails) -> Self {
1215 let RunnerDetails {
1216 name,
1217 runner_type,
1218 description,
1219 tags,
1220 ..
1221 } = value;
1222 Self {
1223 access_level: None,
1224 active: false,
1225 architecture: None,
1226 contacted_at: None,
1227 created_at: None,
1228 created_by: None,
1229 description,
1230 groups: None,
1231 identifier: None,
1232 ip_address: None,
1233 job_execution_status: None,
1234 paused: false,
1235 maintenance_note: None,
1236 maximum_timeout: None,
1237 name,
1238 online: Some(false),
1239 platform: None,
1240 projects: None,
1241 revision: None,
1242 shared: matches!(runner_type, RunnerType::Instance),
1243 runner_type,
1244 run_untagged: false,
1245 status: None,
1246 tags,
1247 version: None,
1248 }
1249 }
1250}
1251impl RunnerMetadata {
1252 pub fn is_available(&self) -> bool {
1254 let Self { active, online, paused, .. } = self;
1255 *active && online.unwrap_or(false) && !*paused
1256 }
1257 pub fn with_identifier(self, value: u64) -> Self {
1259 Self {
1260 identifier: Some(value),
1261 ..self
1262 }
1263 }
1264}
1265impl TryFrom<&str> for SortValue {
1266 type Error = String;
1267
1268 fn try_from(value: &str) -> eyre::Result<Self, Self::Error> {
1269 match value {
1270 | "asc" => Ok(SortValue::Ascending),
1271 | "desc" => Ok(SortValue::Descending),
1272 | _ => Err(format!("Invalid GitLab sort order: {value}")),
1273 }
1274 }
1275}
1276impl fmt::Display for TargetType {
1277 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1278 let s = match self {
1279 | TargetType::Epic => "epic",
1280 | TargetType::Issue => "issue",
1281 | TargetType::MergeRequest => "merge_request",
1282 | TargetType::Milestone => "milestone",
1283 | TargetType::Note => "note",
1284 | TargetType::Project => "project",
1285 | TargetType::Snippet => "snippet",
1286 | TargetType::User => "user",
1287 | TargetType::Unknown => "unknown",
1288 };
1289 write!(f, "{}", s)
1290 }
1291}
1292impl core::str::FromStr for TargetType {
1293 type Err = String;
1294
1295 fn from_str(value: &str) -> Result<Self, Self::Err> {
1296 match value.to_lowercase().as_str() {
1297 | "epic" => Ok(TargetType::Epic),
1298 | "issue" => Ok(TargetType::Issue),
1299 | "merge_request" | "mergerequest" => Ok(TargetType::MergeRequest),
1300 | "milestone" => Ok(TargetType::Milestone),
1301 | "note" => Ok(TargetType::Note),
1302 | "project" => Ok(TargetType::Project),
1303 | "snippet" => Ok(TargetType::Snippet),
1304 | "user" => Ok(TargetType::User),
1305 | _ => Err(format!("Invalid GitLab target type value: {value}")),
1306 }
1307 }
1308}
1309impl TryFrom<&str> for TargetType {
1310 type Error = String;
1311
1312 fn try_from(value: &str) -> Result<Self, Self::Error> {
1313 value.parse()
1314 }
1315}
1316impl TreeEntry {
1317 pub fn path(self) -> String {
1319 self.path
1320 }
1321 pub fn is_blob(&self) -> bool {
1323 let Self { entry_type, .. } = self;
1324 entry_type.eq(&TreeEntryType::Blob)
1325 }
1326}
1327impl<'de> serde::Deserialize<'de> for TreeResponse {
1328 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1329 #[derive(Deserialize)]
1330 #[serde(untagged)]
1331 enum TreeResponseValue {
1332 Entries(Vec<TreeEntry>),
1333 Error(ErrorResponse),
1334 }
1335
1336 match TreeResponseValue::deserialize(deserializer)? {
1337 | TreeResponseValue::Entries(entries) => Ok(Self {
1338 paths: entries.into_iter().filter(|entry| entry.is_blob()).map(|entry| entry.path()).collect(),
1339 error: None,
1340 }),
1341 | TreeResponseValue::Error(why) => Ok(Self {
1342 paths: vec![],
1343 error: Some(why),
1344 }),
1345 }
1346 }
1347}
1348pub(crate) fn handle_tree_paths_response(response: ApiResult<TreeResponse>, page: u32) -> ApiResult<TreeResponse> {
1349 match response {
1350 | Ok(value) => match value.error {
1351 | Some(why) if page > 1 && why.is_terminal_pagination_error() => Ok(TreeResponse::default()),
1352 | Some(why) => Err(eyre!(why.message())),
1353 | None => Ok(value),
1354 },
1355 | Err(why) => Err(why),
1356 }
1357}
1358pub async fn create_runner(options: &Options) -> ApiResult<RunnerCreationResponse> {
1362 #[derive(Deserialize)]
1363 struct StrictRunnerCreationResponse {
1364 #[serde(rename = "id")]
1365 identifier: u64,
1366 token: Option<String>,
1367 token_expires_at: Option<String>,
1368 }
1369 let template = "gitlab::api";
1370 let action = "runner::create";
1371 let path = format!("{template}::{action}");
1372 let runner_metadata = &options.runner_metadata;
1373 let runner_type = &runner_metadata.runner_type;
1374 let description = runner_metadata.description.as_deref().unwrap_or_default();
1375 let tags = runner_metadata.tags.as_deref().unwrap_or_default();
1376 let run_untagged = runner_metadata.run_untagged;
1377 let tag_list = if tags.is_empty() { None } else { Some(tags.join(",")) };
1378 match require_non_empty_secret(&options.token, &path, &GITLAB_TOKEN_VARIABLE_NAMES) {
1379 | Ok(token) => match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
1380 | Ok(endpoint) => {
1381 let params = Params::new()
1382 .with_auth(&token, Some("PRIVATE-TOKEN"))
1383 .with_body("description", description)
1384 .with_body(&format!("{runner_type}_id"), options.identifier.as_deref().unwrap_or_default())
1385 .with_body("runner_type", &format!("{runner_type}_type"))
1386 .with_body("run_untagged", &run_untagged.to_string())
1387 .with_body_maybe("tag_list", tag_list.as_deref())
1388 .with_custom(options.params())
1389 .build();
1390 let response = endpoint.invoke(action, Some(params)).await;
1391 match response {
1392 | Ok(ResponseContent::Json(content)) => match serde_json::from_str::<StrictRunnerCreationResponse>(&content) {
1393 | Ok(parsed) => Ok(RunnerCreationResponse {
1394 identifier: parsed.identifier,
1395 token: parsed.token,
1396 token_expires_at: parsed.token_expires_at,
1397 }),
1398 | Err(_) => {
1399 let rendered = serde_json::from_str::<serde_json::Value>(&content)
1400 .ok()
1401 .and_then(|value| serde_json::to_string_pretty(&value).ok())
1402 .unwrap_or(content);
1403 Err(eyre!("{rendered}"))
1404 }
1405 },
1406 | Ok(other) => endpoint.handle::<RunnerCreationResponse>(Ok(other)),
1407 | Err(why) => Err(why),
1408 }
1409 }
1410 | Err(why) => Err(why),
1411 },
1412 | Err(why) => Err(why),
1413 }
1414}
1415pub async fn events(options: &Options) -> ApiResult<EventsResponse> {
1436 let template = "gitlab::api";
1437 let action = "events";
1438 let path = format!("{template}::{action}");
1439 match require_non_empty_secret(&options.token, &path, &GITLAB_TOKEN_VARIABLE_NAMES) {
1440 | Ok(token) => match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
1441 | Ok(endpoint) => {
1442 let params = Params::new()
1443 .with_auth(&token, Some("PRIVATE-TOKEN"))
1444 .with_template("identifier", options.identifier())
1445 .with_custom(options.params())
1446 .build();
1447 let response = endpoint.invoke_with::<EventFilterKey, EmptyField>(action, Some(params)).await;
1448 endpoint.handle::<EventsResponse>(response)
1449 }
1450 | Err(why) => Err(why),
1451 },
1452 | Err(why) => Err(why),
1453 }
1454}
1455pub async fn groups(options: &Options) -> ApiResult<GroupsResponse> {
1459 let template = "gitlab::api";
1460 let action = "groups";
1461 let path = format!("{template}::{action}");
1462 match require_non_empty_secret(&options.token, &path, &GITLAB_TOKEN_VARIABLE_NAMES) {
1463 | Ok(token) => match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
1464 | Ok(endpoint) => {
1465 let params = Params::new()
1466 .with_auth(&token, Some("PRIVATE-TOKEN"))
1467 .with_template("identifier", options.identifier())
1468 .with_keyvalue("page", Some("1"))
1469 .with_keyvalue("per_page", Some("100"))
1470 .with_custom(options.params())
1471 .build();
1472 let response = endpoint.invoke_with::<PaginationKey, EmptyField>(action, Some(params)).await;
1473 endpoint.handle_or::<GroupsResponse, Fallback<ErrorResponse>>(response)
1474 }
1475 | Err(why) => Err(why),
1476 },
1477 | Err(why) => Err(why),
1478 }
1479}
1480pub async fn language_use(options: &Options) -> ApiResult<ProgrammingLanguageUseResponse> {
1484 let template = "gitlab::api";
1485 let action = "languages";
1486 let path = format!("{template}::{action}");
1487 match require_non_empty_secret(&options.token, &path, &GITLAB_TOKEN_VARIABLE_NAMES) {
1488 | Ok(token) => match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
1489 | Ok(endpoint) => {
1490 let params = Params::new()
1491 .with_auth(&token, Some("PRIVATE-TOKEN"))
1492 .with_template("identifier", options.identifier())
1493 .with_custom(options.params())
1494 .build();
1495 let response = endpoint.invoke_with::<PaginationKey, EmptyField>(action, Some(params)).await;
1496 endpoint.handle::<ProgrammingLanguageUseResponse>(response)
1497 }
1498 | Err(why) => Err(why),
1499 },
1500 | Err(why) => Err(why),
1501 }
1502}
1503pub async fn languages() -> ApiResult<ProgrammingLanguagesResponse> {
1505 let names = ["gitlab::org", "github::org"];
1506 let action = "languages";
1507 async fn fetch(name: &str, action: &str) -> ApiResult<ProgrammingLanguagesResponse> {
1508 match INCLUDED_ENDPOINTS.find_by_name(name) {
1509 | Some(endpoint) => {
1510 let response = endpoint.invoke(action, None).await.map(|content| match content {
1511 | ResponseContent::Raw(content) => ResponseContent::Yaml(content),
1512 | other => other,
1513 });
1514 endpoint.handle::<ProgrammingLanguagesResponse>(response)
1515 }
1516 | None => Err(eyre!("{name} API endpoint not found")),
1517 }
1518 }
1519 let mut response: Option<ProgrammingLanguagesResponse> = None;
1520 let mut errors = Vec::new();
1521 for name in names {
1522 let path = format!("{name}::{action}");
1523 match fetch(name, action).await {
1524 | Ok(value) => {
1525 response = Some(value);
1526 break;
1527 }
1528 | Err(why) => errors.push(format!("{path}={why}")),
1529 }
1530 }
1531 match response {
1532 | Some(value) => Ok(value),
1533 | None => Err(eyre!("Failed to download and parse language metadata — {}", errors.join("; "))),
1534 }
1535}
1536pub async fn merge_request_note(options: &Options) -> ApiResult<NoteMetadata> {
1543 let template = "gitlab::api";
1544 let action = "merge-request-note";
1545 let path = format!("{template}::{action}");
1546 match require_non_empty_secret(&options.token, &path, &GITLAB_TOKEN_VARIABLE_NAMES) {
1547 | Ok(token) => match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
1548 | Ok(endpoint) => {
1549 let body = options.body.as_deref().unwrap_or_default();
1550 let params = Params::new()
1551 .with_auth(&token, Some("PRIVATE-TOKEN"))
1552 .with_body("body", body)
1553 .with_template("identifier", options.identifier())
1554 .with_template("internal_identifier", options.internal_identifier.as_deref())
1555 .with_custom(options.params())
1556 .build();
1557 let response = endpoint.invoke_with::<PaginationKey, EmptyField>(action, Some(params)).await;
1558 endpoint.handle::<NoteMetadata>(response)
1559 }
1560 | Err(why) => Err(why),
1561 },
1562 | Err(why) => Err(why),
1563 }
1564}
1565pub async fn runner(options: &Options) -> ApiResult<RunnerMetadata> {
1567 let template = "gitlab::api";
1568 let action = "runner";
1569 let path = format!("{template}::{action}");
1570 match require_non_empty_secret(&options.token, &path, &GITLAB_TOKEN_VARIABLE_NAMES) {
1571 | Ok(token) => match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
1572 | Ok(endpoint) => {
1573 let params = Params::new()
1574 .with_auth(&token, Some("PRIVATE-TOKEN"))
1575 .with_template("identifier", options.identifier())
1576 .with_custom(options.params())
1577 .build();
1578 let response = endpoint.invoke(action, Some(params)).await;
1579 endpoint.handle::<RunnerMetadata>(response)
1580 }
1581 | Err(why) => Err(why),
1582 },
1583 | Err(why) => Err(why),
1584 }
1585}
1586pub async fn runners(options: &Options) -> ApiResult<RunnersResponse> {
1588 let template = "gitlab::api";
1589 let action = "runners";
1590 let path = format!("{template}::{action}");
1591 match require_non_empty_secret(&options.token, &path, &GITLAB_TOKEN_VARIABLE_NAMES) {
1592 | Ok(token) => match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
1593 | Ok(endpoint) => {
1594 let params = Params::new()
1595 .with_auth(&token, Some("PRIVATE-TOKEN"))
1596 .with_custom(options.params())
1597 .build();
1598 let response = endpoint.invoke_with::<PaginationKey, EmptyField>(action, Some(params)).await;
1599 endpoint.handle_or::<RunnersResponse, Fallback<ErrorResponse>>(response)
1600 }
1601 | Err(why) => Err(why),
1602 },
1603 | Err(why) => Err(why),
1604 }
1605}
1606pub(crate) async fn tree_paths(options: &Options) -> ApiResult<TreeResponse> {
1608 let template = "gitlab::api";
1609 let action = "tree";
1610 match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
1611 | Ok(endpoint) => {
1612 let params = Params::new()
1613 .with_template("identifier", options.identifier())
1614 .with_keyvalue("per_page", Some("100"))
1615 .with_keyvalue("page", Some(&options.page.to_string()))
1616 .with_keyvalue("recursive", Some("true"))
1617 .with_keyvalue("path", options.path.as_deref())
1618 .with_custom(options.params());
1619 let token = if options.token.trim().is_empty() {
1620 first_env_var(&GITLAB_TOKEN_VARIABLE_NAMES)
1621 } else {
1622 Some(options.token.clone())
1623 };
1624 let params = match token.as_ref().filter(|v| !v.trim().is_empty()) {
1625 | Some(token) => params.with_auth(token, Some("PRIVATE-TOKEN")),
1626 | None => params,
1627 };
1628 let response = endpoint.invoke(action, Some(params.build())).await;
1629 handle_tree_paths_response(endpoint.handle_or::<TreeResponse, Fallback<ErrorResponse>>(response), options.page)
1630 }
1631 | Err(why) => Err(why),
1632 }
1633}