use std::path::Path;
use futures::try_join;
use reqwest::{Method, StatusCode};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use crate::{
description::FormatMergeRequest,
error::{ConfigSnafu, GitLabApiSnafu, Result},
forge::{
ApprovalSatisfaction,
ApprovalStatus,
CheckStatus,
DiscussionCount,
Forge,
ForgeCreateMergeRequestOptions,
ForgeMergeRequest,
ForgeUser,
MergeRequestStatus,
},
};
pub struct GitLabForge {
base_url: String,
source_project_id: String,
target_project_id: String,
token: String,
client: reqwest::Client,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitLabUser {
pub id: u64,
pub username: String,
}
impl From<GitLabUser> for ForgeUser {
fn from(user: GitLabUser) -> Self {
ForgeUser {
id: Some(user.id.to_string()),
username: user.username,
}
}
}
impl GitLabForge {
pub fn new(
base_url: impl Into<String>,
source_project_id: impl Into<String>,
target_project_id: impl Into<String>,
token: impl Into<String>,
ca_bundle: Option<impl AsRef<Path>>,
accept_non_compliant_certs: bool,
) -> Result<Self> {
let mut client_builder = reqwest::Client::builder();
if accept_non_compliant_certs {
client_builder = client_builder.tls_danger_accept_invalid_certs(true);
}
if let Some(ca_path) = ca_bundle {
let ca_cert = std::fs::read(ca_path.as_ref()).map_err(|e| {
ConfigSnafu {
message: format!(
"Failed to read CA bundle at {}: {}",
ca_path.as_ref().to_string_lossy(),
e
),
}
.build()
})?;
let certs = reqwest::Certificate::from_pem_bundle(&ca_cert).map_err(|e| {
ConfigSnafu {
message: format!("Failed to parse CA bundle: {}", e),
}
.build()
})?;
for cert in certs {
client_builder = client_builder.add_root_certificate(cert);
}
}
let client = client_builder.build().map_err(|e| {
ConfigSnafu {
message: format!("Failed to build HTTP client: {}", e),
}
.build()
})?;
Ok(Self {
base_url: base_url.into(),
source_project_id: source_project_id.into(),
target_project_id: target_project_id.into(),
token: token.into(),
client,
})
}
fn encoded_target_project_id(&self) -> String {
urlencoding::encode(&self.target_project_id).to_string()
}
async fn request<T: DeserializeOwned>(
&self,
method: Method,
path: impl AsRef<str>,
payload: Option<impl Serialize>,
) -> Result<T> {
let mut req = self
.client
.request(method, format!("{}{}", self.base_url, path.as_ref()))
.header("Authorization", format!("Bearer {}", &self.token));
if let Some(payload) = payload.as_ref() {
req = req.json(payload);
}
let response = req.send().await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await?;
return Err(GitLabApiSnafu {
message: format!("Failed to get: {} - {}", status, text),
}
.build());
}
let body = response.text().await?;
let data: T = serde_json::from_str(&body).map_err(|e| {
GitLabApiSnafu {
message: format!(
"Failed to parse GET response to {}: {}, response: {}",
path.as_ref(),
e,
body
),
}
.build()
})?;
Ok(data)
}
}
impl Forge for GitLabForge {
fn project_id(&self) -> &str {
&self.target_project_id
}
fn source_project_id(&self) -> &str {
&self.source_project_id
}
fn target_project_id(&self) -> &str {
&self.target_project_id
}
fn base_url(&self) -> &str {
&self.base_url
}
async fn current_user(&self) -> Result<ForgeUser> {
let user: GitLabUser = self
.request(Method::GET, "/api/v4/user", None::<()>)
.await?;
Ok(user.into())
}
async fn user_by_username(&self, username: &str) -> Result<Option<ForgeUser>> {
let users: Vec<GitLabUser> = self
.request(
Method::GET,
format!("/api/v4/users?username={}", urlencoding::encode(username)),
None::<()>,
)
.await?;
Ok(users.into_iter().next().map(ForgeUser::from))
}
async fn find_merge_request_by_source_branch(
&self,
branch: &str,
) -> Result<Option<ForgeMergeRequest>> {
let mrs: Vec<MergeRequest> = self
.request(
Method::GET,
format!(
"/api/v4/projects/{}/merge_requests?source_branch={}&state=opened",
self.encoded_target_project_id(),
urlencoding::encode(branch)
),
None::<()>,
)
.await?;
Ok(mrs.into_iter().next().map(ForgeMergeRequest::GitLab))
}
async fn create_merge_request(
&self,
ForgeCreateMergeRequestOptions {
assignee_usernames: assignee_ids,
description,
open_as_draft,
remove_source_branch,
reviewer_usernames: reviewer_ids,
source_branch,
squash,
target_branch,
title,
}: ForgeCreateMergeRequestOptions,
) -> Result<ForgeMergeRequest> {
let mut payload = serde_json::json!({
"source_branch": source_branch,
"target_branch": target_branch,
"title": if open_as_draft { format!("Draft: {}", title) } else { title },
"remove_source_branch": remove_source_branch,
"squash": squash,
});
if self.source_project_id != self.target_project_id {
payload["source_project_id"] = serde_json::json!(self.source_project_id);
}
if let Some(description) = description {
payload["description"] = serde_json::json!(description);
}
if !assignee_ids.is_empty() {
payload["assignee_ids"] = serde_json::json!(assignee_ids);
}
if !reviewer_ids.is_empty() {
payload["reviewer_ids"] = serde_json::json!(reviewer_ids);
}
let mr: MergeRequest = self
.request(
Method::POST,
format!(
"/api/v4/projects/{}/merge_requests",
self.encoded_target_project_id()
),
Some(payload),
)
.await?;
Ok(ForgeMergeRequest::GitLab(mr))
}
async fn update_merge_request_base(
&self,
merge_request_iid: &str,
new_target_branch: &str,
) -> Result<ForgeMergeRequest> {
let mr: MergeRequest = self
.request(
Method::PUT,
format!(
"/api/v4/projects/{}/merge_requests/{}",
self.encoded_target_project_id(),
merge_request_iid
),
Some(serde_json::json!({
"target_branch": new_target_branch,
})),
)
.await?;
Ok(ForgeMergeRequest::GitLab(mr))
}
async fn update_merge_request_description(
&self,
merge_request_iid: &str,
new_description: &str,
) -> Result<ForgeMergeRequest> {
let mr: MergeRequest = self
.request(
Method::PUT,
format!(
"/api/v4/projects/{}/merge_requests/{}",
self.encoded_target_project_id(),
merge_request_iid,
),
Some(serde_json::json!({
"description": new_description,
})),
)
.await?;
Ok(ForgeMergeRequest::GitLab(mr))
}
async fn get_merge_request(&self, merge_request_iid: &str) -> Result<ForgeMergeRequest> {
let mr: MergeRequest = self
.request(
Method::GET,
format!(
"/api/v4/projects/{}/merge_requests/{}",
self.encoded_target_project_id(),
merge_request_iid
),
None::<()>,
)
.await?;
Ok(ForgeMergeRequest::GitLab(mr))
}
async fn get_approval_status(&self, merge_request_iid: &str) -> Result<ApprovalStatus> {
let approvals: Result<MergeRequestApprovals, _> = self
.request(
Method::GET,
format!(
"/api/v4/projects/{}/merge_requests/{}/approvals",
self.encoded_target_project_id(),
merge_request_iid
),
None::<()>,
)
.await;
let approved_count = approvals
.as_ref()
.map(|approvals| approvals.approved_by.len() as u32)
.unwrap_or(0);
let required_count = approvals
.as_ref()
.map(|approvals| approvals.approvals_required)
.unwrap_or(0);
Ok(ApprovalStatus {
blocking_count: 0,
approved_count,
required_count,
satisfaction: match approvals {
Ok(approvals) if approvals.approvals_left == 0 => ApprovalSatisfaction::Satisfied,
Ok(_) => ApprovalSatisfaction::Unsatisfied,
Err(_) => ApprovalSatisfaction::Unknown,
},
})
}
async fn get_check_status(&self, merge_request_iid: &str) -> Result<CheckStatus> {
let mr = self.get_merge_request(merge_request_iid).await?;
let source_branch = mr.source_branch();
let response = self
.client
.request(
Method::GET,
format!(
"{}/api/v4/projects/{}/pipelines?ref={}",
self.base_url,
self.encoded_target_project_id(),
urlencoding::encode(source_branch)
),
)
.header("Authorization", format!("Bearer {}", &self.token))
.send()
.await?;
match response.status() {
StatusCode::OK => {
let pipelines: Vec<Pipeline> = response.json().await?;
match pipelines.first() {
Some(Pipeline {
status: PipelineStatus::Success,
..
}) => Ok(CheckStatus::Success),
Some(Pipeline {
status: PipelineStatus::Failed | PipelineStatus::Canceled,
..
}) => Ok(CheckStatus::Failed),
Some(_) => Ok(CheckStatus::Pending),
_ => Ok(CheckStatus::None),
}
}
StatusCode::NOT_FOUND => Ok(CheckStatus::None),
StatusCode::FORBIDDEN => Ok(CheckStatus::None),
_ => Err(GitLabApiSnafu {
message: format!("Failed to get pipeline status: {}", response.status()),
}
.build()),
}
}
async fn get_merge_request_status(
&self,
merge_request_iid: &str,
) -> Result<MergeRequestStatus> {
let (approval_status, check_status) = try_join!(
self.get_approval_status(merge_request_iid),
self.get_check_status(merge_request_iid),
)?;
Ok(MergeRequestStatus {
iid: merge_request_iid.to_string(),
approval_status,
check_status,
})
}
async fn num_open_discussions(&self, merge_request_iid: &str) -> Result<DiscussionCount> {
let discussions = self.get_discussions(merge_request_iid).await?;
Ok(discussions
.iter()
.filter_map(|discussion| match &discussion.notes[..] {
[]
| [
DiscussionNote {
note_type: None, ..
},
] => None,
[note, ..] => Some(note),
})
.fold(Default::default(), |mut acc, first_note| {
acc.all += 1;
if first_note.resolved {
acc.resolved += 1;
} else if first_note.resolvable {
acc.unresolved += 1;
}
acc
}))
}
}
impl GitLabForge {
async fn get_discussions(&self, merge_request_iid: &str) -> Result<Vec<Discussion>> {
self.request(
Method::GET,
format!(
"/api/v4/projects/{}/merge_requests/{}/discussions",
self.encoded_target_project_id(),
merge_request_iid
),
None::<()>,
)
.await
}
}
impl FormatMergeRequest for GitLabForge {
fn format_merge_request_id(&self, mr_iid: &str) -> String {
format!("!{}", mr_iid)
}
fn mr_name(&self) -> &'static str {
"MR"
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MergeRequest {
pub iid: u64,
pub id: u64,
pub title: String,
pub description: Option<String>,
pub source_branch: String,
pub target_branch: String,
pub state: String,
pub web_url: String,
pub author: GitLabUser,
pub created_at: String,
pub assignees: Vec<GitLabUser>,
pub reviewers: Vec<GitLabUser>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MergeRequestApprovals {
pub approvals_required: u32,
pub approvals_left: u32,
pub approved: bool,
pub approved_by: Vec<ApprovedBy>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ApprovedBy {
pub user: GitLabUser,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
enum PipelineStatus {
Created,
WaitingForResource,
Preparing,
Pending,
Running,
Success,
Failed,
Canceled,
Skipped,
Manual,
Scheduled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Pipeline {
pub id: u64,
pub status: PipelineStatus,
#[serde(rename = "ref")]
pub ref_name: String,
pub sha: String,
pub web_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Discussion {
id: String,
individual_note: bool,
notes: Vec<DiscussionNote>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
enum DiscussionNoteType {
DiscussionNote,
DiffNote,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct NotePosition {
base_sha: String,
start_sha: String,
head_sha: String,
old_path: Option<String>,
new_path: Option<String>,
position_type: String,
old_line: Option<u32>,
new_line: Option<u32>,
line_range: Option<NotePositionLineRange>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct NotePositionLineRange {
start: Option<NotePositionLine>,
length: Option<NotePositionLine>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct NotePositionLine {
line_code: String,
#[serde(rename = "type")]
position_type: String,
old_line: Option<u32>,
new_line: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct NoteSuggestion {
id: String,
from_line: u32,
to_line: u32,
appliable: bool,
applied: bool,
from_content: String,
to_content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DiscussionNote {
id: u64,
#[serde(rename = "type")]
note_type: Option<DiscussionNoteType>,
body: String,
author: GitLabUser,
created_at: String,
updated_at: String,
system: bool,
noteable_id: u64,
noteable_type: String,
project_id: u64,
#[serde(default)]
resolved: bool,
resolvable: bool,
resolved_by: Option<GitLabUser>,
resolved_at: Option<String>,
position: Option<NotePosition>,
#[serde(default)]
suggestions: Vec<NoteSuggestion>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gitlab_client_new() {
let client = GitLabForge::new(
"https://gitlab.example.com".to_string(),
"group/project".to_string(),
"group/project".to_string(),
"token123".to_string(),
None::<&str>,
false,
)
.expect("Failed to create client");
assert_eq!(client.base_url, "https://gitlab.example.com");
assert_eq!(client.source_project_id, "group/project");
assert_eq!(client.target_project_id, "group/project");
assert_eq!(client.token, "token123");
}
#[test]
fn test_encode_project_id() {
let client = GitLabForge::new(
"https://gitlab.example.com".to_string(),
"group/project".to_string(),
"group/project".to_string(),
"token123".to_string(),
None::<&str>,
false,
)
.expect("Failed to create client");
let encoded = client.encoded_target_project_id();
assert_eq!(encoded, "group%2Fproject");
}
#[test]
fn test_ca_bundle_with_multiple_certificates() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut temp_file = NamedTempFile::new().expect("Failed to create temp file");
let cert_bundle = "-----BEGIN CERTIFICATE-----
MIIDeTCCAmGgAwIBAgIUfO3nrSE5qNWMV+TDTa+tkCwUd04wDQYJKoZIhvcNAQEL
BQAwWTELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3Qx
DTALBgNVBAoMBFRlc3QxDTALBgNVBAsMBFRlc3QxDjAMBgNVBAMMBVRlc3QxMB4X
DTI2MDEwODAxMDQxNFoXDTI3MDEwODAxMDQxNFowWTELMAkGA1UEBhMCVVMxDTAL
BgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3QxDTALBgNVBAoMBFRlc3QxDTALBgNV
BAsMBFRlc3QxDjAMBgNVBAMMBVRlc3QxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
MIIBCgKCAQEAo53dK+I1wLb2ck2zOGRDTAQXrXUazxJVPfCVdedJ+pOx4eIR1V8u
iffOsxjWG/hxoIlZpj0+OGj3GdL3wUi7KUqJUcpzVjqylAfYgBIGruQI9qLtmZSx
ZwKhLDRm++83SCRjkwe7daSAgvSlc/0cAWUQcczRPJG1WnG42+V2Tngy6z+FJck4
F8+3dPVGy0tQs0BA6BhMDYffwkRfcx3qI+9rsHb1MdMZ9GDUpG4PNO023jRsPjk3
4kvizo/XyTc6ip6OGFmu3fnXoaO2YkpvHLR5Fgryo5fGoV1J2Wub+caDSC4oJBsq
rAdf5hGE8NxsuauORkMi5cg9h/7Ojn6RpQIDAQABozkwNzAJBgNVHRMEAjAAMAsG
A1UdDwQEAwIF4DAdBgNVHQ4EFgQUr7AeMhGxmPYhMCnJK1Hm6ehZDbkwDQYJKoZI
hvcNAQELBQADggEBAJzhJqfv9RN1HDDPDl5SpG3yZpJYqARe5iuT5O8voLwiGUI+
MdbTO4u0x9khK9tIduW8/oP6DRVqUkvdRuUET414YWq2odYgD7D/3eo14BVnqazx
0UhziLFpW6SGMuS2VrUJDXGk8RLuP5xXZxl2yc8Mhh9n6XwX1QRhWQ+z0anUUDep
Tfcio5swcUsOQGa+9Q2V7Y0Yx2XIVreFi6MAHq/i8vP4CF+zrC1MS+ZEQO/yB1ZB
eH39/z8yA0qBPucG97NBAfWMdqvKU72jV/7flPl6hRiFnDACovPDqqWRDGofeuvS
nrRPwpkJh9lCnuFSMaCybOMgx1tZ9YP0vpAtdA8=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDeTCCAmGgAwIBAgIUN9oyphH7WiltV+bgl5GVEX05MEQwDQYJKoZIhvcNAQEL
BQAwWTELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3Qx
DTALBgNVBAoMBFRlc3QxDTALBgNVBAsMBFRlc3QxDjAMBgNVBAMMBVRlc3QyMB4X
DTI2MDEwODAxMDQzM1oXDTI3MDEwODAxMDQzM1owWTELMAkGA1UEBhMCVVMxDTAL
BgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3QxDTALBgNVBAoMBFRlc3QxDTALBgNV
BAsMBFRlc3QxDjAMBgNVBAMMBVRlc3QyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
MIIBCgKCAQEAtMP3dttGNAbZkDvWuqVf6JBVzHtGY1Jrq1Yohcbbz2tRH13pmnsZ
ml6rnYw2BzJo8PuuLwVvI8yNOtX95XT1MoidW5Marh3MIr6AJ4zgIfNsoC4v32gZ
wfRTiU0E4Y0l6W/McA8DzN25gBUswkd9iPtosM+H5P/fF2xlXlH9TkMz/JxL9haI
wcJcFaLvmJLuO5j1byLplKefjTCVSCvMK+5Z9iP3FxVFkS/Dmjtw1aJwMBNIRXLL
+KDnRStmqqbMPwgNz28BKaif3QThGfa03lLrINQ2OOL3ZaULj5pllpOgf3SL3h54
zviV5VitLLTXowAJkpjgSjBjGHTS5MmW3wIDAQABozkwNzAJBgNVHRMEAjAAMAsG
A1UdDwQEAwIF4DAdBgNVHQ4EFgQUOoaNpsdD/j+YxvwbUDsGR/IjGfYwDQYJKoZI
hvcNAQELBQADggEBABmCPwOnbaTSbShJqFDscoRQo8nuPuSNP76pu+TB14O+vsJq
a8KIRiCTycs72zxaJbdB+5knZs+p3QnDRH3YXhDq8T6xJzDW+mDwrO/xcpdDfEkO
hkLenuLhRNuhwhqAkcdaBvrnZHI7wuI6FAx5EK6MnFaCVvNrFhF/XZRKWH0D022j
wNLLlmTiHEaSCWW/FNYfkwzF+oamHunxZ0TRfFFnVpE1ADMVt9CGe/K1eLoJ9ZLW
zAAjdQJFYiiLIdUrYat1Jz+NlrTCI5/KEIs3/+aS4HwRnM3h3w6taQKDg2q2Hiez
uYyBeUf6LmQswHqXfxOmAoy1HbXDtNvmClznsb0=
-----END CERTIFICATE-----";
temp_file
.write_all(cert_bundle.as_bytes())
.expect("Failed to write to temp file");
let path = temp_file.path().to_str().unwrap().to_string();
GitLabForge::new(
"https://gitlab.example.com".to_string(),
"group/project".to_string(),
"group/project".to_string(),
"token123".to_string(),
Some(path.as_str()),
false,
)
.expect("Failed to create client with multi-cert bundle");
}
}