use crate::io::api::{
require_non_empty_secret, Configuration, DatabasePersistence, EmptyField, Endpoint, Fallback, Param, Params, RemoteResource, ResponseContent,
TreeEntryType, ValueValidator, INCLUDED_ENDPOINTS,
};
use crate::io::config::{RunnerDetails, RunnerStatus, RunnerType};
use crate::io::database::schema::{ProgrammingLanguageRow, Table};
use crate::io::database::{Database, Operations};
use crate::io::{first_env_var, with_progress, ApiResult, ProgressType};
use crate::prelude::var;
use crate::prelude::HashMap;
use crate::schema::validate::is_date;
use crate::util::constants::env::GITLAB_TOKEN_VARIABLE_NAMES;
use crate::util::{Label, Searchable};
use async_trait::async_trait;
use bon::Builder;
use color_eyre::eyre::{self, eyre};
use core::fmt;
use derive_more::Display;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use tracing::debug;
pub mod bot;
pub type EventsResponse = Vec<EventDetails>;
pub type GroupsResponse = Vec<GroupDetails>;
pub type RunnersResponse = Vec<RunnerMetadata>;
pub type ProgrammingLanguageEntries = Vec<ProgrammingLanguageMetadata>;
pub type ProgrammingLanguageUseEntries = Vec<ProgrammingLanguageUseMetadata>;
pub trait Create {
fn create(_options: &Options) -> ApiResult<Self>
where
Self: Sized,
{
Err(eyre!("GitLab struct creation is not implemented"))
}
fn register(self) -> ApiResult<Self>
where
Self: Sized,
{
Err(eyre!("GitLab struct registration is not implemented"))
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AccessLevel {
NotProtected,
RefProtected,
}
#[derive(Clone, Debug, Display, Serialize, Deserialize)]
pub enum Emoji {
#[display(":seedling:")]
Seedling,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum EventAction {
#[serde(rename = "approved")]
Approved,
#[serde(rename = "closed")]
Closed,
#[serde(rename = "commented")]
Commented,
#[serde(rename = "commented on")]
CommentedOn,
#[serde(rename = "created")]
Created,
#[serde(rename = "destroyed")]
Destroyed,
#[serde(rename = "expired")]
Expired,
#[serde(rename = "joined")]
Joined,
#[serde(rename = "left")]
Left,
#[serde(rename = "merged")]
Merged,
#[serde(rename = "pushed")]
Pushed,
#[serde(rename = "pushed to")]
PushedTo,
#[serde(rename = "reopened")]
Reopened,
#[serde(rename = "updated")]
Updated,
#[serde(rename = "deleted")]
Deleted,
#[serde(rename = "accepted")]
Accepted,
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventFilterKey {
Action,
TargetType,
After,
Before,
Sort,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GroupVisibility {
#[default]
Public,
Internal,
Private,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OrderByValue {
CreatedAt,
FullName,
#[serde(rename = "id")]
Identifier,
LabelPriority,
LastActivityAt,
MilestoneDue,
Name,
Path,
Popularity,
DueDate,
Priority,
RelativePosition,
Similarity,
Title,
UpdatedAt,
Weight,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaginationKey {
OrderBy,
Page,
Pagination,
PerPage,
Sort,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SortValue {
#[default]
#[serde(rename = "desc")]
Descending,
#[serde(rename = "asc")]
Ascending,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum TargetType {
Epic,
Issue,
MergeRequest,
Milestone,
Note,
Project,
Snippet,
User,
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ErrorResponse {
message: Option<serde_json::Value>,
error: Option<String>,
error_description: Option<String>,
}
#[skip_serializing_none]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EventDetails {
#[serde(rename = "id")]
pub identifier: u64,
pub project_id: u64,
pub action_name: EventAction,
pub target_id: u64,
pub target_iid: u64,
pub target_type: TargetType,
pub author_id: u64,
pub target_title: String,
pub created_at: String,
pub author: UserMetadata,
pub imported: bool,
pub imported_from: String,
pub push_data: Option<PushData>,
pub author_username: String,
}
#[skip_serializing_none]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GroupDetails {
#[serde(rename = "id")]
pub identifier: u64,
#[serde(rename = "web_url")]
pub url: String,
pub name: String,
pub path: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub emails_disabled: bool,
#[serde(default)]
pub emails_enabled: bool,
#[serde(default)]
pub show_diff_preview_in_email: bool,
pub visibility: Option<GroupVisibility>,
#[serde(default)]
pub share_with_group_lock: bool,
#[serde(default)]
pub require_two_factor_authentication: bool,
#[serde(default)]
pub lfs_enabled: bool,
#[serde(default)]
pub archived: bool,
#[serde(default)]
pub duo_features_enabled: bool,
#[serde(default)]
pub lock_duo_features_enabled: bool,
#[serde(default)]
pub auto_duo_code_review_enabled: bool,
#[serde(default)]
pub math_rendering_limits_enabled: bool,
#[serde(default)]
pub lock_math_rendering_limits_enabled: bool,
#[serde(default)]
pub request_access_enabled: bool,
pub two_factor_grace_period: Option<u64>,
pub project_creation_level: Option<String>,
pub auto_devops_enabled: Option<bool>,
pub subgroup_creation_level: Option<String>,
pub mentions_disabled: Option<bool>,
pub default_branch: Option<String>,
pub default_branch_protection: Option<u64>,
pub default_branch_protection_defaults: Option<RunnerGroupBranchProtectionDefaults>,
#[serde(rename = "avatar_url")]
pub avatar_url: Option<String>,
pub full_name: Option<String>,
pub full_path: Option<String>,
pub created_at: Option<String>,
pub parent_id: Option<u64>,
pub organization_id: Option<u64>,
pub shared_runners_setting: Option<String>,
pub max_artifacts_size: Option<u64>,
pub marked_for_deletion_on: Option<String>,
#[serde(rename = "ldap_cn")]
pub ldap_common_name: Option<String>,
pub ldap_access: Option<String>,
pub file_template_project_id: Option<u64>,
pub wiki_access_level: Option<String>,
pub duo_core_features_enabled: Option<bool>,
}
#[skip_serializing_none]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NoteMetadata {
#[serde(rename = "id")]
pub identifier: u64,
#[serde(rename = "type")]
pub note_type: Option<String>,
pub body: String,
pub author: UserMetadata,
pub created_at: String,
pub updated_at: String,
pub system: bool,
pub noteable_id: u64,
pub noteable_iid: u64,
pub noteable_type: String,
pub project_id: u64,
pub resolvable: bool,
pub confidential: bool,
pub internal: bool,
pub imported: bool,
pub imported_from: String,
pub commands_changes: serde_json::Value,
}
#[derive(Builder, Clone, Debug)]
#[builder(start_fn = with_token, on(String, into))]
pub struct Options {
#[builder(start_fn)]
pub token: String,
pub body: Option<String>,
#[builder(default = String::from("gitlab.com"))]
pub domain: String,
pub identifier: Option<String>,
pub path: Option<String>,
#[builder(default = 1)]
pub page: u32,
pub internal_identifier: Option<String>,
#[builder(default = RunnerMetadata::default())]
pub runner_metadata: RunnerMetadata,
#[builder(default = vec![])]
pub custom_params: Vec<Param>,
}
#[skip_serializing_none]
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ProgrammingLanguageDetails {
pub language_id: Option<u64>,
#[serde(rename = "type")]
pub language_type: Option<String>,
pub color: Option<String>,
pub group: Option<String>,
}
#[skip_serializing_none]
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ProgrammingLanguageMetadata {
pub name: String,
pub language_id: Option<u64>,
pub language_type: Option<String>,
pub color: Option<String>,
pub group: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ProgrammingLanguagesResponse {
pub languages: ProgrammingLanguageEntries,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ProgrammingLanguageUseMetadata {
pub name: String,
pub percentage: f64,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ProgrammingLanguageUseResponse {
pub languages: ProgrammingLanguageUseEntries,
}
#[skip_serializing_none]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PushData {
pub commit_count: u64,
pub action: EventAction,
pub ref_type: String,
pub commit_from: Option<String>,
pub commit_to: Option<String>,
#[serde(rename = "ref")]
pub ref_name: String,
pub commit_title: Option<String>,
pub ref_count: Option<u64>,
}
#[skip_serializing_none]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RunnerCreationResponse {
#[serde(default)]
#[serde(rename = "id")]
pub identifier: u64,
pub token: Option<String>,
pub token_expires_at: Option<String>,
}
#[skip_serializing_none]
#[derive(Clone, Debug, Builder, Serialize, Deserialize)]
#[builder(start_fn = init, on(String, into), on(&str, into))]
pub struct RunnerMetadata {
#[serde(rename = "id")]
pub identifier: Option<u64>,
#[builder(default)]
#[serde(default)]
pub active: bool,
#[serde(default)]
pub online: Option<bool>,
#[builder(default)]
#[serde(default)]
pub paused: bool,
#[builder(default)]
#[serde(default)]
pub run_untagged: bool,
#[builder(default)]
#[serde(default, rename = "is_shared")]
pub shared: bool,
pub architecture: Option<String>,
pub description: Option<String>,
pub ip_address: Option<String>,
#[builder(with = |value: &str| RunnerType::from(value))]
#[builder(default = RunnerType::Project)]
pub runner_type: RunnerType,
pub created_by: Option<UserMetadata>,
pub created_at: Option<String>,
pub contacted_at: Option<String>,
pub maintenance_note: Option<String>,
pub name: Option<String>,
pub status: Option<RunnerStatus>,
pub job_execution_status: Option<String>,
pub platform: Option<String>,
pub projects: Option<Vec<RunnerScope>>,
pub groups: Option<Vec<RunnerScope>>,
pub revision: Option<String>,
#[builder(with = |values: &[&str]| values.iter().map(|s| s.to_string()).collect::<Vec<String>>())]
#[serde(rename = "tag_list")]
pub tags: Option<Vec<String>>,
pub version: Option<String>,
pub access_level: Option<AccessLevel>,
pub maximum_timeout: Option<u64>,
}
#[skip_serializing_none]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RunnerGroupAccessLevel {
pub access_level: u64,
}
#[skip_serializing_none]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RunnerGroupBranchProtectionDefaults {
pub allowed_to_push: Vec<RunnerGroupAccessLevel>,
pub allow_force_push: bool,
pub allowed_to_merge: Vec<RunnerGroupAccessLevel>,
}
#[skip_serializing_none]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RunnerScope {
#[serde(rename = "id")]
pub identifier: u64,
pub name: String,
pub path: Option<String>,
pub name_with_namespace: Option<String>,
pub path_with_namespace: Option<String>,
#[serde(rename = "web_url")]
pub url: Option<String>,
}
#[skip_serializing_none]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TreeEntry {
pub id: String,
pub name: String,
#[serde(rename = "type")]
pub entry_type: TreeEntryType,
pub path: String,
pub mode: String,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct TreeResponse {
pub paths: Vec<String>,
#[serde(skip_serializing)]
pub(crate) error: Option<ErrorResponse>,
}
#[skip_serializing_none]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserMetadata {
pub avatar_url: String,
#[serde(rename = "id")]
pub identifier: u64,
pub locked: bool,
pub name: String,
#[serde(rename = "public_email")]
pub email: Option<String>,
pub state: String,
pub username: String,
#[serde(rename = "web_url")]
pub url: String,
}
impl ErrorResponse {
fn is_terminal_pagination_message(message: &str) -> bool {
let message = message.to_lowercase();
let invalid_page =
message.contains("page") && (message.contains("invalid") || message.contains("out of range") || message.contains("not found"));
let forbidden_page = message.contains("403") && message.contains("forbidden");
invalid_page || forbidden_page
}
fn is_terminal_pagination_error(&self) -> bool {
Self::is_terminal_pagination_message(&self.message())
}
fn message(&self) -> String {
let message = self
.message
.as_ref()
.and_then(|value| serde_json::to_string(value).ok())
.unwrap_or_default();
let error = self.error.clone().unwrap_or_default();
let description = self.error_description.clone().unwrap_or_default();
[message, error, description]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join(" ")
}
}
impl fmt::Display for EventAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
| EventAction::Approved => "approved",
| EventAction::Closed => "closed",
| EventAction::Commented => "commented",
| EventAction::CommentedOn => "commented on",
| EventAction::Created => "created",
| EventAction::Destroyed => "destroyed",
| EventAction::Expired => "expired",
| EventAction::Joined => "joined",
| EventAction::Left => "left",
| EventAction::Merged => "merged",
| EventAction::Pushed => "pushed",
| EventAction::PushedTo => "pushed to",
| EventAction::Reopened => "reopened",
| EventAction::Updated => "updated",
| EventAction::Deleted => "deleted",
| EventAction::Accepted => "accepted",
| EventAction::Unknown => "unknown",
};
write!(f, "{}", s)
}
}
impl core::str::FromStr for EventAction {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
| "approved" => Ok(EventAction::Approved),
| "closed" => Ok(EventAction::Closed),
| "commented" => Ok(EventAction::Commented),
| "commented on" => Ok(EventAction::CommentedOn),
| "created" => Ok(EventAction::Created),
| "destroyed" => Ok(EventAction::Destroyed),
| "expired" => Ok(EventAction::Expired),
| "joined" => Ok(EventAction::Joined),
| "left" => Ok(EventAction::Left),
| "merged" => Ok(EventAction::Merged),
| "pushed" => Ok(EventAction::Pushed),
| "pushed to" => Ok(EventAction::PushedTo),
| "reopened" => Ok(EventAction::Reopened),
| "updated" => Ok(EventAction::Updated),
| "deleted" => Ok(EventAction::Deleted),
| "accepted" => Ok(EventAction::Accepted),
| _ => Err(format!("Invalid GitLab event action value: {value}")),
}
}
}
impl TryFrom<&str> for EventAction {
type Error = String;
fn try_from(value: &str) -> Result<Self, Self::Error> {
value.parse()
}
}
impl fmt::Display for EventFilterKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
| EventFilterKey::Action => "action",
| EventFilterKey::TargetType => "target_type",
| EventFilterKey::After => "after",
| EventFilterKey::Before => "before",
| EventFilterKey::Sort => "sort",
};
write!(f, "{}", s)
}
}
impl TryFrom<&str> for EventFilterKey {
type Error = String;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value {
| "action" => Ok(EventFilterKey::Action),
| "target_type" => Ok(EventFilterKey::TargetType),
| "after" => Ok(EventFilterKey::After),
| "before" => Ok(EventFilterKey::Before),
| "sort" => Ok(EventFilterKey::Sort),
| _ => Err(format!("Invalid EventFilterKey: {}", value)),
}
}
}
impl ValueValidator for EventFilterKey {
fn is_valid(&self, value: &str) -> bool {
match self {
| EventFilterKey::Action => EventAction::try_from(value).is_ok(),
| EventFilterKey::TargetType => TargetType::try_from(value).is_ok(),
| EventFilterKey::After => is_date(value).is_ok(),
| EventFilterKey::Before => is_date(value).is_ok(),
| EventFilterKey::Sort => SortValue::try_from(value).is_ok(),
}
}
}
impl Configuration for Options {
fn from_env() -> Self {
if let Err(why) = dotenvy::from_filename(".env") {
debug!("=> {} Load .env — {why}", Label::skip());
}
Self {
token: first_env_var(&GITLAB_TOKEN_VARIABLE_NAMES).unwrap_or_default(),
identifier: var("CI_PROJECT_ID").ok(),
internal_identifier: var("CI_MERGE_REQUEST_IID").ok(),
domain: var("CI_SERVER_HOST").unwrap_or_else(|_| "gitlab.com".to_string()),
body: None,
path: None,
page: 1,
runner_metadata: RunnerMetadata::default(),
custom_params: vec![],
}
}
fn with_body(self, value: impl Into<String>) -> Self {
Self {
body: Some(value.into()),
..self
}
}
fn with_domain(self, value: impl Into<String>) -> Self {
Self {
domain: value.into(),
..self
}
}
fn with_identifier(self, value: impl Into<String>) -> Self {
Self {
identifier: Some(value.into()),
..self
}
}
fn token(&self) -> &str {
&self.token
}
fn domain(&self) -> &str {
&self.domain
}
fn identifier(&self) -> Option<&str> {
self.identifier.as_deref()
}
fn with_params(self, params: Vec<Param>) -> Self {
Self {
custom_params: params,
..self
}
}
fn params(&self) -> &[Param] {
&self.custom_params
}
}
impl Options {
pub fn with_page(self, value: u32) -> Self {
Self { page: value, ..self }
}
pub fn with_path(self, value: impl Into<String>) -> Self {
Self {
path: Some(value.into()),
..self
}
}
pub fn with_runner(self, metadata: RunnerMetadata) -> Self {
Self {
runner_metadata: metadata,
..self
}
}
}
impl Default for Options {
fn default() -> Self {
Self::from_env()
}
}
impl TryFrom<&str> for OrderByValue {
type Error = String;
fn try_from(value: &str) -> eyre::Result<Self, Self::Error> {
match value {
| "created_at" => Ok(OrderByValue::CreatedAt),
| "due_date" => Ok(OrderByValue::DueDate),
| "full_name" => Ok(OrderByValue::FullName),
| "id" => Ok(OrderByValue::Identifier),
| "label_priority" => Ok(OrderByValue::LabelPriority),
| "last_activity_at" => Ok(OrderByValue::LastActivityAt),
| "milestone_due" => Ok(OrderByValue::MilestoneDue),
| "name" => Ok(OrderByValue::Name),
| "path" => Ok(OrderByValue::Path),
| "popularity" => Ok(OrderByValue::Popularity),
| "priority" => Ok(OrderByValue::Priority),
| "relative_position" => Ok(OrderByValue::RelativePosition),
| "similarity" => Ok(OrderByValue::Similarity),
| "title" => Ok(OrderByValue::Title),
| "updated_at" => Ok(OrderByValue::UpdatedAt),
| "weight" => Ok(OrderByValue::Weight),
| _ => Err(format!("Invalid GitLab order_by value: {value}")),
}
}
}
impl From<ProgrammingLanguageMetadata> for ProgrammingLanguageRow {
fn from(value: ProgrammingLanguageMetadata) -> Self {
let ProgrammingLanguageMetadata {
name,
language_id,
language_type,
color,
group,
} = value;
ProgrammingLanguageRow::init()
.name(name)
.maybe_language_id(language_id.and_then(|value| i64::try_from(value).ok()))
.maybe_language_type(language_type)
.maybe_color(color)
.maybe_group_name(group)
.build()
}
}
impl ProgrammingLanguagesResponse {
pub fn parse(data: HashMap<String, ProgrammingLanguageDetails>) -> Self {
let languages = data
.into_iter()
.filter_map(|(name, details)| {
details
.language_type
.as_ref()
.map(|kind| kind.eq_ignore_ascii_case("programming"))
.filter(|is_programming| *is_programming)
.map(|_| ProgrammingLanguageMetadata {
name,
language_id: details.language_id,
language_type: details.language_type,
color: details.color,
group: details.group,
})
})
.collect();
Self { languages }
}
}
impl ProgrammingLanguageUseResponse {
pub fn parse(data: HashMap<String, f64>) -> Self {
let mut languages = data
.into_iter()
.map(|(name, percentage)| ProgrammingLanguageUseMetadata { name, percentage })
.collect::<ProgrammingLanguageUseEntries>();
languages.sort_by(|a, b| a.name.cmp(&b.name));
Self { languages }
}
pub fn entries(&self) -> Vec<(String, f64)> {
let mut entries = self
.languages
.iter()
.map(|ProgrammingLanguageUseMetadata { name, percentage }| (name.clone(), *percentage))
.collect::<Vec<_>>();
entries.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(core::cmp::Ordering::Equal));
entries
}
pub fn names(&self) -> Vec<String> {
self.entries().into_iter().map(|(name, _)| name).collect()
}
}
impl<'de> serde::Deserialize<'de> for ProgrammingLanguagesResponse {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
HashMap::<String, ProgrammingLanguageDetails>::deserialize(deserializer).map(Self::parse)
}
}
impl<'de> serde::Deserialize<'de> for ProgrammingLanguageUseResponse {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
HashMap::<String, f64>::deserialize(deserializer).map(Self::parse)
}
}
#[async_trait]
impl DatabasePersistence for ProgrammingLanguagesResponse {
async fn persist(self, database: Database<Table>) -> ApiResult<usize> {
let Self { languages } = self;
let message: fn(&ProgrammingLanguageMetadata) -> String = |item| format!("Saving \"{}\" language metadata", item.name);
let operation = |item| async { database.insert(ProgrammingLanguageRow::from(item)) };
let finish = |count| format!("{}Saved metadata for {count} programming languages", Label::CHECKMARK);
with_progress(languages, message, operation, finish, None, ProgressType::Bar)
.await
.map(|counts| counts.into_iter().sum())
.map_err(eyre::Report::msg)
}
}
impl fmt::Display for PaginationKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
| PaginationKey::OrderBy => "order_by",
| PaginationKey::Page => "page",
| PaginationKey::Pagination => "pagination",
| PaginationKey::PerPage => "per_page",
| PaginationKey::Sort => "sort",
};
write!(f, "{}", s)
}
}
impl TryFrom<&str> for PaginationKey {
type Error = String;
fn try_from(value: &str) -> eyre::Result<Self, Self::Error> {
match value {
| "order_by" => Ok(PaginationKey::OrderBy),
| "page" => Ok(PaginationKey::Page),
| "pagination" => Ok(PaginationKey::Pagination),
| "per_page" => Ok(PaginationKey::PerPage),
| "sort" => Ok(PaginationKey::Sort),
| _ => Err(format!("Invalid GitLab pagination field: {value}")),
}
}
}
impl ValueValidator for PaginationKey {
fn is_valid(&self, value: &str) -> bool {
match self {
| PaginationKey::OrderBy => OrderByValue::try_from(value).is_ok(),
| PaginationKey::Page => value.parse::<u64>().is_ok(),
| PaginationKey::PerPage => value.parse::<u64>().is_ok(),
| PaginationKey::Sort => SortValue::try_from(value).is_ok(),
| _ => true,
}
}
}
impl Default for RunnerMetadata {
fn default() -> Self {
Self::init().build()
}
}
impl From<RunnerDetails> for RunnerMetadata {
fn from(value: RunnerDetails) -> Self {
let RunnerDetails {
name,
runner_type,
description,
tags,
..
} = value;
Self {
access_level: None,
active: false,
architecture: None,
contacted_at: None,
created_at: None,
created_by: None,
description,
groups: None,
identifier: None,
ip_address: None,
job_execution_status: None,
paused: false,
maintenance_note: None,
maximum_timeout: None,
name,
online: Some(false),
platform: None,
projects: None,
revision: None,
shared: matches!(runner_type, RunnerType::Instance),
runner_type,
run_untagged: false,
status: None,
tags,
version: None,
}
}
}
impl RunnerMetadata {
pub fn is_available(&self) -> bool {
let Self { active, online, paused, .. } = self;
*active && online.unwrap_or(false) && !*paused
}
pub fn with_identifier(self, value: u64) -> Self {
Self {
identifier: Some(value),
..self
}
}
}
impl TryFrom<&str> for SortValue {
type Error = String;
fn try_from(value: &str) -> eyre::Result<Self, Self::Error> {
match value {
| "asc" => Ok(SortValue::Ascending),
| "desc" => Ok(SortValue::Descending),
| _ => Err(format!("Invalid GitLab sort order: {value}")),
}
}
}
impl fmt::Display for TargetType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
| TargetType::Epic => "epic",
| TargetType::Issue => "issue",
| TargetType::MergeRequest => "merge_request",
| TargetType::Milestone => "milestone",
| TargetType::Note => "note",
| TargetType::Project => "project",
| TargetType::Snippet => "snippet",
| TargetType::User => "user",
| TargetType::Unknown => "unknown",
};
write!(f, "{}", s)
}
}
impl core::str::FromStr for TargetType {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_lowercase().as_str() {
| "epic" => Ok(TargetType::Epic),
| "issue" => Ok(TargetType::Issue),
| "merge_request" | "mergerequest" => Ok(TargetType::MergeRequest),
| "milestone" => Ok(TargetType::Milestone),
| "note" => Ok(TargetType::Note),
| "project" => Ok(TargetType::Project),
| "snippet" => Ok(TargetType::Snippet),
| "user" => Ok(TargetType::User),
| _ => Err(format!("Invalid GitLab target type value: {value}")),
}
}
}
impl TryFrom<&str> for TargetType {
type Error = String;
fn try_from(value: &str) -> Result<Self, Self::Error> {
value.parse()
}
}
impl TreeEntry {
pub fn path(self) -> String {
self.path
}
pub fn is_blob(&self) -> bool {
let Self { entry_type, .. } = self;
entry_type.eq(&TreeEntryType::Blob)
}
}
impl<'de> serde::Deserialize<'de> for TreeResponse {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum TreeResponseValue {
Entries(Vec<TreeEntry>),
Error(ErrorResponse),
}
match TreeResponseValue::deserialize(deserializer)? {
| TreeResponseValue::Entries(entries) => Ok(Self {
paths: entries.into_iter().filter(|entry| entry.is_blob()).map(|entry| entry.path()).collect(),
error: None,
}),
| TreeResponseValue::Error(why) => Ok(Self {
paths: vec![],
error: Some(why),
}),
}
}
}
pub(crate) fn handle_tree_paths_response(response: ApiResult<TreeResponse>, page: u32) -> ApiResult<TreeResponse> {
match response {
| Ok(value) => match value.error {
| Some(why) if page > 1 && why.is_terminal_pagination_error() => Ok(TreeResponse::default()),
| Some(why) => Err(eyre!(why.message())),
| None => Ok(value),
},
| Err(why) => Err(why),
}
}
pub async fn create_runner(options: &Options) -> ApiResult<RunnerCreationResponse> {
#[derive(Deserialize)]
struct StrictRunnerCreationResponse {
#[serde(rename = "id")]
identifier: u64,
token: Option<String>,
token_expires_at: Option<String>,
}
let template = "gitlab::api";
let action = "runner::create";
let path = format!("{template}::{action}");
let runner_metadata = &options.runner_metadata;
let runner_type = &runner_metadata.runner_type;
let description = runner_metadata.description.as_deref().unwrap_or_default();
let tags = runner_metadata.tags.as_deref().unwrap_or_default();
let run_untagged = runner_metadata.run_untagged;
let tag_list = if tags.is_empty() { None } else { Some(tags.join(",")) };
match require_non_empty_secret(&options.token, &path, &GITLAB_TOKEN_VARIABLE_NAMES) {
| Ok(token) => match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
| Ok(endpoint) => {
let params = Params::new()
.with_auth(&token, Some("PRIVATE-TOKEN"))
.with_body("description", description)
.with_body(&format!("{runner_type}_id"), options.identifier.as_deref().unwrap_or_default())
.with_body("runner_type", &format!("{runner_type}_type"))
.with_body("run_untagged", &run_untagged.to_string())
.with_body_maybe("tag_list", tag_list.as_deref())
.with_custom(options.params())
.build();
let response = endpoint.invoke(action, Some(params)).await;
match response {
| Ok(ResponseContent::Json(content)) => match serde_json::from_str::<StrictRunnerCreationResponse>(&content) {
| Ok(parsed) => Ok(RunnerCreationResponse {
identifier: parsed.identifier,
token: parsed.token,
token_expires_at: parsed.token_expires_at,
}),
| Err(_) => {
let rendered = serde_json::from_str::<serde_json::Value>(&content)
.ok()
.and_then(|value| serde_json::to_string_pretty(&value).ok())
.unwrap_or(content);
Err(eyre!("{rendered}"))
}
},
| Ok(other) => endpoint.handle::<RunnerCreationResponse>(Ok(other)),
| Err(why) => Err(why),
}
}
| Err(why) => Err(why),
},
| Err(why) => Err(why),
}
}
pub async fn events(options: &Options) -> ApiResult<EventsResponse> {
let template = "gitlab::api";
let action = "events";
let path = format!("{template}::{action}");
match require_non_empty_secret(&options.token, &path, &GITLAB_TOKEN_VARIABLE_NAMES) {
| Ok(token) => match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
| Ok(endpoint) => {
let params = Params::new()
.with_auth(&token, Some("PRIVATE-TOKEN"))
.with_template("identifier", options.identifier())
.with_custom(options.params())
.build();
let response = endpoint.invoke_with::<EventFilterKey, EmptyField>(action, Some(params)).await;
endpoint.handle::<EventsResponse>(response)
}
| Err(why) => Err(why),
},
| Err(why) => Err(why),
}
}
pub async fn groups(options: &Options) -> ApiResult<GroupsResponse> {
let template = "gitlab::api";
let action = "groups";
let path = format!("{template}::{action}");
match require_non_empty_secret(&options.token, &path, &GITLAB_TOKEN_VARIABLE_NAMES) {
| Ok(token) => match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
| Ok(endpoint) => {
let params = Params::new()
.with_auth(&token, Some("PRIVATE-TOKEN"))
.with_template("identifier", options.identifier())
.with_keyvalue("page", Some("1"))
.with_keyvalue("per_page", Some("100"))
.with_custom(options.params())
.build();
let response = endpoint.invoke_with::<PaginationKey, EmptyField>(action, Some(params)).await;
endpoint.handle_or::<GroupsResponse, Fallback<ErrorResponse>>(response)
}
| Err(why) => Err(why),
},
| Err(why) => Err(why),
}
}
pub async fn language_use(options: &Options) -> ApiResult<ProgrammingLanguageUseResponse> {
let template = "gitlab::api";
let action = "languages";
let path = format!("{template}::{action}");
match require_non_empty_secret(&options.token, &path, &GITLAB_TOKEN_VARIABLE_NAMES) {
| Ok(token) => match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
| Ok(endpoint) => {
let params = Params::new()
.with_auth(&token, Some("PRIVATE-TOKEN"))
.with_template("identifier", options.identifier())
.with_custom(options.params())
.build();
let response = endpoint.invoke_with::<PaginationKey, EmptyField>(action, Some(params)).await;
endpoint.handle::<ProgrammingLanguageUseResponse>(response)
}
| Err(why) => Err(why),
},
| Err(why) => Err(why),
}
}
pub async fn languages() -> ApiResult<ProgrammingLanguagesResponse> {
let names = ["gitlab::org", "github::org"];
let action = "languages";
async fn fetch(name: &str, action: &str) -> ApiResult<ProgrammingLanguagesResponse> {
match INCLUDED_ENDPOINTS.find_by_name(name) {
| Some(endpoint) => {
let response = endpoint.invoke(action, None).await.map(|content| match content {
| ResponseContent::Raw(content) => ResponseContent::Yaml(content),
| other => other,
});
endpoint.handle::<ProgrammingLanguagesResponse>(response)
}
| None => Err(eyre!("{name} API endpoint not found")),
}
}
let mut response: Option<ProgrammingLanguagesResponse> = None;
let mut errors = Vec::new();
for name in names {
let path = format!("{name}::{action}");
match fetch(name, action).await {
| Ok(value) => {
response = Some(value);
break;
}
| Err(why) => errors.push(format!("{path}={why}")),
}
}
match response {
| Some(value) => Ok(value),
| None => Err(eyre!("Failed to download and parse language metadata — {}", errors.join("; "))),
}
}
pub async fn merge_request_note(options: &Options) -> ApiResult<NoteMetadata> {
let template = "gitlab::api";
let action = "merge-request-note";
let path = format!("{template}::{action}");
match require_non_empty_secret(&options.token, &path, &GITLAB_TOKEN_VARIABLE_NAMES) {
| Ok(token) => match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
| Ok(endpoint) => {
let body = options.body.as_deref().unwrap_or_default();
let params = Params::new()
.with_auth(&token, Some("PRIVATE-TOKEN"))
.with_body("body", body)
.with_template("identifier", options.identifier())
.with_template("internal_identifier", options.internal_identifier.as_deref())
.with_custom(options.params())
.build();
let response = endpoint.invoke_with::<PaginationKey, EmptyField>(action, Some(params)).await;
endpoint.handle::<NoteMetadata>(response)
}
| Err(why) => Err(why),
},
| Err(why) => Err(why),
}
}
pub async fn runner(options: &Options) -> ApiResult<RunnerMetadata> {
let template = "gitlab::api";
let action = "runner";
let path = format!("{template}::{action}");
match require_non_empty_secret(&options.token, &path, &GITLAB_TOKEN_VARIABLE_NAMES) {
| Ok(token) => match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
| Ok(endpoint) => {
let params = Params::new()
.with_auth(&token, Some("PRIVATE-TOKEN"))
.with_template("identifier", options.identifier())
.with_custom(options.params())
.build();
let response = endpoint.invoke(action, Some(params)).await;
endpoint.handle::<RunnerMetadata>(response)
}
| Err(why) => Err(why),
},
| Err(why) => Err(why),
}
}
pub async fn runners(options: &Options) -> ApiResult<RunnersResponse> {
let template = "gitlab::api";
let action = "runners";
let path = format!("{template}::{action}");
match require_non_empty_secret(&options.token, &path, &GITLAB_TOKEN_VARIABLE_NAMES) {
| Ok(token) => match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
| Ok(endpoint) => {
let params = Params::new()
.with_auth(&token, Some("PRIVATE-TOKEN"))
.with_custom(options.params())
.build();
let response = endpoint.invoke_with::<PaginationKey, EmptyField>(action, Some(params)).await;
endpoint.handle_or::<RunnersResponse, Fallback<ErrorResponse>>(response)
}
| Err(why) => Err(why),
},
| Err(why) => Err(why),
}
}
pub(crate) async fn tree_paths(options: &Options) -> ApiResult<TreeResponse> {
let template = "gitlab::api";
let action = "tree";
match Endpoint::from_template(template).map(|e| e.with_domain(options.domain())) {
| Ok(endpoint) => {
let params = Params::new()
.with_template("identifier", options.identifier())
.with_keyvalue("per_page", Some("100"))
.with_keyvalue("page", Some(&options.page.to_string()))
.with_keyvalue("recursive", Some("true"))
.with_keyvalue("path", options.path.as_deref())
.with_custom(options.params());
let token = if options.token.trim().is_empty() {
first_env_var(&GITLAB_TOKEN_VARIABLE_NAMES)
} else {
Some(options.token.clone())
};
let params = match token.as_ref().filter(|v| !v.trim().is_empty()) {
| Some(token) => params.with_auth(token, Some("PRIVATE-TOKEN")),
| None => params,
};
let response = endpoint.invoke(action, Some(params.build())).await;
handle_tree_paths_response(endpoint.handle_or::<TreeResponse, Fallback<ErrorResponse>>(response), options.page)
}
| Err(why) => Err(why),
}
}