use crate::error::{Error, Result};
use crate::platform::PlatformService;
use crate::types::{Platform, PlatformConfig, PrComment, PullRequest};
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::debug;
pub struct GitLabService {
client: Client,
token: String,
host: String,
config: PlatformConfig,
project_path: String,
}
#[derive(Deserialize)]
struct MergeRequest {
iid: u64,
web_url: String,
source_branch: String,
target_branch: String,
title: String,
#[serde(default)]
draft: bool,
}
#[derive(Deserialize)]
struct MrNote {
id: u64,
body: String,
system: bool,
}
impl From<MergeRequest> for PullRequest {
fn from(mr: MergeRequest) -> Self {
Self {
number: mr.iid,
html_url: mr.web_url,
base_ref: mr.target_branch,
head_ref: mr.source_branch,
title: mr.title,
node_id: None, is_draft: mr.draft,
}
}
}
#[derive(Serialize)]
struct CreateMrPayload {
source_branch: String,
target_branch: String,
title: String,
#[serde(skip_serializing_if = "Option::is_none")]
draft: Option<bool>,
}
const DEFAULT_TIMEOUT_SECS: u64 = 30;
impl GitLabService {
pub fn new(token: String, owner: String, repo: String, host: Option<String>) -> Result<Self> {
let host = host.unwrap_or_else(|| "gitlab.com".to_string());
let project_path = format!("{owner}/{repo}");
let client = Client::builder()
.timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS))
.build()
.map_err(|e| Error::GitLabApi(format!("failed to create HTTP client: {e}")))?;
let config_host = if host == "gitlab.com" {
None
} else {
Some(host.clone())
};
Ok(Self {
client,
token,
host,
config: PlatformConfig {
platform: Platform::GitLab,
owner,
repo,
host: config_host,
},
project_path,
})
}
fn api_url(&self, path: &str) -> String {
format!("https://{}/api/v4{}", self.host, path)
}
fn encoded_project(&self) -> String {
urlencoding::encode(&self.project_path).into_owned()
}
}
#[async_trait]
impl PlatformService for GitLabService {
async fn find_existing_pr(&self, head_branch: &str) -> Result<Option<PullRequest>> {
debug!(head_branch, "finding existing MR");
let url = self.api_url(&format!(
"/projects/{}/merge_requests",
self.encoded_project()
));
let mrs: Vec<MergeRequest> = self
.client
.get(&url)
.header("PRIVATE-TOKEN", &self.token)
.query(&[("source_branch", head_branch), ("state", "opened")])
.send()
.await?
.error_for_status()
.map_err(|e| Error::GitLabApi(e.to_string()))?
.json()
.await?;
let result: Option<PullRequest> = mrs.into_iter().next().map(Into::into);
if let Some(ref pr) = result {
debug!(mr_iid = pr.number, "found existing MR");
} else {
debug!("no existing MR found");
}
Ok(result)
}
async fn create_pr_with_options(
&self,
head: &str,
base: &str,
title: &str,
draft: bool,
) -> Result<PullRequest> {
debug!(head, base, draft, "creating MR");
let url = self.api_url(&format!(
"/projects/{}/merge_requests",
self.encoded_project()
));
let payload = CreateMrPayload {
source_branch: head.to_string(),
target_branch: base.to_string(),
title: title.to_string(),
draft: if draft { Some(true) } else { None },
};
let mr: MergeRequest = self
.client
.post(&url)
.header("PRIVATE-TOKEN", &self.token)
.json(&payload)
.send()
.await?
.error_for_status()
.map_err(|e| Error::GitLabApi(e.to_string()))?
.json()
.await?;
let pr: PullRequest = mr.into();
debug!(mr_iid = pr.number, "created MR");
Ok(pr)
}
async fn update_pr_base(&self, pr_number: u64, new_base: &str) -> Result<PullRequest> {
debug!(mr_iid = pr_number, new_base, "updating MR base");
let url = self.api_url(&format!(
"/projects/{}/merge_requests/{}",
self.encoded_project(),
pr_number
));
let mr: MergeRequest = self
.client
.put(&url)
.header("PRIVATE-TOKEN", &self.token)
.json(&serde_json::json!({ "target_branch": new_base }))
.send()
.await?
.error_for_status()
.map_err(|e| Error::GitLabApi(e.to_string()))?
.json()
.await?;
debug!(mr_iid = pr_number, "updated MR base");
Ok(mr.into())
}
async fn publish_pr(&self, pr_number: u64) -> Result<PullRequest> {
debug!(mr_iid = pr_number, "publishing MR");
let url = self.api_url(&format!(
"/projects/{}/merge_requests/{}",
self.encoded_project(),
pr_number
));
let mr: MergeRequest = self
.client
.put(&url)
.header("PRIVATE-TOKEN", &self.token)
.json(&serde_json::json!({ "state_event": "ready" }))
.send()
.await?
.error_for_status()
.map_err(|e| Error::GitLabApi(e.to_string()))?
.json()
.await?;
debug!(mr_iid = pr_number, "published MR");
Ok(mr.into())
}
async fn list_pr_comments(&self, pr_number: u64) -> Result<Vec<PrComment>> {
debug!(mr_iid = pr_number, "listing MR comments");
let url = self.api_url(&format!(
"/projects/{}/merge_requests/{}/notes",
self.encoded_project(),
pr_number
));
let notes: Vec<MrNote> = self
.client
.get(&url)
.header("PRIVATE-TOKEN", &self.token)
.send()
.await?
.error_for_status()
.map_err(|e| Error::GitLabApi(e.to_string()))?
.json()
.await?;
let comments: Vec<PrComment> = notes
.into_iter()
.filter(|n| !n.system)
.map(|n| PrComment {
id: n.id,
body: n.body,
})
.collect();
debug!(
mr_iid = pr_number,
count = comments.len(),
"listed MR comments"
);
Ok(comments)
}
async fn create_pr_comment(&self, pr_number: u64, body: &str) -> Result<()> {
debug!(mr_iid = pr_number, "creating MR comment");
let url = self.api_url(&format!(
"/projects/{}/merge_requests/{}/notes",
self.encoded_project(),
pr_number
));
self.client
.post(&url)
.header("PRIVATE-TOKEN", &self.token)
.json(&serde_json::json!({ "body": body }))
.send()
.await?
.error_for_status()
.map_err(|e| Error::GitLabApi(e.to_string()))?;
debug!(mr_iid = pr_number, "created MR comment");
Ok(())
}
async fn update_pr_comment(&self, pr_number: u64, comment_id: u64, body: &str) -> Result<()> {
debug!(mr_iid = pr_number, comment_id, "updating MR comment");
let url = self.api_url(&format!(
"/projects/{}/merge_requests/{}/notes/{}",
self.encoded_project(),
pr_number,
comment_id
));
self.client
.put(&url)
.header("PRIVATE-TOKEN", &self.token)
.json(&serde_json::json!({ "body": body }))
.send()
.await?
.error_for_status()
.map_err(|e| Error::GitLabApi(e.to_string()))?;
debug!(mr_iid = pr_number, comment_id, "updated MR comment");
Ok(())
}
fn config(&self) -> &PlatformConfig {
&self.config
}
}