use crate::io::api::{
require_non_empty_secret, Configuration, DatabasePersistence, EmptyField, Endpoint, Fallback, Identifier, Param, Params, RemoteResource,
RepositoryFileMetadata, ResponseContent, TreeEntry, 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_iso_date_or_rfc3339_timestamp;
use crate::util::constants::env::GITLAB_TOKEN_VARIABLE_NAMES;
use crate::util::{Label, Searchable, SemanticVersion};
use async_trait::async_trait;
use bon::Builder;
use color_eyre::eyre::{self, eyre};
use core::fmt;
use data_encoding::BASE64;
use derive_more::Display;
use futures::future::BoxFuture;
use futures::FutureExt;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use strum::EnumIs;
use tracing::debug;
use validator::Validate;
pub mod bot;
pub mod database;
#[cfg(feature = "analysis")]
pub mod intake;
#[cfg(feature = "analysis")]
pub mod review;
pub mod service;
pub mod webhook;
pub use service::*;
pub use webhook::{HookActor, HookPayload, MergeRequestAction, WebhookDelivery, WebhookOperation, WebhookOperationHandler};
pub type EventsResponse = Vec<EventDetails>;
pub type GitLabIdentity = Identifier<u64>;
pub type GroupsResponse = Vec<GroupDetails>;
pub type MergeRequestDiffsResponse = Vec<MergeRequestDiff>;
pub type NotesResponse = Vec<Note>;
pub type ProgrammingLanguageEntries = Vec<ProgrammingLanguageMetadata>;
pub type ProgrammingLanguageUseEntries = Vec<ProgrammingLanguageUseMetadata>;
pub type ProjectWebhooksResponse = Vec<ProjectWebhook>;
pub type RunnersResponse = Vec<RunnerMetadata>;
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, Copy, Debug, Display, Eq, PartialEq)]
pub enum CommitStatusState {
#[display("running")]
Running,
#[display("success")]
Success,
#[display("failed")]
Failed,
}
#[derive(Clone, Debug, Display, Serialize, Deserialize)]
pub enum Emoji {
#[display(":seedling:")]
Seedling,
}
#[derive(Clone, Debug, EnumIs, 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, Deserialize, Eq, PartialEq, Serialize)]
#[serde(untagged)]
pub enum Note {
WorkItem {
#[serde(rename = "id")]
identifier: u64,
body: String,
author: WorkItemUser,
#[serde(default)]
system: bool,
#[serde(default)]
confidential: bool,
#[serde(default)]
internal: bool,
},
MergeRequest {
#[serde(rename = "id")]
identifier: u64,
body: String,
author: Option<GitLabIdentity>,
},
}
#[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, EnumIs, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum TargetType {
Epic,
Issue,
MergeRequest,
Milestone,
Note,
Project,
Snippet,
User,
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct CommitStatus {
pub name: String,
pub sha: String,
pub status: String,
pub description: Option<String>,
pub target_url: Option<String>,
}
#[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: Option<u64>,
pub target_iid: Option<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,
pub note: Option<NoteMetadata>,
}
#[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>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct InstanceVersion {
pub version: String,
pub revision: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct MergeRequestDetails {
pub iid: u64,
pub project_id: u64,
pub source_project_id: Option<u64>,
pub sha: String,
pub title: String,
#[serde(default)]
pub description: String,
pub web_url: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct MergeRequestDiff {
pub old_path: String,
pub new_path: String,
#[serde(default)]
pub new_file: bool,
#[serde(default)]
pub renamed_file: bool,
#[serde(default)]
pub deleted_file: bool,
#[serde(default)]
pub generated_file: bool,
#[serde(default)]
pub collapsed: bool,
#[serde(default)]
pub too_large: 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: Option<u64>,
pub noteable_iid: Option<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>,
pub sha: 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,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ProjectMember {
#[serde(rename = "id")]
pub identifier: u64,
pub access_level: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ProjectWebhook {
pub id: u64,
pub url: String,
#[serde(default)]
pub merge_requests_events: bool,
#[serde(default)]
pub note_events: bool,
#[serde(default)]
pub enable_ssl_verification: bool,
#[serde(default)]
pub token_present: bool,
#[serde(default)]
pub signing_token_present: bool,
}
#[derive(Serialize)]
struct ProjectWebhookRequest<'a> {
url: &'a str,
name: &'static str,
description: &'static str,
merge_requests_events: bool,
note_events: bool,
enable_ssl_verification: bool,
#[serde(skip_serializing_if = "Option::is_none")]
token: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
signing_token: Option<&'a str>,
}
#[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>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct RepositoryFile {
pub file_path: String,
pub size: u64,
pub encoding: String,
pub content: String,
}
#[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, 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, 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 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>,
}
#[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,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Validate)]
pub struct WebhookOptions {
#[validate(url)]
public_url: Option<String>,
#[validate(required)]
webhook_token: Option<String>,
#[validate(required)]
signing_token: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WebhookRegistration {
pub hook: ProjectWebhook,
pub created: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct WorkItem {
#[serde(rename = "id")]
pub identifier: u64,
pub iid: u64,
pub project_id: u64,
pub title: String,
#[serde(default)]
pub description: String,
pub author: WorkItemUser,
#[serde(default)]
pub issue_type: String,
#[serde(default)]
pub confidential: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct WorkItemUser {
#[serde(rename = "id")]
pub identifier: u64,
pub username: String,
#[serde(default)]
pub bot: bool,
}
impl From<bool> for CommitStatusState {
fn from(success: bool) -> Self {
if success {
Self::Success
} else {
Self::Failed
}
}
}
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 | EventFilterKey::Before => is_iso_date_or_rfc3339_timestamp(value),
| EventFilterKey::Sort => SortValue::try_from(value).is_ok(),
}
}
}
impl InstanceVersion {
pub fn supports_signing_tokens(&self) -> bool {
SemanticVersion::from(self.version.as_str()).major >= 19
}
}
impl From<SemanticVersion> for InstanceVersion {
fn from(version: SemanticVersion) -> Self {
Self {
version: version.to_string(),
revision: None,
}
}
}
impl Note {
pub fn identifier(&self) -> u64 {
match self {
| Self::WorkItem { identifier, .. } | Self::MergeRequest { identifier, .. } => *identifier,
}
}
pub fn body(&self) -> &str {
match self {
| Self::WorkItem { body, .. } | Self::MergeRequest { body, .. } => body,
}
}
pub fn author_id(&self) -> Option<u64> {
match self {
| Self::WorkItem { author, .. } => Some(author.identifier),
| Self::MergeRequest { author, .. } => author.as_ref().map(|author| author.identifier),
}
}
}
impl Options {
pub fn with_internal_identifier(self, value: impl Into<String>) -> Self {
Self {
internal_identifier: Some(value.into()),
..self
}
}
pub fn with_sha(self, value: impl Into<String>) -> Self {
Self {
sha: Some(value.into()),
..self
}
}
pub fn internal_identifier(&self) -> ApiResult<&str> {
self.internal_identifier
.as_deref()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| eyre!("GitLab internal resource identifier is required"))
}
pub fn sha(&self) -> ApiResult<&str> {
self.sha
.as_deref()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| eyre!("GitLab commit SHA is required"))
}
pub fn path(&self) -> ApiResult<&str> {
self.path
.as_deref()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| eyre!("GitLab repository path is required"))
}
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 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(),
sha: var("CI_COMMIT_SHA").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 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 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 | PaginationKey::PerPage => value.parse::<u64>().is_ok(),
| PaginationKey::Sort => SortValue::try_from(value).is_ok(),
| _ => true,
}
}
}
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 }
}
}
#[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<'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 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 ProgrammingLanguageUseResponse {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
HashMap::<String, f64>::deserialize(deserializer).map(Self::parse)
}
}
impl RepositoryFile {
pub fn decoded_content(&self) -> ApiResult<Vec<u8>> {
if self.encoding.eq_ignore_ascii_case("base64") {
let content = self.content.chars().filter(|character| !character.is_whitespace()).collect::<String>();
BASE64
.decode(content.as_bytes())
.map_err(|why| eyre!("Failed to decode GitLab repository file {} — {why}", self.file_path))
} else {
Ok(self.content.as_bytes().to_vec())
}
}
}
impl RepositoryFileMetadata for RepositoryFile {
fn path(&self) -> &str {
&self.file_path
}
fn size(&self) -> Option<u64> {
Some(self.size)
}
}
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 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 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<'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(TreeEntry::is_file).map(TreeEntry::path).collect(),
error: None,
}),
| TreeResponseValue::Error(why) => Ok(Self {
paths: vec![],
error: Some(why),
}),
}
}
}
impl WebhookOptions {
pub fn from_env(public_url: Option<&str>) -> Self {
Self {
public_url: public_url.map(str::to_string),
webhook_token: var("GITLAB_WEBHOOK_TOKEN").ok().filter(|value| !value.trim().is_empty()),
signing_token: var("GITLAB_WEBHOOK_SIGNING_TOKEN").ok().filter(|value| !value.trim().is_empty()),
}
}
pub fn new(public_url: Option<&str>, webhook_token: Option<&str>, signing_token: Option<&str>) -> Self {
Self {
public_url: public_url.map(str::to_string),
webhook_token: webhook_token.map(str::to_string),
signing_token: signing_token.map(str::to_string),
}
}
fn url(&self) -> Option<String> {
self.public_url
.as_deref()
.map(|public_url| format!("{}/webhooks/gitlab", public_url.trim_end_matches('/')))
}
fn credentials(&self, supports_signing: bool) -> (Option<&str>, Option<&str>) {
let signing_token = supports_signing.then_some(self.signing_token.as_deref()).flatten();
let webhook_token = signing_token.is_none().then_some(self.webhook_token.as_deref()).flatten();
(webhook_token, signing_token)
}
fn for_registration(&self, supports_signing: bool) -> Self {
let (webhook_token, signing_token) = self.credentials(supports_signing);
Self {
public_url: self.url(),
webhook_token: webhook_token.map(str::to_string),
signing_token: signing_token.map(str::to_string),
}
}
}
impl WorkItemUser {
pub fn new(identifier: u64, username: impl Into<String>, bot: bool) -> Self {
Self {
identifier,
username: username.into(),
bot,
}
}
}
#[cfg(test)]
mod tests;