use std::sync::Arc;
use gitlab::{AsyncGitlab, GitlabBuilder};
use crate::env_helpers::env_first;
pub(crate) struct GitLabHandles {
pub(crate) client: Arc<AsyncGitlab>,
pub(crate) project: cursus::forge::gitlab::GitLabProject,
pub(crate) uses_job_token_only: bool,
}
pub(crate) struct GitLabClientOutcome {
pub(crate) client: Arc<dyn cursus::forge::CodeForgeClient>,
pub(crate) uses_job_token_only: bool,
}
#[cfg_attr(coverage_nightly, coverage(off))]
pub(crate) async fn gitlab_handles(
git: &dyn cursus::git::Git,
config: &Option<cursus::model::config::Config>,
) -> Result<GitLabHandles, String> {
let cfg = config
.as_ref()
.ok_or_else(|| "No configuration file found".to_string())?;
let resolved = cursus::forge::gitlab::GitLabProject::resolve(&cfg.gitlab, git)
.await
.map_err(|e| format!("{e:#}"))?;
let (scheme, host) = gitlab_endpoint(&cfg.gitlab.host);
validate_gitlab_host(&host)?;
let project = pin_endpoint_on_project(&scheme, &host, resolved);
let (token, token_kind, uses_job_token_only) = pick_gitlab_token()?;
let async_client = build_async_gitlab(&host, &scheme, &token, token_kind).await?;
Ok(GitLabHandles {
client: Arc::new(async_client),
project,
uses_job_token_only,
})
}
pub(super) fn pin_endpoint_on_project(
scheme: &str,
host: &str,
resolved: cursus::forge::gitlab::GitLabProject,
) -> cursus::forge::gitlab::GitLabProject {
cursus::forge::gitlab::GitLabProject {
host: host.to_string(),
..resolved.with_scheme(scheme)
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn pick_gitlab_token() -> Result<(String, cursus::forge::gitlab::GitLabTokenKind, bool), String> {
let pat = env_first(&["GITLAB_TOKEN"]);
let job_token = env_first(&["CI_JOB_TOKEN"]);
match (pat, job_token) {
(Some(token), _) => Ok((
token,
cursus::forge::gitlab::GitLabTokenKind::PersonalAccessToken,
false,
)),
(_, Some(token)) => Ok((
token,
cursus::forge::gitlab::GitLabTokenKind::JobToken,
true,
)),
(None, None) => Err(
"No GitLab token found (GITLAB_TOKEN, or CI_JOB_TOKEN for publish flows)".to_string(),
),
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
async fn build_async_gitlab(
host: &str,
scheme: &str,
token: &str,
token_kind: cursus::forge::gitlab::GitLabTokenKind,
) -> Result<AsyncGitlab, String> {
let mut builder = match token_kind {
cursus::forge::gitlab::GitLabTokenKind::PersonalAccessToken => {
GitlabBuilder::new(host, token)
}
cursus::forge::gitlab::GitLabTokenKind::JobToken => {
GitlabBuilder::new_with_job_token(host, token)
}
};
if scheme == "http" {
builder.insecure();
}
builder
.build_async()
.await
.map_err(|e| format!("Failed to initialise GitLab client for host '{host}': {e:#}"))
}
#[cfg_attr(coverage_nightly, coverage(off))]
pub(crate) fn resolve_gitlab_forge_client_from_handles(
handles: &GitLabHandles,
) -> GitLabClientOutcome {
let client = cursus::forge::gitlab::ReqwestGitLabClient::new(
(*handles.client).clone(),
handles.project.clone(),
);
GitLabClientOutcome {
client: Arc::new(client) as Arc<dyn cursus::forge::CodeForgeClient>,
uses_job_token_only: handles.uses_job_token_only,
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn gitlab_endpoint(config_host: &str) -> (String, String) {
gitlab_endpoint_from(env_first(&["CI_API_V4_URL"]).as_deref(), config_host)
}
pub(super) fn gitlab_endpoint_from(
ci_api_v4_url: Option<&str>,
config_host: &str,
) -> (String, String) {
if let Some(ci_url) = ci_api_v4_url {
let trimmed = ci_url.trim_end_matches('/');
let trimmed = trimmed.strip_suffix("/api/v4").unwrap_or(trimmed);
split_scheme(trimmed)
} else if !config_host.trim().is_empty() {
split_scheme(config_host.trim().trim_end_matches('/'))
} else {
("https".to_string(), "gitlab.com".to_string())
}
}
pub(super) fn split_scheme(s: &str) -> (String, String) {
if let Some(rest) = s.strip_prefix("https://") {
("https".to_string(), rest.to_string())
} else if let Some(rest) = s.strip_prefix("http://") {
("http".to_string(), rest.to_string())
} else if let Some(idx) = s.find("://") {
("https".to_string(), s[idx + 3..].to_string())
} else {
("https".to_string(), s.to_string())
}
}
pub(super) fn validate_gitlab_host(host: &str) -> Result<(), String> {
let (hostname, port) = match host.split_once(':') {
Some((h, p)) => (h, Some(p)),
None => (host, None),
};
if hostname.contains(':') || port.is_some_and(|p| p.contains(':')) {
return Err(format!("Invalid GitLab host: {host:?}"));
}
if hostname.is_empty()
|| hostname == "."
|| hostname == ".."
|| !hostname
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(format!("Invalid GitLab host: {host:?}"));
}
if let Some(p) = port
&& (p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()))
{
return Err(format!("Invalid GitLab host: {host:?}"));
}
Ok(())
}