use serde::{Deserialize, Serialize};
use crate::core::config::AzureDevOpsConfig;
#[derive(Debug, thiserror::Error)]
pub enum AzdoError {
#[error("not implemented: {method} is planned for Phase {phase}")]
NotImplemented {
method: String,
phase: u32,
},
#[error("invalid credentials: {0}")]
InvalidCredentials(String),
#[error("invalid URL: {0}")]
InvalidUrl(String),
#[error("HTTP error {status}: {message}")]
Http {
status: u16,
message: String,
},
#[error("authentication failed (401): check PAT and organisation URL")]
Unauthorized,
#[error("access denied (403): PAT lacks required scope")]
Forbidden,
#[error("resource not found (404): check organisation, project, and credentials")]
NotFound,
#[error("request error: {0}")]
Request(#[from] reqwest::Error),
#[error("response parse error: {0}")]
Parse(String),
#[error("invalid Azure DevOps configuration: {0}")]
Config(String),
}
#[derive(Debug, Clone, Serialize)]
pub struct AzdoConnectionInfo {
pub status: String,
pub phase: u32,
pub organization_url: String,
pub message: String,
pub user_id: Option<String>,
pub user_name: Option<String>,
pub instance_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AzdoWorkItem {
pub id: u32,
pub title: String,
pub state: String,
pub work_item_type: String,
pub tags: Vec<String>,
pub team_project: String,
pub url: Option<String>,
#[serde(default)]
pub iteration_path: Option<String>,
#[serde(default)]
pub area_path: Option<String>,
}
pub type WorkItem = AzdoWorkItem;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AzdoWorkItemType {
pub name: String,
pub reference_name: String,
pub description: String,
pub color: String,
pub icon: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AzdoField {
pub name: String,
pub reference_name: String,
pub field_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkItemRef {
pub id: u32,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WiqlResult {
pub query_type: String,
pub work_items: Vec<WorkItemRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AzdoIteration {
pub id: String,
pub name: String,
pub path: String,
pub start_date: Option<String>,
pub finish_date: Option<String>,
pub time_frame: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AzdoUser {
pub descriptor: String,
pub display_name: String,
pub mail_address: Option<String>,
pub principal_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AzdoComment {
pub id: u32,
pub text: String,
pub created_by: String,
pub created_date: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AzdoWorkItemExtended {
pub id: u32,
pub title: String,
pub state: String,
pub work_item_type: String,
pub iteration_path: Option<String>,
pub area_path: Option<String>,
pub tags: Vec<String>,
pub custom_fields: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AzdoProject {
pub id: String,
pub name: String,
pub state: String,
pub visibility: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ConnectionDataResponse {
authenticated_user: AuthenticatedUser,
instance_id: String,
#[serde(default)]
#[allow(dead_code)]
deployment_type: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AuthenticatedUser {
id: String,
provider_display_name: String,
}
#[derive(Debug, Deserialize)]
struct ProjectsResponse {
#[allow(dead_code)]
count: u32,
value: Vec<AzdoProjectRaw>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AzdoProjectRaw {
id: String,
name: String,
state: String,
visibility: String,
}
#[derive(Debug, Deserialize)]
struct ListEnvelope<T> {
#[allow(dead_code)]
#[serde(default)]
count: u32,
value: Vec<T>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WorkItemTypeRaw {
name: String,
reference_name: String,
#[serde(default)]
description: String,
#[serde(default)]
color: String,
#[serde(default)]
icon: IconRaw,
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct IconRaw {
#[serde(default)]
id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct FieldRaw {
name: String,
reference_name: String,
#[serde(rename = "type", default)]
field_type: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WiqlResponseRaw {
#[serde(default)]
query_type: String,
#[serde(default)]
work_items: Vec<WorkItemRefRaw>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WorkItemRefRaw {
id: u32,
#[serde(default)]
url: String,
}
#[derive(Debug, Deserialize)]
struct WorkItemBatchResponse {
#[allow(dead_code)]
#[serde(default)]
count: u32,
value: Vec<WorkItemRaw>,
}
#[derive(Debug, Deserialize)]
struct WorkItemRaw {
id: u32,
#[serde(default)]
fields: serde_json::Map<String, serde_json::Value>,
#[serde(default)]
url: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IterationRaw {
id: String,
name: String,
#[serde(default)]
path: String,
#[serde(default)]
attributes: IterationAttributesRaw,
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct IterationAttributesRaw {
#[serde(default)]
start_date: Option<String>,
#[serde(default)]
finish_date: Option<String>,
#[serde(default)]
time_frame: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommentsResponse {
#[serde(default)]
comments: Vec<CommentRaw>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommentRaw {
id: u32,
#[serde(default)]
text: String,
#[serde(default)]
created_by: IdentityRefRaw,
#[serde(default)]
created_date: String,
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct IdentityRefRaw {
#[serde(default)]
display_name: String,
}
#[derive(Debug, Deserialize)]
struct WorkItemRelationsResponse {
#[serde(default)]
relations: Vec<WorkItemRelationRaw>,
}
#[derive(Debug, Deserialize)]
struct WorkItemRelationRaw {
#[serde(default)]
rel: String,
#[serde(default)]
url: String,
#[serde(default)]
#[allow(dead_code)]
attributes: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UserRaw {
#[serde(default)]
descriptor: String,
#[serde(default)]
display_name: String,
#[serde(default)]
mail_address: Option<String>,
#[serde(default)]
principal_name: Option<String>,
}
pub struct AzureDevOpsClient {
config: AzureDevOpsConfig,
}
fn encode_path_segment(s: &str) -> String {
fn is_unreserved(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~')
}
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
if is_unreserved(b) {
out.push(b as char);
} else {
out.push_str(&format!("%{:02X}", b));
}
}
out
}
fn build_client() -> Result<reqwest::Client, AzdoError> {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static(concat!("tga/", env!("CARGO_PKG_VERSION"))),
);
headers.insert(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/json"),
);
reqwest::Client::builder()
.default_headers(headers)
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(AzdoError::Request)
}
impl AzureDevOpsClient {
pub fn new(config: AzureDevOpsConfig) -> Self {
Self { config }
}
pub fn config(&self) -> &AzureDevOpsConfig {
&self.config
}
pub fn validate_credentials(&self) -> Result<(), AzdoError> {
if self.config.pat.trim().is_empty() {
return Err(AzdoError::InvalidCredentials(
"PAT is empty (a non-empty PAT is required)".into(),
));
}
Ok(())
}
pub fn test_connection_stub(&self) -> AzdoConnectionInfo {
AzdoConnectionInfo {
status: "stub".to_string(),
phase: 1,
organization_url: self.config.organization_url.clone(),
message: "stub probe — call test_connection() for a real check".to_string(),
user_id: None,
user_name: None,
instance_id: None,
}
}
fn org_url(&self) -> &str {
self.config.organization_url.trim_end_matches('/')
}
pub async fn test_connection(&self) -> Result<AzdoConnectionInfo, AzdoError> {
self.validate_credentials()?;
let client = build_client()?;
let url = format!(
"{}/_apis/connectionData?connectOptions=none&api-version=7.1-preview.1",
self.org_url()
);
let resp = client
.get(&url)
.basic_auth("", Some(&self.config.pat))
.send()
.await?;
let status = resp.status();
match status.as_u16() {
200 => {
let body: ConnectionDataResponse = resp
.json()
.await
.map_err(|e| AzdoError::Parse(e.to_string()))?;
Ok(AzdoConnectionInfo {
status: "connected".to_string(),
phase: 2,
organization_url: self.config.organization_url.clone(),
message: format!(
"authenticated as {} (instance {})",
body.authenticated_user.provider_display_name, body.instance_id
),
user_id: Some(body.authenticated_user.id),
user_name: Some(body.authenticated_user.provider_display_name),
instance_id: Some(body.instance_id),
})
}
401 => Err(AzdoError::Unauthorized),
403 => Err(AzdoError::Forbidden),
404 => Err(AzdoError::NotFound),
s => {
let message = resp.text().await.unwrap_or_default();
Err(AzdoError::Http { status: s, message })
}
}
}
pub async fn get_projects(&self) -> Result<Vec<AzdoProject>, AzdoError> {
self.validate_credentials()?;
let client = build_client()?;
let url = format!("{}/_apis/projects?api-version=7.1&$top=100", self.org_url());
let resp = client
.get(&url)
.basic_auth("", Some(&self.config.pat))
.send()
.await?;
let status = resp.status();
match status.as_u16() {
200 => {
let body: ProjectsResponse = resp
.json()
.await
.map_err(|e| AzdoError::Parse(e.to_string()))?;
let projects = body
.value
.into_iter()
.map(|p| AzdoProject {
id: p.id,
name: p.name,
state: p.state,
visibility: p.visibility,
})
.collect();
Ok(projects)
}
401 => Err(AzdoError::Unauthorized),
403 => Err(AzdoError::Forbidden),
404 => Err(AzdoError::NotFound),
s => {
let message = resp.text().await.unwrap_or_default();
Err(AzdoError::Http { status: s, message })
}
}
}
async fn map_status(resp: reqwest::Response) -> AzdoError {
let status = resp.status().as_u16();
match status {
401 => AzdoError::Unauthorized,
403 => AzdoError::Forbidden,
404 => AzdoError::NotFound,
s => {
let message = resp.text().await.unwrap_or_default();
AzdoError::Http { status: s, message }
}
}
}
pub async fn get_work_item_types(
&self,
project: &str,
) -> Result<Vec<AzdoWorkItemType>, AzdoError> {
self.validate_credentials()?;
let client = build_client()?;
let url = format!(
"{}/{}/_apis/wit/workitemtypes?api-version=7.1",
self.org_url(),
encode_path_segment(project),
);
tracing::debug!(url = %url, "GET work item types");
let resp = client
.get(&url)
.basic_auth("", Some(&self.config.pat))
.send()
.await?;
if !resp.status().is_success() {
return Err(Self::map_status(resp).await);
}
let body: ListEnvelope<WorkItemTypeRaw> = resp
.json()
.await
.map_err(|e| AzdoError::Parse(e.to_string()))?;
Ok(body
.value
.into_iter()
.map(|t| AzdoWorkItemType {
name: t.name,
reference_name: t.reference_name,
description: t.description,
color: t.color,
icon: t.icon.id,
})
.collect())
}
pub async fn get_fields(&self, project: &str) -> Result<Vec<AzdoField>, AzdoError> {
self.validate_credentials()?;
let client = build_client()?;
let url = format!(
"{}/{}/_apis/wit/fields?api-version=7.1",
self.org_url(),
encode_path_segment(project),
);
tracing::debug!(url = %url, "GET fields");
let resp = client
.get(&url)
.basic_auth("", Some(&self.config.pat))
.send()
.await?;
if !resp.status().is_success() {
return Err(Self::map_status(resp).await);
}
let body: ListEnvelope<FieldRaw> = resp
.json()
.await
.map_err(|e| AzdoError::Parse(e.to_string()))?;
Ok(body
.value
.into_iter()
.map(|f| AzdoField {
name: f.name,
reference_name: f.reference_name,
field_type: f.field_type,
})
.collect())
}
pub async fn run_wiql(&self, project: &str, query: &str) -> Result<WiqlResult, AzdoError> {
self.validate_credentials()?;
let client = build_client()?;
let url = format!(
"{}/{}/_apis/wit/wiql?api-version=7.1",
self.org_url(),
encode_path_segment(project),
);
tracing::debug!(url = %url, "POST wiql");
let resp = client
.post(&url)
.basic_auth("", Some(&self.config.pat))
.json(&serde_json::json!({ "query": query }))
.send()
.await?;
if !resp.status().is_success() {
return Err(Self::map_status(resp).await);
}
let body: WiqlResponseRaw = resp
.json()
.await
.map_err(|e| AzdoError::Parse(e.to_string()))?;
Ok(WiqlResult {
query_type: body.query_type,
work_items: body
.work_items
.into_iter()
.map(|w| WorkItemRef {
id: w.id,
url: w.url,
})
.collect(),
})
}
pub async fn get_recent_work_item_ids(
&self,
project: &str,
since_days: u32,
) -> Result<Vec<u32>, AzdoError> {
let query = format!(
"SELECT [System.Id] FROM WorkItems \
WHERE [System.TeamProject] = @project \
AND [System.ChangedDate] >= @today - {since_days} \
ORDER BY [System.ChangedDate] DESC"
);
let result = self.run_wiql(project, &query).await?;
Ok(result.work_items.into_iter().map(|w| w.id).collect())
}
pub async fn get_work_items(&self, ids: &[u32]) -> Result<Vec<AzdoWorkItem>, AzdoError> {
if ids.is_empty() {
return Ok(Vec::new());
}
self.validate_credentials()?;
let client = build_client()?;
let url = format!(
"{}/_apis/wit/workitemsbatch?api-version=7.1",
self.org_url()
);
let fields = [
"System.Id",
"System.Title",
"System.State",
"System.WorkItemType",
"System.Tags",
"System.TeamProject",
"System.IterationPath",
"System.AreaPath",
];
let mut all = Vec::with_capacity(ids.len());
for chunk in ids.chunks(200) {
tracing::debug!(url = %url, count = chunk.len(), "POST workitemsbatch");
let resp = client
.post(&url)
.basic_auth("", Some(&self.config.pat))
.json(&serde_json::json!({
"ids": chunk,
"fields": fields,
"errorPolicy": "omit",
}))
.send()
.await?;
if !resp.status().is_success() {
return Err(Self::map_status(resp).await);
}
let body: WorkItemBatchResponse = resp
.json()
.await
.map_err(|e| AzdoError::Parse(e.to_string()))?;
if body.value.len() < chunk.len() {
let returned_ids: std::collections::HashSet<u32> =
body.value.iter().map(|w| w.id).collect();
let dropped: Vec<u32> = chunk
.iter()
.copied()
.filter(|id| !returned_ids.contains(id))
.collect();
let first_dropped: Vec<u32> = dropped.iter().take(10).copied().collect();
tracing::debug!(
requested = chunk.len(),
returned = body.value.len(),
dropped = dropped.len(),
first_dropped = ?first_dropped,
"ADO workitemsbatch silently omitted some IDs (errorPolicy=omit)"
);
}
for w in body.value {
all.push(parse_work_item(w));
}
}
Ok(all)
}
pub async fn get_iterations(&self, project: &str) -> Result<Vec<AzdoIteration>, AzdoError> {
self.validate_credentials()?;
let client = build_client()?;
let url = format!(
"{}/{}/_apis/work/teamsettings/iterations?api-version=7.1",
self.org_url(),
encode_path_segment(project),
);
tracing::debug!(url = %url, "GET iterations");
let resp = client
.get(&url)
.basic_auth("", Some(&self.config.pat))
.send()
.await?;
if !resp.status().is_success() {
return Err(Self::map_status(resp).await);
}
let body: ListEnvelope<IterationRaw> = resp
.json()
.await
.map_err(|e| AzdoError::Parse(e.to_string()))?;
Ok(body
.value
.into_iter()
.map(|it| AzdoIteration {
id: it.id,
name: it.name,
path: it.path,
start_date: it.attributes.start_date,
finish_date: it.attributes.finish_date,
time_frame: it.attributes.time_frame,
})
.collect())
}
pub async fn get_users(&self) -> Result<Vec<AzdoUser>, AzdoError> {
self.validate_credentials()?;
let graph_url = self.graph_users_url()?;
let client = build_client()?;
tracing::debug!(url = %graph_url, "GET graph users");
let resp = client
.get(&graph_url)
.basic_auth("", Some(&self.config.pat))
.send()
.await?;
if !resp.status().is_success() {
return Err(Self::map_status(resp).await);
}
let body: ListEnvelope<UserRaw> = resp
.json()
.await
.map_err(|e| AzdoError::Parse(e.to_string()))?;
Ok(body
.value
.into_iter()
.map(|u| AzdoUser {
descriptor: u.descriptor,
display_name: u.display_name,
mail_address: u.mail_address,
principal_name: u.principal_name,
})
.collect())
}
pub async fn get_work_item_comments(
&self,
project: &str,
work_item_id: u32,
) -> Result<Vec<AzdoComment>, AzdoError> {
self.validate_credentials()?;
let client = build_client()?;
let url = format!(
"{}/{}/_apis/wit/workItems/{}/comments?api-version=7.1-preview.3",
self.org_url(),
encode_path_segment(project),
work_item_id,
);
tracing::debug!(url = %url, "GET work item comments");
let resp = client
.get(&url)
.basic_auth("", Some(&self.config.pat))
.send()
.await?;
if !resp.status().is_success() {
return Err(Self::map_status(resp).await);
}
let body: CommentsResponse = resp
.json()
.await
.map_err(|e| AzdoError::Parse(e.to_string()))?;
Ok(body
.comments
.into_iter()
.map(|c| AzdoComment {
id: c.id,
text: c.text,
created_by: c.created_by.display_name,
created_date: c.created_date,
})
.collect())
}
pub async fn get_work_item_extended(
&self,
id: u32,
) -> Result<Option<AzdoWorkItemExtended>, AzdoError> {
self.validate_credentials()?;
let client = build_client()?;
let url = format!(
"{}/_apis/wit/workitems/{}?$expand=all&api-version=7.1",
self.org_url(),
id,
);
tracing::debug!(url = %url, "GET work item extended");
let resp = client
.get(&url)
.basic_auth("", Some(&self.config.pat))
.send()
.await?;
if resp.status().as_u16() == 404 {
return Ok(None);
}
if !resp.status().is_success() {
return Err(Self::map_status(resp).await);
}
let raw: WorkItemRaw = resp
.json()
.await
.map_err(|e| AzdoError::Parse(e.to_string()))?;
Ok(Some(parse_work_item_extended(raw)))
}
pub async fn get_work_item_commit_links(
&self,
project: &str,
work_item_id: u32,
) -> Result<Vec<String>, AzdoError> {
self.validate_credentials()?;
let client = build_client()?;
let url = format!(
"{}/{}/_apis/wit/workItems/{}?$expand=relations&api-version=7.1",
self.org_url(),
encode_path_segment(project),
work_item_id,
);
tracing::debug!(url = %url, "GET work item relations");
let resp = client
.get(&url)
.basic_auth("", Some(&self.config.pat))
.send()
.await?;
if resp.status().as_u16() == 404 {
return Ok(Vec::new());
}
if !resp.status().is_success() {
return Err(Self::map_status(resp).await);
}
let raw: WorkItemRelationsResponse = resp
.json()
.await
.map_err(|e| AzdoError::Parse(e.to_string()))?;
Ok(extract_commit_shas_from_relations(&raw.relations))
}
fn graph_users_url(&self) -> Result<String, AzdoError> {
let org = self.org_url();
let lower = org.to_lowercase();
if !lower.contains("dev.azure.com") && !lower.contains(".visualstudio.com") {
return Ok(format!("{org}/_graph/users?api-version=7.1-preview.1"));
}
let org_slug = if let Some(rest) = lower.strip_prefix("https://dev.azure.com/") {
rest.trim_end_matches('/').split('/').next().unwrap_or("")
} else if let Some(rest) = lower.strip_prefix("https://") {
rest.split('.').next().unwrap_or("")
} else {
return Err(AzdoError::InvalidUrl(format!(
"cannot derive org slug from {org}"
)));
};
if org_slug.is_empty() {
return Err(AzdoError::InvalidUrl(format!(
"cannot derive org slug from {org}"
)));
}
Ok(format!(
"https://vssps.dev.azure.com/{org_slug}/_apis/graph/users?api-version=7.1-preview.1"
))
}
}
fn parse_work_item(raw: WorkItemRaw) -> AzdoWorkItem {
let get_str = |key: &str| -> String {
raw.fields
.get(key)
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
};
let tags_raw = get_str("System.Tags");
let tags = if tags_raw.is_empty() {
Vec::new()
} else {
tags_raw
.split(';')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
let get_opt = |key: &str| -> Option<String> {
raw.fields
.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
};
AzdoWorkItem {
id: raw.id,
title: get_str("System.Title"),
state: get_str("System.State"),
work_item_type: get_str("System.WorkItemType"),
tags,
team_project: get_str("System.TeamProject"),
url: raw.url,
iteration_path: get_opt("System.IterationPath"),
area_path: get_opt("System.AreaPath"),
}
}
fn parse_work_item_extended(raw: WorkItemRaw) -> AzdoWorkItemExtended {
use std::collections::HashMap;
const STANDARD_FIELDS: &[&str] = &[
"System.Id",
"System.Title",
"System.State",
"System.WorkItemType",
"System.Tags",
"System.IterationPath",
"System.AreaPath",
];
let get_str = |key: &str| -> String {
raw.fields
.get(key)
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
};
let get_opt = |key: &str| -> Option<String> {
raw.fields
.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
};
let tags_raw = get_str("System.Tags");
let tags = if tags_raw.is_empty() {
Vec::new()
} else {
tags_raw
.split(';')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
let mut custom_fields: HashMap<String, serde_json::Value> = HashMap::new();
for (k, v) in &raw.fields {
if !STANDARD_FIELDS.contains(&k.as_str()) {
custom_fields.insert(k.clone(), v.clone());
}
}
AzdoWorkItemExtended {
id: raw.id,
title: get_str("System.Title"),
state: get_str("System.State"),
work_item_type: get_str("System.WorkItemType"),
iteration_path: get_opt("System.IterationPath"),
area_path: get_opt("System.AreaPath"),
tags,
custom_fields,
}
}
fn extract_commit_shas_from_relations(relations: &[WorkItemRelationRaw]) -> Vec<String> {
let mut out = Vec::new();
for r in relations {
let is_artifact = r.rel.eq_ignore_ascii_case("ArtifactLink");
let is_versioned = r.rel.starts_with("System.LinkTypes.Versioned");
if !(is_artifact || is_versioned) {
continue;
}
let lower = r.url.to_lowercase();
if !lower.starts_with("vstfs:///git/commit/") {
continue;
}
let suffix = &r.url["vstfs:///Git/Commit/".len()..];
let last = suffix
.rsplit_once("%2F")
.or_else(|| suffix.rsplit_once("%2f"))
.or_else(|| suffix.rsplit_once('/'))
.map(|(_, sha)| sha)
.unwrap_or(suffix);
let sha = last.split('?').next().unwrap_or(last).trim();
if !sha.is_empty() {
out.push(sha.to_string());
}
}
out
}
pub fn extract_work_item_refs(re: ®ex::Regex, text: &str) -> Vec<u32> {
use std::collections::HashSet;
let mut seen = HashSet::new();
let mut out = Vec::new();
for cap in re.captures_iter(text) {
if let Some(m) = cap.get(1) {
if let Ok(id) = m.as_str().parse::<u32>() {
if seen.insert(id) {
out.push(id);
}
}
}
}
out
}
pub fn feed_azdo_users(
resolver: &mut crate::collect::identity::IdentityResolver,
users: &[AzdoUser],
) {
for u in users {
let Some(email) = u.mail_address.as_deref() else {
continue;
};
let email = email.trim();
if email.is_empty() || u.display_name.trim().is_empty() {
continue;
}
resolver.add_alias(email, &u.display_name);
}
}
pub async fn fetch_referenced_work_items(
client: &AzureDevOpsClient,
re: ®ex::Regex,
messages: &[&str],
_project: &str,
) -> Result<Vec<AzdoWorkItem>, AzdoError> {
use std::collections::HashSet;
let mut seen = HashSet::new();
let mut ids = Vec::new();
for msg in messages {
for id in extract_work_item_refs(re, msg) {
if seen.insert(id) {
ids.push(id);
}
}
}
if ids.is_empty() {
return Ok(Vec::new());
}
client.get_work_items(&ids).await
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn sample_config_for(server_url: &str) -> AzureDevOpsConfig {
AzureDevOpsConfig {
organization_url: server_url.to_string(),
pat: "secret-pat".into(),
project: Some("MyProject".into()),
projects: vec![],
ticket_regex: r"AB#(\d+)".into(),
team_keys: vec![],
fetch_on_reference: true,
fetch_prs: false,
}
}
fn sample_config() -> AzureDevOpsConfig {
AzureDevOpsConfig {
organization_url: "https://dev.azure.com/myorg".into(),
pat: "secret-pat".into(),
project: Some("MyProject".into()),
projects: vec![],
ticket_regex: r"AB#(\d+)".into(),
team_keys: vec![],
fetch_on_reference: true,
fetch_prs: false,
}
}
#[test]
fn stub_connection_info_has_phase_1() {
let client = AzureDevOpsClient::new(sample_config());
let info = client.test_connection_stub();
assert_eq!(info.phase, 1);
assert_eq!(info.status, "stub");
assert_eq!(info.organization_url, "https://dev.azure.com/myorg");
}
#[test]
fn validate_credentials_accepts_non_empty_pat() {
let client = AzureDevOpsClient::new(sample_config());
client
.validate_credentials()
.expect("non-empty PAT should validate");
}
#[test]
fn validate_credentials_rejects_empty_pat() {
let mut cfg = sample_config();
cfg.pat = " ".into();
let client = AzureDevOpsClient::new(cfg);
let err = client
.validate_credentials()
.expect_err("whitespace PAT should be rejected");
assert!(matches!(err, AzdoError::InvalidCredentials(_)));
}
#[tokio::test]
async fn get_work_items_empty_ids_short_circuits() {
let client = AzureDevOpsClient::new(sample_config());
let out = client
.get_work_items(&[])
.await
.expect("empty ids should short-circuit to Ok(vec![])");
assert!(out.is_empty());
}
const EXPECTED_AUTH: &str = "Basic OnNlY3JldC1wYXQ=";
#[tokio::test]
async fn test_connection_succeeds_on_200() {
let server = MockServer::start().await;
let body = serde_json::json!({
"authenticatedUser": {
"id": "11111111-1111-1111-1111-111111111111",
"providerDisplayName": "John Doe",
"subjectDescriptor": "aad.xxx"
},
"instanceId": "22222222-2222-2222-2222-222222222222",
"deploymentType": "hosted"
});
Mock::given(method("GET"))
.and(path("/_apis/connectionData"))
.and(query_param("api-version", "7.1-preview.1"))
.and(query_param("connectOptions", "none"))
.and(header("authorization", EXPECTED_AUTH))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let info = client
.test_connection()
.await
.expect("200 should yield connected info");
assert_eq!(info.status, "connected");
assert_eq!(info.phase, 2);
assert_eq!(info.user_name.as_deref(), Some("John Doe"));
assert_eq!(
info.user_id.as_deref(),
Some("11111111-1111-1111-1111-111111111111")
);
assert_eq!(
info.instance_id.as_deref(),
Some("22222222-2222-2222-2222-222222222222")
);
}
#[tokio::test]
async fn test_connection_returns_unauthorized_on_401() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/_apis/connectionData"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let err = client.test_connection().await.expect_err("401 should err");
assert!(matches!(err, AzdoError::Unauthorized), "got {err:?}");
}
#[tokio::test]
async fn test_connection_returns_forbidden_on_403() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/_apis/connectionData"))
.respond_with(ResponseTemplate::new(403))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let err = client.test_connection().await.expect_err("403 should err");
assert!(matches!(err, AzdoError::Forbidden), "got {err:?}");
}
#[tokio::test]
async fn test_connection_returns_not_found_on_404() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/_apis/connectionData"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let err = client.test_connection().await.expect_err("404 should err");
assert!(matches!(err, AzdoError::NotFound), "got {err:?}");
}
#[tokio::test]
async fn test_connection_returns_http_on_500() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/_apis/connectionData"))
.respond_with(ResponseTemplate::new(500).set_body_string("upstream boom"))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let err = client.test_connection().await.expect_err("500 should err");
match err {
AzdoError::Http { status, message } => {
assert_eq!(status, 500);
assert!(message.contains("upstream boom"), "msg: {message}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn test_connection_rejects_empty_pat_pre_flight() {
let mut cfg = sample_config();
cfg.pat = " ".into();
let client = AzureDevOpsClient::new(cfg);
let err = client
.test_connection()
.await
.expect_err("empty PAT short-circuits before HTTP");
assert!(matches!(err, AzdoError::InvalidCredentials(_)));
}
#[tokio::test]
async fn get_projects_returns_list_on_200() {
let server = MockServer::start().await;
let body = serde_json::json!({
"count": 2,
"value": [
{
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"name": "MyProject",
"state": "wellFormed",
"visibility": "private",
"lastUpdateTime": "2025-01-01T00:00:00Z"
},
{
"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"name": "OtherProject",
"state": "wellFormed",
"visibility": "public",
"lastUpdateTime": "2025-01-02T00:00:00Z"
}
]
});
Mock::given(method("GET"))
.and(path("/_apis/projects"))
.and(query_param("api-version", "7.1"))
.and(query_param("$top", "100"))
.and(header("authorization", EXPECTED_AUTH))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let projects = client.get_projects().await.expect("200 should yield list");
assert_eq!(projects.len(), 2);
assert_eq!(projects[0].name, "MyProject");
assert_eq!(projects[0].state, "wellFormed");
assert_eq!(projects[0].visibility, "private");
assert_eq!(projects[1].name, "OtherProject");
assert_eq!(projects[1].visibility, "public");
}
#[tokio::test]
async fn get_projects_returns_empty_on_zero_count() {
let server = MockServer::start().await;
let body = serde_json::json!({
"count": 0,
"value": []
});
Mock::given(method("GET"))
.and(path("/_apis/projects"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let projects = client.get_projects().await.expect("200 empty list ok");
assert!(projects.is_empty());
}
#[tokio::test]
async fn get_projects_returns_unauthorized_on_401() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/_apis/projects"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let err = client.get_projects().await.expect_err("401 should err");
assert!(matches!(err, AzdoError::Unauthorized), "got {err:?}");
}
#[tokio::test]
async fn get_work_item_types_parses_response() {
let server = MockServer::start().await;
let body = serde_json::json!({
"count": 2,
"value": [
{
"name": "Bug",
"referenceName": "Microsoft.VSTS.WorkItemTypes.Bug",
"description": "Tracks a defect",
"color": "CC293D",
"icon": { "id": "icon_insect", "url": "https://x" }
},
{
"name": "User Story",
"referenceName": "Microsoft.VSTS.WorkItemTypes.UserStory",
"description": "",
"color": "009CCC",
"icon": { "id": "icon_book" }
}
]
});
Mock::given(method("GET"))
.and(path("/MyProject/_apis/wit/workitemtypes"))
.and(query_param("api-version", "7.1"))
.and(header("authorization", EXPECTED_AUTH))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let types = client
.get_work_item_types("MyProject")
.await
.expect("200 ok");
assert_eq!(types.len(), 2);
assert_eq!(types[0].name, "Bug");
assert_eq!(types[0].reference_name, "Microsoft.VSTS.WorkItemTypes.Bug");
assert_eq!(types[0].color, "CC293D");
assert_eq!(types[0].icon, "icon_insect");
assert_eq!(types[1].name, "User Story");
assert_eq!(types[1].icon, "icon_book");
}
#[tokio::test]
async fn get_work_item_types_maps_401() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/MyProject/_apis/wit/workitemtypes"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let err = client
.get_work_item_types("MyProject")
.await
.expect_err("401 should err");
assert!(matches!(err, AzdoError::Unauthorized));
}
#[tokio::test]
async fn get_fields_parses_response() {
let server = MockServer::start().await;
let body = serde_json::json!({
"count": 2,
"value": [
{ "name": "Title", "referenceName": "System.Title", "type": "string" },
{ "name": "State", "referenceName": "System.State", "type": "string" }
]
});
Mock::given(method("GET"))
.and(path("/MyProject/_apis/wit/fields"))
.and(query_param("api-version", "7.1"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let fields = client.get_fields("MyProject").await.expect("200 ok");
assert_eq!(fields.len(), 2);
assert_eq!(fields[0].reference_name, "System.Title");
assert_eq!(fields[0].field_type, "string");
}
#[tokio::test]
async fn get_fields_encodes_project_with_spaces() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/My%20Project/_apis/wit/fields"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"count": 0,
"value": []
})))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let fields = client
.get_fields("My Project")
.await
.expect("space in project name should encode");
assert!(fields.is_empty());
}
#[tokio::test]
async fn run_wiql_parses_response() {
let server = MockServer::start().await;
let body = serde_json::json!({
"queryType": "flat",
"queryResultType": "workItem",
"workItems": [
{ "id": 42, "url": "https://x/42" },
{ "id": 43, "url": "https://x/43" }
]
});
Mock::given(method("POST"))
.and(path("/MyProject/_apis/wit/wiql"))
.and(query_param("api-version", "7.1"))
.and(header("authorization", EXPECTED_AUTH))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let result = client
.run_wiql("MyProject", "SELECT [System.Id] FROM WorkItems")
.await
.expect("200 ok");
assert_eq!(result.query_type, "flat");
assert_eq!(result.work_items.len(), 2);
assert_eq!(result.work_items[0].id, 42);
assert_eq!(result.work_items[0].url, "https://x/42");
}
#[tokio::test]
async fn get_recent_work_item_ids_returns_ids() {
let server = MockServer::start().await;
let body = serde_json::json!({
"queryType": "flat",
"workItems": [
{ "id": 1, "url": "" },
{ "id": 2, "url": "" }
]
});
Mock::given(method("POST"))
.and(path("/MyProject/_apis/wit/wiql"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let ids = client
.get_recent_work_item_ids("MyProject", 30)
.await
.expect("200 ok");
assert_eq!(ids, vec![1, 2]);
}
#[tokio::test]
async fn run_wiql_maps_401() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/MyProject/_apis/wit/wiql"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let err = client
.run_wiql("MyProject", "SELECT [System.Id] FROM WorkItems")
.await
.expect_err("401");
assert!(matches!(err, AzdoError::Unauthorized));
}
#[tokio::test]
async fn get_work_items_batch_parses_response() {
let server = MockServer::start().await;
let body = serde_json::json!({
"count": 2,
"value": [
{
"id": 42,
"url": "https://x/42",
"fields": {
"System.Id": 42,
"System.Title": "Fix login bug",
"System.State": "Active",
"System.WorkItemType": "Bug",
"System.Tags": "frontend; urgent ; ",
"System.TeamProject": "MyProject"
}
},
{
"id": 43,
"fields": {
"System.Id": 43,
"System.Title": "New feature",
"System.State": "New",
"System.WorkItemType": "User Story",
"System.TeamProject": "MyProject"
}
}
]
});
Mock::given(method("POST"))
.and(path("/_apis/wit/workitemsbatch"))
.and(query_param("api-version", "7.1"))
.and(header("authorization", EXPECTED_AUTH))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let items = client.get_work_items(&[42, 43]).await.expect("200 ok");
assert_eq!(items.len(), 2);
assert_eq!(items[0].id, 42);
assert_eq!(items[0].title, "Fix login bug");
assert_eq!(items[0].state, "Active");
assert_eq!(items[0].work_item_type, "Bug");
assert_eq!(items[0].tags, vec!["frontend", "urgent"]);
assert_eq!(items[0].team_project, "MyProject");
assert_eq!(items[0].url.as_deref(), Some("https://x/42"));
assert!(items[1].tags.is_empty());
assert!(items[1].url.is_none());
}
#[tokio::test]
async fn get_work_items_chunks_in_batches_of_200() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/_apis/wit/workitemsbatch"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"count": 0,
"value": []
})))
.expect(2) .mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let ids: Vec<u32> = (1..=250).collect();
let items = client.get_work_items(&ids).await.expect("200 ok");
assert!(items.is_empty());
drop(server);
}
#[tokio::test]
async fn get_work_items_maps_403() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/_apis/wit/workitemsbatch"))
.respond_with(ResponseTemplate::new(403))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let err = client.get_work_items(&[1]).await.expect_err("403");
assert!(matches!(err, AzdoError::Forbidden));
}
fn default_ab_regex() -> regex::Regex {
regex::Regex::new(r"(?i)\bAB#(\d+)\b").expect("default AB# regex compiles")
}
#[test]
fn extract_work_item_refs_finds_ids() {
let re = default_ab_regex();
let out = extract_work_item_refs(&re, "Fixes AB#42 and AB#100 and AB#42 again");
assert_eq!(out, vec![42, 100]);
}
#[test]
fn extract_work_item_refs_is_case_insensitive() {
let re = default_ab_regex();
let out = extract_work_item_refs(&re, "see ab#7 and Ab#8 and AB#9");
assert_eq!(out, vec![7, 8, 9]);
}
#[test]
fn extract_work_item_refs_returns_empty_when_no_match() {
let re = default_ab_regex();
let out = extract_work_item_refs(&re, "nothing to see here #42 PROJ-1");
assert!(out.is_empty());
}
#[test]
fn extract_work_item_refs_honours_custom_regex() {
let re = regex::Regex::new(r"\B#(\d{4,8})\b").expect("custom regex compiles");
let msg = "Merged PR 12: fix login (#12345) and #67890 follow-up";
let out = extract_work_item_refs(&re, msg);
assert_eq!(out, vec![12_345, 67_890]);
}
#[tokio::test]
async fn fetch_referenced_work_items_aggregates_from_messages() {
let server = MockServer::start().await;
let body = serde_json::json!({
"count": 2,
"value": [
{
"id": 7,
"fields": {
"System.Id": 7,
"System.Title": "Seven",
"System.State": "Active",
"System.WorkItemType": "Task",
"System.TeamProject": "MyProject"
}
},
{
"id": 9,
"fields": {
"System.Id": 9,
"System.Title": "Nine",
"System.State": "Closed",
"System.WorkItemType": "Bug",
"System.TeamProject": "MyProject"
}
}
]
});
Mock::given(method("POST"))
.and(path("/_apis/wit/workitemsbatch"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let re = default_ab_regex();
let msgs = ["fix AB#7", "AB#7 again and AB#9"];
let items = fetch_referenced_work_items(&client, &re, &msgs, "MyProject")
.await
.expect("batch ok");
assert_eq!(items.len(), 2);
let ids: Vec<u32> = items.iter().map(|w| w.id).collect();
assert!(ids.contains(&7));
assert!(ids.contains(&9));
}
#[tokio::test]
async fn fetch_referenced_work_items_uses_custom_regex_end_to_end() {
use wiremock::matchers::body_partial_json;
let server = MockServer::start().await;
let body = serde_json::json!({
"count": 2,
"value": [
{
"id": 12345,
"fields": {
"System.Id": 12345,
"System.Title": "Issue twelve-thousand",
"System.State": "Active",
"System.WorkItemType": "Task",
"System.TeamProject": "MyProject"
}
},
{
"id": 67890,
"fields": {
"System.Id": 67890,
"System.Title": "Follow-up",
"System.State": "Closed",
"System.WorkItemType": "Bug",
"System.TeamProject": "MyProject"
}
}
]
});
Mock::given(method("POST"))
.and(path("/_apis/wit/workitemsbatch"))
.and(body_partial_json(
serde_json::json!({"ids": [12345, 67890]}),
))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let re = regex::Regex::new(r"\B#(\d{4,8})\b").expect("repro regex compiles");
let msgs = [
"Merged PR 12: fix login (#12345)",
"follow-up to #67890 thanks",
];
let items = fetch_referenced_work_items(&client, &re, &msgs, "MyProject")
.await
.expect("batch ok with custom regex");
let ids: Vec<u32> = items.iter().map(|w| w.id).collect();
assert!(ids.contains(&12_345), "expected 12345 to be fetched");
assert!(ids.contains(&67_890), "expected 67890 to be fetched");
assert!(!ids.contains(&12), "PR 12 must not be extracted");
}
#[tokio::test]
async fn fetch_referenced_work_items_empty_when_no_refs() {
let client = AzureDevOpsClient::new(sample_config());
let re = default_ab_regex();
let msgs = ["nothing here", "PROJ-1 only"];
let items = fetch_referenced_work_items(&client, &re, &msgs, "MyProject")
.await
.expect("no HTTP should be triggered");
assert!(items.is_empty());
}
#[test]
fn encode_path_segment_passes_through_unreserved() {
assert_eq!(
encode_path_segment("MyProject_1.2-3~x"),
"MyProject_1.2-3~x"
);
}
#[test]
fn encode_path_segment_encodes_space() {
assert_eq!(encode_path_segment("My Project"), "My%20Project");
}
#[test]
fn encode_path_segment_encodes_slash() {
assert_eq!(encode_path_segment("a/b"), "a%2Fb");
}
#[tokio::test]
async fn get_iterations_parses_response() {
let server = MockServer::start().await;
let body = serde_json::json!({
"count": 2,
"value": [
{
"id": "11111111-1111-1111-1111-111111111111",
"name": "Sprint 1",
"path": "MyProject\\Release 1\\Sprint 1",
"attributes": {
"startDate": "2025-01-01T00:00:00Z",
"finishDate": "2025-01-14T00:00:00Z",
"timeFrame": "past"
}
},
{
"id": "22222222-2222-2222-2222-222222222222",
"name": "Sprint 2",
"path": "MyProject\\Release 1\\Sprint 2",
"attributes": {
"startDate": null,
"finishDate": null,
"timeFrame": "future"
}
}
]
});
Mock::given(method("GET"))
.and(path("/MyProject/_apis/work/teamsettings/iterations"))
.and(query_param("api-version", "7.1"))
.and(header("authorization", EXPECTED_AUTH))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let iters = client.get_iterations("MyProject").await.expect("200 ok");
assert_eq!(iters.len(), 2);
assert_eq!(iters[0].name, "Sprint 1");
assert_eq!(iters[0].time_frame, "past");
assert_eq!(iters[0].start_date.as_deref(), Some("2025-01-01T00:00:00Z"));
assert_eq!(iters[1].time_frame, "future");
assert!(iters[1].start_date.is_none());
}
#[tokio::test]
async fn get_iterations_maps_403() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/MyProject/_apis/work/teamsettings/iterations"))
.respond_with(ResponseTemplate::new(403))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let err = client
.get_iterations("MyProject")
.await
.expect_err("403 should err");
assert!(matches!(err, AzdoError::Forbidden));
}
#[tokio::test]
async fn get_users_parses_response() {
let server = MockServer::start().await;
let body = serde_json::json!({
"count": 2,
"value": [
{
"descriptor": "aad.xxx",
"displayName": "Alice Smith",
"mailAddress": "alice@contoso.com",
"principalName": "alice@contoso.com"
},
{
"descriptor": "msa.yyy",
"displayName": "Bob Jones"
}
]
});
Mock::given(method("GET"))
.and(path("/_graph/users"))
.and(query_param("api-version", "7.1-preview.1"))
.and(header("authorization", EXPECTED_AUTH))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let users = client.get_users().await.expect("200 ok");
assert_eq!(users.len(), 2);
assert_eq!(users[0].display_name, "Alice Smith");
assert_eq!(users[0].mail_address.as_deref(), Some("alice@contoso.com"));
assert_eq!(users[1].display_name, "Bob Jones");
assert!(users[1].mail_address.is_none());
}
#[tokio::test]
async fn get_users_maps_403_for_missing_scope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/_graph/users"))
.respond_with(ResponseTemplate::new(403))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let err = client.get_users().await.expect_err("403 expected");
assert!(matches!(err, AzdoError::Forbidden));
}
#[test]
fn graph_users_url_dev_azure_form() {
let mut cfg = sample_config();
cfg.organization_url = "https://dev.azure.com/myorg".into();
let client = AzureDevOpsClient::new(cfg);
let url = client.graph_users_url().expect("derive url");
assert!(
url.starts_with("https://vssps.dev.azure.com/myorg/_apis/graph/users"),
"got {url}"
);
}
#[test]
fn graph_users_url_visualstudio_form() {
let mut cfg = sample_config();
cfg.organization_url = "https://myorg.visualstudio.com".into();
let client = AzureDevOpsClient::new(cfg);
let url = client.graph_users_url().expect("derive url");
assert!(
url.starts_with("https://vssps.dev.azure.com/myorg/_apis/graph/users"),
"got {url}"
);
}
#[test]
fn feed_azdo_users_registers_email_aliases() {
use crate::collect::identity::IdentityResolver;
let mut resolver = IdentityResolver::new(None);
let users = vec![
AzdoUser {
descriptor: "d1".into(),
display_name: "Alice Smith".into(),
mail_address: Some("alice@contoso.com".into()),
principal_name: Some("alice@contoso.com".into()),
},
AzdoUser {
descriptor: "d2".into(),
display_name: "Bob Jones".into(),
mail_address: None,
principal_name: None,
},
AzdoUser {
descriptor: "d3".into(),
display_name: "".into(),
mail_address: Some("ghost@contoso.com".into()),
principal_name: None,
},
];
feed_azdo_users(&mut resolver, &users);
let (name, _) = resolver.resolve("anybody", "alice@contoso.com");
assert_eq!(name, "Alice Smith");
let (name, email) = resolver.resolve("Bob Jones", "unknown@x.com");
assert_eq!(name, "Bob Jones");
assert_eq!(email, "unknown@x.com");
}
#[tokio::test]
async fn org_url_trailing_slash_is_trimmed() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/_apis/projects"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"count": 0,
"value": []
})))
.mount(&server)
.await;
let mut cfg = sample_config_for(&server.uri());
cfg.organization_url.push('/');
let client = AzureDevOpsClient::new(cfg);
let projects = client
.get_projects()
.await
.expect("trailing slash should be tolerated");
assert!(projects.is_empty());
}
#[tokio::test]
async fn get_work_item_comments_parses_response() {
let server = MockServer::start().await;
let body = serde_json::json!({
"totalCount": 2,
"count": 2,
"comments": [
{
"id": 101,
"workItemId": 42,
"text": "Looks good to me",
"createdBy": { "displayName": "Alice" },
"createdDate": "2025-01-01T12:00:00Z"
},
{
"id": 102,
"workItemId": 42,
"text": "<div>Done</div>",
"createdBy": { "displayName": "Bob" },
"createdDate": "2025-01-02T08:00:00Z"
}
]
});
Mock::given(method("GET"))
.and(path("/MyProject/_apis/wit/workItems/42/comments"))
.and(query_param("api-version", "7.1-preview.3"))
.and(header("authorization", EXPECTED_AUTH))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let comments = client
.get_work_item_comments("MyProject", 42)
.await
.expect("200 ok");
assert_eq!(comments.len(), 2);
assert_eq!(comments[0].id, 101);
assert_eq!(comments[0].text, "Looks good to me");
assert_eq!(comments[0].created_by, "Alice");
assert_eq!(comments[0].created_date, "2025-01-01T12:00:00Z");
assert_eq!(comments[1].text, "<div>Done</div>");
}
#[tokio::test]
async fn get_work_item_comments_maps_404() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/MyProject/_apis/wit/workItems/999/comments"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let err = client
.get_work_item_comments("MyProject", 999)
.await
.expect_err("404");
assert!(matches!(err, AzdoError::NotFound));
}
#[tokio::test]
async fn get_work_item_extended_returns_full_fields() {
let server = MockServer::start().await;
let body = serde_json::json!({
"id": 42,
"url": "https://x/42",
"fields": {
"System.Id": 42,
"System.Title": "Improve cache",
"System.State": "Active",
"System.WorkItemType": "User Story",
"System.Tags": "perf; cache",
"System.IterationPath": "MyProject\\Sprint 3",
"System.AreaPath": "MyProject\\Backend",
"Microsoft.VSTS.Common.Priority": 2,
"Custom.RiskScore": "medium"
}
});
Mock::given(method("GET"))
.and(path("/_apis/wit/workitems/42"))
.and(query_param("api-version", "7.1"))
.and(query_param("$expand", "all"))
.and(header("authorization", EXPECTED_AUTH))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let item = client
.get_work_item_extended(42)
.await
.expect("200 ok")
.expect("found");
assert_eq!(item.id, 42);
assert_eq!(item.title, "Improve cache");
assert_eq!(item.state, "Active");
assert_eq!(item.work_item_type, "User Story");
assert_eq!(item.tags, vec!["perf", "cache"]);
assert_eq!(item.iteration_path.as_deref(), Some("MyProject\\Sprint 3"));
assert_eq!(item.area_path.as_deref(), Some("MyProject\\Backend"));
assert!(!item.custom_fields.contains_key("System.Title"));
assert!(!item.custom_fields.contains_key("System.IterationPath"));
assert_eq!(
item.custom_fields
.get("Microsoft.VSTS.Common.Priority")
.and_then(|v| v.as_i64()),
Some(2)
);
assert_eq!(
item.custom_fields
.get("Custom.RiskScore")
.and_then(|v| v.as_str()),
Some("medium")
);
}
#[tokio::test]
async fn get_work_item_extended_maps_404_to_none() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/_apis/wit/workitems/999"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let out = client.get_work_item_extended(999).await.expect("ok(None)");
assert!(out.is_none());
}
#[tokio::test]
async fn get_work_item_commit_links_extracts_shas() {
let server = MockServer::start().await;
let body = serde_json::json!({
"id": 42,
"relations": [
{
"rel": "ArtifactLink",
"url": "vstfs:///Git/Commit/proj-guid%2Frepo-guid%2Fabc123def456",
"attributes": { "name": "Fixed in Commit" }
},
{
"rel": "ArtifactLink",
"url": "vstfs:///Git/Commit/proj-guid%2Frepo-guid%2F0123456789abcdef",
"attributes": { "name": "Fixed in Commit" }
},
{
"rel": "System.LinkTypes.Related",
"url": "https://dev.azure.com/myorg/_apis/wit/workItems/77",
"attributes": {}
}
]
});
Mock::given(method("GET"))
.and(path("/MyProject/_apis/wit/workItems/42"))
.and(query_param("api-version", "7.1"))
.and(query_param("$expand", "relations"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let shas = client
.get_work_item_commit_links("MyProject", 42)
.await
.expect("200 ok");
assert_eq!(shas, vec!["abc123def456", "0123456789abcdef"]); }
#[tokio::test]
async fn get_work_item_commit_links_404_returns_empty() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/MyProject/_apis/wit/workItems/999"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let client = AzureDevOpsClient::new(sample_config_for(&server.uri()));
let shas = client
.get_work_item_commit_links("MyProject", 999)
.await
.expect("404 yields empty");
assert!(shas.is_empty());
}
#[test]
fn extract_commit_shas_handles_versioned_and_artifact_rels() {
let rels = vec![
WorkItemRelationRaw {
rel: "ArtifactLink".into(),
url: "vstfs:///Git/Commit/p%2Fr%2Fdeadbeef".into(),
attributes: serde_json::Map::new(),
},
WorkItemRelationRaw {
rel: "System.LinkTypes.VersionedRelated".into(),
url: "vstfs:///Git/Commit/p/r/cafebabe".into(),
attributes: serde_json::Map::new(),
},
WorkItemRelationRaw {
rel: "AttachedFile".into(),
url: "vstfs:///Git/Commit/p%2Fr%2Fnotacommit".into(),
attributes: serde_json::Map::new(),
},
];
let shas = extract_commit_shas_from_relations(&rels);
assert_eq!(shas, vec!["deadbeef", "cafebabe"]);
}
}