use std::path::Path;
use anyhow::{Context, bail};
use serde::Deserialize;
use super::{
CliApiRequest, PlatformData, RemoteRefInfo, RemoteRefProvider, cli_api_error, cli_config_value,
run_cli_api,
};
use crate::git::{RefType, Repository};
#[derive(Debug, Clone, Copy)]
pub struct GitLabProvider;
impl RemoteRefProvider for GitLabProvider {
fn ref_type(&self) -> RefType {
RefType::Mr
}
fn fetch_info(&self, number: u32, repo: &Repository) -> anyhow::Result<RemoteRefInfo> {
let repo_root = repo.repo_path()?;
fetch_mr_info(number, repo_root)
}
fn ref_path(&self, number: u32) -> String {
format!("merge-requests/{}/head", number)
}
}
#[derive(Debug, Deserialize)]
struct GlabMrResponse {
title: String,
author: GlabAuthor,
state: String,
#[serde(default)]
draft: bool,
source_branch: String,
source_project_id: u64,
target_project_id: u64,
web_url: String,
}
#[derive(Debug, Deserialize)]
struct GlabAuthor {
username: String,
}
#[derive(Debug, Deserialize)]
struct GlabProject {
ssh_url_to_repo: Option<String>,
http_url_to_repo: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GlabApiErrorResponse {
#[serde(default)]
message: String,
#[serde(default)]
error: String,
}
fn fetch_mr_info(mr_number: u32, repo_root: &Path) -> anyhow::Result<RemoteRefInfo> {
let api_path = format!("projects/:id/merge_requests/{}", mr_number);
let args = ["api", api_path.as_str()];
let output = run_cli_api(CliApiRequest {
tool: "glab",
args: &args,
repo_root,
prompt_env: ("GLAB_NO_PROMPT", "1"),
install_hint: "GitLab CLI (glab) not installed; install from https://gitlab.com/gitlab-org/cli#installation",
run_context: "Failed to run glab api",
})?;
if !output.status.success() {
if let Ok(error_response) = serde_json::from_slice::<GlabApiErrorResponse>(&output.stdout) {
let error_text = if !error_response.message.is_empty() {
&error_response.message
} else {
&error_response.error
};
if error_text.starts_with("404") {
bail!("MR !{} not found", mr_number);
}
if error_text.starts_with("401") {
bail!("GitLab CLI not authenticated; run glab auth login");
}
if error_text.starts_with("403") {
bail!("GitLab API access forbidden for MR !{}", mr_number);
}
}
return Err(cli_api_error(
RefType::Mr,
format!("glab api failed for MR !{}", mr_number),
&output,
));
}
let response: GlabMrResponse = serde_json::from_slice(&output.stdout).with_context(|| {
format!(
"Failed to parse GitLab API response for MR !{}. \
This may indicate a GitLab API change.",
mr_number
)
})?;
if response.source_branch.is_empty() {
bail!(
"MR !{} has empty branch name; the MR may be in an invalid state",
mr_number
);
}
let is_cross_repo = response.source_project_id != response.target_project_id;
let (project_url, _) = response
.web_url
.split_once("/-/")
.with_context(|| format!("GitLab MR URL missing /-/ separator: {}", response.web_url))?;
let parsed_url = crate::git::GitRemoteUrl::parse(project_url).ok_or_else(|| {
anyhow::anyhow!("Failed to parse GitLab project from MR URL: {project_url}")
})?;
Ok(RemoteRefInfo {
ref_type: RefType::Mr,
number: mr_number,
title: response.title,
author: response.author.username,
state: response.state,
draft: response.draft,
source_branch: response.source_branch,
is_cross_repo,
url: response.web_url,
fork_push_url: None, platform_data: PlatformData::GitLab {
host: parsed_url.host().to_string(),
base_owner: parsed_url.owner().to_string(),
base_repo: parsed_url.repo().to_string(),
source_project_id: response.source_project_id,
target_project_id: response.target_project_id,
},
})
}
#[derive(Debug)]
pub struct GitLabForkUrls {
pub fork_push_url: Option<String>,
pub target_url: Option<String>,
}
pub fn fetch_gitlab_project_urls(
info: &RemoteRefInfo,
repo_root: &Path,
) -> anyhow::Result<GitLabForkUrls> {
let PlatformData::GitLab {
source_project_id,
target_project_id,
..
} = &info.platform_data
else {
bail!("fetch_gitlab_project_urls called on non-GitLab ref");
};
let (source_ssh, source_http) = fetch_project_urls(*source_project_id, repo_root)
.with_context(|| {
format!(
"Failed to fetch source project {} for MR !{}",
source_project_id, info.number
)
})?;
let (target_ssh, target_http) = fetch_project_urls(*target_project_id, repo_root)
.with_context(|| {
format!(
"Failed to fetch target project {} for MR !{}",
target_project_id, info.number
)
})?;
let use_ssh = git_protocol() == "ssh";
let fork_push_url = if use_ssh {
source_ssh.or(source_http)
} else {
source_http.or(source_ssh)
};
let target_url = if use_ssh {
target_ssh.or(target_http)
} else {
target_http.or(target_ssh)
};
Ok(GitLabForkUrls {
fork_push_url,
target_url,
})
}
fn fetch_project_urls(
project_id: u64,
repo_root: &Path,
) -> anyhow::Result<(Option<String>, Option<String>)> {
let api_path = format!("projects/{}", project_id);
let args = ["api", api_path.as_str()];
let output = run_cli_api(CliApiRequest {
tool: "glab",
args: &args,
repo_root,
prompt_env: ("GLAB_NO_PROMPT", "1"),
install_hint: "GitLab CLI (glab) not installed; install from https://gitlab.com/gitlab-org/cli#installation",
run_context: "Failed to run glab api",
})?;
if !output.status.success() {
bail!("Failed to fetch project {}", project_id);
}
let response: GlabProject = serde_json::from_slice(&output.stdout)?;
Ok((response.ssh_url_to_repo, response.http_url_to_repo))
}
pub fn git_protocol() -> String {
cli_config_value("glab", "git_protocol")
.filter(|p| p == "ssh" || p == "https")
.unwrap_or_else(|| "https".to_string())
}
pub fn fork_remote_url(host: &str, owner: &str, repo: &str) -> String {
if git_protocol() == "ssh" {
format!("git@{host}:{owner}/{repo}.git")
} else {
format!("https://{host}/{owner}/{repo}.git")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::remote_ref::RemoteRefInfo;
#[test]
fn test_ref_path() {
let provider = GitLabProvider;
assert_eq!(provider.ref_path(42), "merge-requests/42/head");
assert_eq!(provider.tracking_ref(42), "refs/merge-requests/42/head");
}
#[test]
fn test_ref_type() {
let provider = GitLabProvider;
assert_eq!(provider.ref_type(), RefType::Mr);
}
#[test]
fn test_fork_remote_url_formats() {
let url = fork_remote_url("gitlab.com", "contributor", "repo");
let valid_urls = [
"git@gitlab.com:contributor/repo.git",
"https://gitlab.com/contributor/repo.git",
];
assert!(valid_urls.contains(&url.as_str()), "unexpected URL: {url}");
let url = fork_remote_url("gitlab.example.com", "org", "project");
let valid_urls = [
"git@gitlab.example.com:org/project.git",
"https://gitlab.example.com/org/project.git",
];
assert!(valid_urls.contains(&url.as_str()), "unexpected URL: {url}");
}
#[test]
fn test_fetch_gitlab_project_urls_rejects_github_ref() {
let github_info = RemoteRefInfo {
ref_type: RefType::Pr,
number: 123,
title: "Test PR".to_string(),
author: "user".to_string(),
state: "open".to_string(),
draft: false,
source_branch: "feature".to_string(),
is_cross_repo: false,
url: "https://github.com/owner/repo/pull/123".to_string(),
fork_push_url: None,
platform_data: PlatformData::GitHub {
host: "github.com".to_string(),
head_owner: "user".to_string(),
head_repo: "repo".to_string(),
base_owner: "owner".to_string(),
base_repo: "repo".to_string(),
},
};
let result = fetch_gitlab_project_urls(&github_info, std::path::Path::new("."));
insta::assert_snapshot!(result.unwrap_err(), @"fetch_gitlab_project_urls called on non-GitLab ref");
}
}