use super::types::*;
use super::{Platform, PlatformHost};
use crate::auth::SecureString;
use anyhow::{bail, Context, Result};
use async_trait::async_trait;
pub struct GitLabClient {
token: SecureString,
owner: String,
repo: String,
client: reqwest::Client,
api_base: String,
}
impl GitLabClient {
pub fn new(token: SecureString, owner: String, repo: String) -> Self {
Self::new_with_base(token, owner, repo, "https://gitlab.com/api/v4".to_string())
}
pub fn new_with_base(
token: SecureString,
owner: String,
repo: String,
api_base: String,
) -> Self {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.user_agent("securegit")
.build()
.expect("Failed to create HTTP client");
Self {
token,
owner,
repo,
client,
api_base,
}
}
fn auth_headers(&self) -> reqwest::header::HeaderMap {
let mut headers = reqwest::header::HeaderMap::new();
if let Ok(val) = reqwest::header::HeaderValue::from_str(self.token.as_str()) {
headers.insert("PRIVATE-TOKEN", val);
}
headers
}
fn project_id(&self) -> String {
format!("{}%2F{}", urlencoding(&self.owner), urlencoding(&self.repo))
}
fn project_url(&self) -> String {
format!("{}/projects/{}", self.api_base, self.project_id())
}
}
#[async_trait]
impl Platform for GitLabClient {
fn host(&self) -> PlatformHost {
PlatformHost::GitLab
}
async fn create_pull_request(&self, pr: &CreatePR) -> Result<PullRequest> {
let url = format!("{}/merge_requests", self.project_url());
let body = serde_json::json!({
"title": pr.title,
"description": pr.body,
"source_branch": pr.head,
"target_branch": pr.base,
});
let resp = self
.client
.post(&url)
.headers(self.auth_headers())
.json(&body)
.send()
.await
.context("Failed to create merge request")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("GitLab API error ({}): {}", status, text);
}
let d: serde_json::Value = resp.json().await?;
Ok(PullRequest {
number: d["iid"].as_u64().unwrap_or(0),
title: d["title"].as_str().unwrap_or("").to_string(),
state: d["state"].as_str().unwrap_or("opened").to_string(),
html_url: d["web_url"].as_str().unwrap_or("").to_string(),
head_ref: d["source_branch"].as_str().unwrap_or("").to_string(),
base_ref: d["target_branch"].as_str().unwrap_or("").to_string(),
draft: d["draft"].as_bool().unwrap_or(false),
user: d["author"]["username"].as_str().unwrap_or("").to_string(),
created_at: d["created_at"].as_str().unwrap_or("").to_string(),
})
}
async fn list_pull_requests(&self, state: &str) -> Result<Vec<PullRequest>> {
let gl_state = match state {
"open" => "opened",
"closed" => "closed",
"all" => "all",
other => other,
};
let url = format!(
"{}/merge_requests?state={}&per_page=30",
self.project_url(),
gl_state
);
let resp = self
.client
.get(&url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to list merge requests")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("GitLab API error ({}): {}", status, text);
}
let data: Vec<serde_json::Value> = resp.json().await?;
Ok(data
.into_iter()
.map(|d| PullRequest {
number: d["iid"].as_u64().unwrap_or(0),
title: d["title"].as_str().unwrap_or("").to_string(),
state: d["state"].as_str().unwrap_or("").to_string(),
html_url: d["web_url"].as_str().unwrap_or("").to_string(),
head_ref: d["source_branch"].as_str().unwrap_or("").to_string(),
base_ref: d["target_branch"].as_str().unwrap_or("").to_string(),
draft: d["draft"].as_bool().unwrap_or(false),
user: d["author"]["username"].as_str().unwrap_or("").to_string(),
created_at: d["created_at"].as_str().unwrap_or("").to_string(),
})
.collect())
}
async fn get_pull_request(&self, number: u64) -> Result<PullRequest> {
let url = format!("{}/merge_requests/{}", self.project_url(), number);
let resp = self
.client
.get(&url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to get merge request")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("GitLab API error ({}): {}", status, text);
}
let d: serde_json::Value = resp.json().await?;
Ok(PullRequest {
number: d["iid"].as_u64().unwrap_or(0),
title: d["title"].as_str().unwrap_or("").to_string(),
state: d["state"].as_str().unwrap_or("").to_string(),
html_url: d["web_url"].as_str().unwrap_or("").to_string(),
head_ref: d["source_branch"].as_str().unwrap_or("").to_string(),
base_ref: d["target_branch"].as_str().unwrap_or("").to_string(),
draft: d["draft"].as_bool().unwrap_or(false),
user: d["author"]["username"].as_str().unwrap_or("").to_string(),
created_at: d["created_at"].as_str().unwrap_or("").to_string(),
})
}
async fn create_issue(&self, issue: &CreateIssue) -> Result<Issue> {
let url = format!("{}/issues", self.project_url());
let body = serde_json::json!({
"title": issue.title,
"description": issue.body,
"labels": issue.labels.join(","),
});
let resp = self
.client
.post(&url)
.headers(self.auth_headers())
.json(&body)
.send()
.await
.context("Failed to create issue")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("GitLab API error ({}): {}", status, text);
}
let d: serde_json::Value = resp.json().await?;
Ok(Issue {
number: d["iid"].as_u64().unwrap_or(0),
title: d["title"].as_str().unwrap_or("").to_string(),
state: d["state"].as_str().unwrap_or("opened").to_string(),
html_url: d["web_url"].as_str().unwrap_or("").to_string(),
})
}
async fn search_issues(&self, query: &str) -> Result<Vec<Issue>> {
let url = format!(
"{}/issues?search={}&per_page=20",
self.project_url(),
urlencoding(query)
);
let resp = self
.client
.get(&url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to search issues")?;
if !resp.status().is_success() {
return Ok(vec![]);
}
let data: Vec<serde_json::Value> = resp.json().await?;
Ok(data
.into_iter()
.map(|d| Issue {
number: d["iid"].as_u64().unwrap_or(0),
title: d["title"].as_str().unwrap_or("").to_string(),
state: d["state"].as_str().unwrap_or("").to_string(),
html_url: d["web_url"].as_str().unwrap_or("").to_string(),
})
.collect())
}
async fn add_labels(&self, number: u64, labels: &[String]) -> Result<()> {
let url = format!("{}/merge_requests/{}", self.project_url(), number);
let body = serde_json::json!({
"add_labels": labels.join(","),
});
let resp = self
.client
.put(&url)
.headers(self.auth_headers())
.json(&body)
.send()
.await
.context("Failed to add labels")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("GitLab API error adding labels ({}): {}", status, text);
}
Ok(())
}
async fn create_release(&self, release: &CreateRelease) -> Result<Release> {
let url = format!("{}/releases", self.project_url());
let body = serde_json::json!({
"tag_name": release.tag_name,
"name": release.name,
"description": release.body,
});
let resp = self
.client
.post(&url)
.headers(self.auth_headers())
.json(&body)
.send()
.await
.context("Failed to create release")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("GitLab API error ({}): {}", status, text);
}
let d: serde_json::Value = resp.json().await?;
Ok(Release {
id: 0, tag_name: d["tag_name"].as_str().unwrap_or("").to_string(),
name: d["name"].as_str().unwrap_or("").to_string(),
html_url: d["_links"]["self"].as_str().unwrap_or("").to_string(),
upload_url: format!(
"{}/releases/{}/assets/links",
self.project_url(),
urlencoding(&release.tag_name)
),
draft: false,
prerelease: false,
created_at: d["created_at"].as_str().unwrap_or("").to_string(),
})
}
async fn list_releases(&self, count: usize) -> Result<Vec<Release>> {
let url = format!("{}/releases?per_page={}", self.project_url(), count);
let resp = self
.client
.get(&url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to list releases")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("GitLab API error ({}): {}", status, text);
}
let data: Vec<serde_json::Value> = resp.json().await?;
Ok(data
.into_iter()
.map(|d| Release {
id: 0,
tag_name: d["tag_name"].as_str().unwrap_or("").to_string(),
name: d["name"].as_str().unwrap_or("").to_string(),
html_url: d["_links"]["self"].as_str().unwrap_or("").to_string(),
upload_url: String::new(),
draft: false,
prerelease: false,
created_at: d["created_at"].as_str().unwrap_or("").to_string(),
})
.collect())
}
async fn upload_release_asset(
&self,
_upload_url: &str,
name: &str,
_content_type: &str,
_data: Vec<u8>,
) -> Result<()> {
tracing::warn!(
"GitLab release asset upload not yet supported for '{}'. Use the GitLab UI to attach files.",
name
);
Ok(())
}
async fn get_check_runs(&self, ref_name: &str) -> Result<CombinedStatus> {
let url = format!(
"{}/pipelines?ref={}&per_page=5",
self.project_url(),
urlencoding(ref_name)
);
let resp = self
.client
.get(&url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to get pipelines")?;
if !resp.status().is_success() {
return Ok(CombinedStatus {
state: "unknown".to_string(),
total_count: 0,
check_runs: vec![],
});
}
let pipelines: Vec<serde_json::Value> = resp.json().await?;
let mut runs = Vec::new();
for p in &pipelines {
let pipeline_id = p["id"].as_u64().unwrap_or(0);
if pipeline_id > 0 {
let jobs_url = format!("{}/pipelines/{}/jobs", self.project_url(), pipeline_id);
if let Ok(jobs_resp) = self
.client
.get(&jobs_url)
.headers(self.auth_headers())
.send()
.await
{
if let Ok(jobs) = jobs_resp.json::<Vec<serde_json::Value>>().await {
for j in jobs {
runs.push(CheckRun {
name: j["name"].as_str().unwrap_or("").to_string(),
status: j["status"].as_str().unwrap_or("").to_string(),
conclusion: j["status"].as_str().map(|s| {
match s {
"success" => "success",
"failed" => "failure",
"canceled" => "cancelled",
_ => s,
}
.to_string()
}),
html_url: j["web_url"].as_str().map(|s| s.to_string()),
started_at: j["started_at"].as_str().map(|s| s.to_string()),
completed_at: j["finished_at"].as_str().map(|s| s.to_string()),
});
}
}
}
break; }
}
let overall_state = if let Some(p) = pipelines.first() {
p["status"].as_str().unwrap_or("pending").to_string()
} else {
"no_pipelines".to_string()
};
Ok(CombinedStatus {
state: overall_state,
total_count: runs.len() as u64,
check_runs: runs,
})
}
async fn get_authenticated_user(&self) -> Result<String> {
let url = format!("{}/user", self.api_base);
let resp = self
.client
.get(&url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to get authenticated user")?;
if !resp.status().is_success() {
bail!("Authentication failed — invalid or expired token");
}
let data: serde_json::Value = resp.json().await?;
Ok(data["username"].as_str().unwrap_or("unknown").to_string())
}
async fn create_repo(&self, repo: &CreateRepo) -> Result<Repository> {
let visibility = if repo.private { "private" } else { "public" };
let mut body = serde_json::json!({
"name": repo.name,
"visibility": visibility,
});
if let Some(ref desc) = repo.description {
body["description"] = serde_json::Value::String(desc.clone());
}
if let Some(ref namespace) = repo.namespace {
let ns_url = format!(
"{}/namespaces?search={}",
self.api_base,
urlencoding(namespace)
);
let ns_resp = self
.client
.get(&ns_url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to search namespaces")?;
if ns_resp.status().is_success() {
let namespaces: Vec<serde_json::Value> = ns_resp.json().await?;
let ns_id = namespaces
.iter()
.find(|ns| ns["full_path"].as_str() == Some(namespace))
.or_else(|| namespaces.first())
.and_then(|ns| ns["id"].as_u64());
if let Some(id) = ns_id {
body["namespace_id"] = serde_json::Value::Number(id.into());
} else {
bail!(
"Namespace '{}' not found. Check the group path and your permissions.",
namespace
);
}
}
}
let url = format!("{}/projects", self.api_base);
let resp = self
.client
.post(&url)
.headers(self.auth_headers())
.json(&body)
.send()
.await
.context("Failed to create project")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("GitLab API error ({}): {}", status, text);
}
let d: serde_json::Value = resp.json().await?;
Ok(Repository {
id: d["id"].as_u64().unwrap_or(0),
name: d["name"].as_str().unwrap_or("").to_string(),
full_name: d["path_with_namespace"]
.as_str()
.unwrap_or("")
.to_string(),
web_url: d["web_url"].as_str().unwrap_or("").to_string(),
clone_url_http: d["http_url_to_repo"].as_str().unwrap_or("").to_string(),
clone_url_ssh: d["ssh_url_to_repo"].as_str().map(|s| s.to_string()),
private: d["visibility"].as_str() == Some("private"),
})
}
}
fn urlencoding(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => result.push(c),
' ' => result.push_str("%20"),
'/' => result.push_str("%2F"),
_ => {
for b in c.to_string().as_bytes() {
result.push_str(&format!("%{:02X}", b));
}
}
}
}
result
}