use anyhow::{Context, Result};
use colored::Colorize;
use super::{Forge, MergeRequestResult};
pub struct GitLabForge {
pub token: String,
pub slug: String,
pub api_base: String,
}
impl GitLabForge {
fn encoded_project_id(&self) -> String {
self.slug.replace('/', "%2F")
}
}
impl Forge for GitLabForge {
fn create_release(&self, tag: &str, body: &str, prerelease: bool, draft: bool) -> Result<()> {
if draft {
eprintln!(
"{}",
"Warning: GitLab does not support draft releases, creating as published".yellow()
);
}
let project = self.encoded_project_id();
let url = format!("{}/projects/{project}/releases", self.api_base);
let mut payload = serde_json::json!({
"tag_name": tag,
"name": tag,
"description": body,
});
if prerelease {
payload["upcoming_release"] = serde_json::json!(true);
}
ureq::post(&url)
.header("PRIVATE-TOKEN", &self.token)
.header("User-Agent", "ferrflow")
.send_json(payload)
.with_context(|| format!("Failed to create GitLab release for {tag}"))?;
Ok(())
}
fn find_draft_release(&self, _tag: &str) -> Result<Option<u64>> {
Ok(None)
}
fn publish_release(&self, _release_id: u64) -> Result<()> {
Ok(())
}
fn create_merge_request(
&self,
head: &str,
base: &str,
title: &str,
body: &str,
) -> Result<MergeRequestResult> {
let project = self.encoded_project_id();
let url = format!("{}/projects/{project}/merge_requests", self.api_base);
let payload = serde_json::json!({
"source_branch": head,
"target_branch": base,
"title": title,
"description": body,
});
let response: serde_json::Value = ureq::post(&url)
.header("PRIVATE-TOKEN", &self.token)
.header("User-Agent", "ferrflow")
.send_json(payload)
.with_context(|| format!("Failed to create MR from {head} to {base}"))?
.body_mut()
.read_json()
.with_context(|| "Failed to parse MR response")?;
let iid = response["iid"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("MR response missing iid field"))?;
Ok(MergeRequestResult {
id: iid,
auto_merge_key: iid.to_string(),
})
}
fn enable_auto_merge(&self, mr: &MergeRequestResult) -> Result<()> {
let project = self.encoded_project_id();
let url = format!(
"{}/projects/{project}/merge_requests/{}/merge",
self.api_base, mr.id
);
let payload = serde_json::json!({
"merge_when_pipeline_succeeds": true,
"squash": true,
});
ureq::put(&url)
.header("PRIVATE-TOKEN", &self.token)
.header("User-Agent", "ferrflow")
.send_json(payload)
.with_context(|| format!("Failed to enable auto-merge on MR !{}", mr.id))?;
Ok(())
}
fn mr_noun(&self) -> &'static str {
"MR"
}
fn release_noun(&self) -> &'static str {
"GitLab Release"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encoded_project_id_simple() {
let forge = GitLabForge {
token: String::new(),
slug: "owner/repo".to_string(),
api_base: "https://gitlab.com/api/v4".to_string(),
};
assert_eq!(forge.encoded_project_id(), "owner%2Frepo");
}
#[test]
fn encoded_project_id_subgroup() {
let forge = GitLabForge {
token: String::new(),
slug: "group/subgroup/repo".to_string(),
api_base: "https://gitlab.com/api/v4".to_string(),
};
assert_eq!(forge.encoded_project_id(), "group%2Fsubgroup%2Frepo");
}
#[test]
fn mr_noun_returns_mr() {
let forge = GitLabForge {
token: String::new(),
slug: "owner/repo".to_string(),
api_base: "https://gitlab.com/api/v4".to_string(),
};
assert_eq!(forge.mr_noun(), "MR");
}
#[test]
fn release_noun_returns_gitlab_release() {
let forge = GitLabForge {
token: String::new(),
slug: "owner/repo".to_string(),
api_base: "https://gitlab.com/api/v4".to_string(),
};
assert_eq!(forge.release_noun(), "GitLab Release");
}
#[test]
fn find_draft_release_always_none() {
let forge = GitLabForge {
token: String::new(),
slug: "owner/repo".to_string(),
api_base: "https://gitlab.com/api/v4".to_string(),
};
assert_eq!(forge.find_draft_release("v1.0.0").unwrap(), None);
}
#[test]
fn publish_release_noop() {
let forge = GitLabForge {
token: String::new(),
slug: "owner/repo".to_string(),
api_base: "https://gitlab.com/api/v4".to_string(),
};
assert!(forge.publish_release(123).is_ok());
}
#[test]
fn create_release_payload_structure() {
let mut payload = serde_json::json!({
"tag_name": "v1.0.0",
"name": "v1.0.0",
"description": "Release notes",
});
payload["upcoming_release"] = serde_json::json!(true);
assert_eq!(payload["upcoming_release"], true);
assert_eq!(payload["tag_name"], "v1.0.0");
}
#[test]
fn mr_response_parsing() {
let response: serde_json::Value = serde_json::json!({"iid": 15});
let iid = response["iid"].as_u64().unwrap();
assert_eq!(iid, 15);
}
#[test]
fn auto_merge_payload_structure() {
let payload = serde_json::json!({
"merge_when_pipeline_succeeds": true,
"squash": true,
});
assert_eq!(payload["merge_when_pipeline_succeeds"], true);
assert_eq!(payload["squash"], true);
}
#[test]
fn api_base_self_hosted() {
let forge = GitLabForge {
token: String::new(),
slug: "team/project".to_string(),
api_base: "https://gitlab.internal/api/v4".to_string(),
};
assert_eq!(forge.api_base, "https://gitlab.internal/api/v4");
}
}