use crate::collect::azdo::{
client::AzureDevOpsClient,
errors::AzdoError,
types::{AzdoUser, AzdoWorkItem, AzdoWorkItemExtended},
wire::{WorkItemRaw, WorkItemRelationRaw},
};
pub(super) async fn map_response_error(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(super) 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
}
pub(super) 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)
}
pub(super) 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"),
}
}
pub(super) 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,
}
}
pub(super) 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
}