use anyhow::{Context, bail};
use serde::Deserialize;
use super::{CliApiRequest, PlatformData, RemoteRefInfo, RemoteRefProvider, cli_api_error};
use crate::git::url::GitRemoteUrl;
use crate::git::{RefType, Repository};
#[derive(Debug, Clone, Copy)]
pub struct AzureDevOpsProvider;
impl RemoteRefProvider for AzureDevOpsProvider {
fn ref_type(&self) -> RefType {
RefType::Pr
}
fn platform_label(&self) -> &'static str {
"azure-devops"
}
fn fetch_info(&self, number: u32, repo: &Repository) -> anyhow::Result<RemoteRefInfo> {
fetch_pr_info(number, repo)
}
fn ref_path(&self, number: u32) -> String {
format!("pull/{}/head", number)
}
}
pub fn fork_remote_url(host: &str, organization: &str, project: &str, repo: &str) -> String {
if host.to_ascii_lowercase().ends_with(".visualstudio.com") {
format!("https://{}/{}/_git/{}", host, project, repo)
} else {
format!(
"https://{}/{}/{}/_git/{}",
host, organization, project, repo
)
}
}
pub fn pr_web_url(host: &str, organization: &str, project: &str, repo: &str, pr: u32) -> String {
if host.to_ascii_lowercase().ends_with(".visualstudio.com") {
format!(
"https://{}/{}/_git/{}/pullrequest/{}",
host, project, repo, pr
)
} else {
format!(
"https://dev.azure.com/{}/{}/_git/{}/pullrequest/{}",
organization, project, repo, pr
)
}
}
pub fn build_web_url(host: &str, organization: &str, project: &str, build_id: u32) -> String {
if host.to_ascii_lowercase().ends_with(".visualstudio.com") {
format!(
"https://{}/{}/_build/results?buildId={}",
host, project, build_id
)
} else {
format!(
"https://dev.azure.com/{}/{}/_build/results?buildId={}",
organization, project, build_id
)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AzPrResponse {
title: String,
created_by: AzIdentity,
status: String,
#[serde(default)]
is_draft: bool,
source_ref_name: String,
repository: AzRepository,
#[serde(default)]
fork_source: Option<AzForkRef>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AzIdentity {
unique_name: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AzRepository {
name: String,
project: AzProject,
#[serde(default)]
web_url: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AzProject {
name: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AzForkRef {
repository: AzForkRepository,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AzForkRepository {
#[serde(default)]
remote_url: Option<String>,
#[serde(default)]
ssh_url: Option<String>,
}
fn detect_azure_target(repo: &Repository) -> Option<(String, String)> {
if let Ok(remote) = repo.primary_remote()
&& let Some(url) = repo.effective_remote_url(&remote)
&& let Some(parsed) = GitRemoteUrl::parse(&url)
&& let Some(org) = parsed.azure_organization()
{
return Some((parsed.host().to_string(), org.to_string()));
}
for (_, url) in repo.all_remote_urls() {
if let Some(parsed) = GitRemoteUrl::parse(&url)
&& let Some(org) = parsed.azure_organization()
{
return Some((parsed.host().to_string(), org.to_string()));
}
}
None
}
pub fn az_org_url(host: &str, organization: &str) -> String {
let lower = host.to_ascii_lowercase();
if lower.ends_with(".visualstudio.com") {
format!("https://{}", host)
} else {
format!("https://dev.azure.com/{}", organization)
}
}
fn parse_web_url(web_url: Option<&str>) -> Option<(String, String)> {
let url = web_url?;
let rest = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))?;
let (host, path) = rest.split_once('/')?;
let host_lower = host.to_ascii_lowercase();
if host_lower == "dev.azure.com" {
let org = path.split('/').next().filter(|s| !s.is_empty())?;
Some((host.to_string(), org.to_string()))
} else if host_lower.ends_with(".visualstudio.com") {
let org = host.split('.').next().filter(|s| !s.is_empty())?;
Some((host.to_string(), org.to_string()))
} else {
None
}
}
fn fetch_pr_info(pr_number: u32, repo: &Repository) -> anyhow::Result<RemoteRefInfo> {
let repo_root = repo.repo_path()?;
let pr_id = pr_number.to_string();
let mut args = vec![
"repos",
"pr",
"show",
"--id",
pr_id.as_str(),
"--output",
"json",
];
let target = detect_azure_target(repo);
let org_url = target.as_ref().map(|(host, org)| az_org_url(host, org));
if let Some(org_url) = &org_url {
args.extend(["--org", org_url]);
}
let output = super::run_cli_api(CliApiRequest {
tool: "az",
args: &args,
repo_root,
prompt_env: ("AZURE_CORE_NO_COLOR", "true"),
install_hint: "Azure CLI (az) not installed; install from https://aka.ms/installazurecli",
run_context: "Failed to run az repos pr show",
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stderr.contains("does not exist") || stdout_str.contains("does not exist") {
bail!("Azure DevOps PR #{} not found", pr_number);
}
if stderr.contains("login") || stderr.contains("authenticate") {
bail!("Azure CLI not authenticated; run az login");
}
if stderr.contains("azure-devops") && stderr.contains("extension") {
bail!(
"Azure DevOps CLI extension not installed; \
run: az extension add --name azure-devops"
);
}
return Err(cli_api_error(
RefType::Pr,
format!("az repos pr show failed for PR #{}", pr_number),
&output,
));
}
let response: AzPrResponse = serde_json::from_slice(&output.stdout).with_context(|| {
format!(
"Failed to parse Azure DevOps API response for PR #{}. \
This may indicate an az CLI version issue.",
pr_number
)
})?;
let source_branch = response
.source_ref_name
.strip_prefix("refs/heads/")
.unwrap_or(&response.source_ref_name)
.to_string();
let is_cross_repo = response.fork_source.is_some();
let fork_push_url = response.fork_source.as_ref().and_then(|fork| {
fork.repository
.ssh_url
.clone()
.or_else(|| fork.repository.remote_url.clone())
});
let project = response.repository.project.name.clone();
let repo_name = response.repository.name.clone();
let (host, organization) = parse_web_url(response.repository.web_url.as_deref())
.or_else(|| target.clone())
.with_context(|| {
format!(
"Could not determine Azure DevOps org/host for PR #{}: \
response had no web_url and no local Azure remote is configured.",
pr_number
)
})?;
let pr_url = pr_web_url(&host, &organization, &project, &repo_name, pr_number);
Ok(RemoteRefInfo {
ref_type: RefType::Pr,
number: pr_number,
title: response.title,
author: response.created_by.unique_name,
state: response.status,
draft: response.is_draft,
source_branch,
is_cross_repo,
url: pr_url,
fork_push_url,
platform_data: PlatformData::AzureDevOps {
host,
organization,
project,
repo_name,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ref_path() {
let provider = AzureDevOpsProvider;
assert_eq!(provider.ref_path(550), "pull/550/head");
assert_eq!(provider.tracking_ref(550), "refs/pull/550/head");
}
#[test]
fn test_ref_type() {
let provider = AzureDevOpsProvider;
assert_eq!(provider.ref_type(), RefType::Pr);
}
#[test]
fn test_parse_web_url_dev_azure() {
let parsed = parse_web_url(Some("https://dev.azure.com/myorg/myproject/_git/myrepo"));
assert_eq!(
parsed,
Some(("dev.azure.com".to_string(), "myorg".to_string()))
);
}
#[test]
fn test_parse_web_url_visualstudio() {
let parsed = parse_web_url(Some("https://myorg.visualstudio.com/myproject/_git/myrepo"));
assert_eq!(
parsed,
Some(("myorg.visualstudio.com".to_string(), "myorg".to_string()))
);
}
#[test]
fn test_parse_web_url_missing_or_unknown() {
assert_eq!(parse_web_url(None), None);
assert_eq!(parse_web_url(Some("https://github.com/owner/repo")), None);
assert_eq!(parse_web_url(Some("not-a-url")), None);
}
#[test]
fn test_fork_remote_url_format() {
assert_eq!(
fork_remote_url("dev.azure.com", "myorg", "myproject", "myrepo"),
"https://dev.azure.com/myorg/myproject/_git/myrepo"
);
assert_eq!(
fork_remote_url("myorg.visualstudio.com", "myorg", "myproject", "myrepo"),
"https://myorg.visualstudio.com/myproject/_git/myrepo"
);
}
#[test]
fn test_pr_web_url_format() {
assert_eq!(
pr_web_url("dev.azure.com", "myorg", "myproject", "myrepo", 42),
"https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequest/42"
);
assert_eq!(
pr_web_url("myorg.visualstudio.com", "myorg", "myproject", "myrepo", 42),
"https://myorg.visualstudio.com/myproject/_git/myrepo/pullrequest/42"
);
}
#[test]
fn test_az_org_url_format() {
assert_eq!(
az_org_url("dev.azure.com", "myorg"),
"https://dev.azure.com/myorg"
);
assert_eq!(
az_org_url("myorg.visualstudio.com", "myorg"),
"https://myorg.visualstudio.com"
);
assert_eq!(
az_org_url("ssh.dev.azure.com", "myorg"),
"https://dev.azure.com/myorg"
);
}
}