411 lines
12 KiB
Rust
411 lines
12 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TrackerField {
|
|
pub field_id: i64,
|
|
pub label: String,
|
|
pub field_type: String,
|
|
pub values: Vec<FieldValue>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct FieldValue {
|
|
pub id: i64,
|
|
pub label: String,
|
|
}
|
|
|
|
pub struct TuleapClient {
|
|
http: reqwest::Client,
|
|
base_url: String,
|
|
username: String,
|
|
password: String,
|
|
}
|
|
|
|
impl TuleapClient {
|
|
pub fn new(http: &reqwest::Client, base_url: &str, username: &str, password: &str) -> Self {
|
|
Self {
|
|
http: http.clone(),
|
|
base_url: base_url.trim_end_matches('/').to_string(),
|
|
username: username.to_string(),
|
|
password: password.to_string(),
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub async fn test_connection(&self) -> Result<(), String> {
|
|
let url = format!("{}/api/projects?limit=1", self.base_url);
|
|
let resp = self
|
|
.http
|
|
.get(&url)
|
|
.basic_auth(&self.username, Some(&self.password))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("request failed: {}", e))?;
|
|
|
|
if resp.status().is_success() {
|
|
Ok(())
|
|
} else {
|
|
Err(format!("connection test failed: HTTP {}", resp.status()))
|
|
}
|
|
}
|
|
|
|
pub async fn get_tracker_fields(&self, tracker_id: i32) -> Result<Vec<TrackerField>, String> {
|
|
let url = format!("{}/api/trackers/{}", self.base_url, tracker_id);
|
|
let resp = self
|
|
.http
|
|
.get(&url)
|
|
.basic_auth(&self.username, Some(&self.password))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("request failed: {}", e))?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(format!("get tracker fields failed: HTTP {}", resp.status()));
|
|
}
|
|
|
|
let body: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("failed to parse response: {}", e))?;
|
|
|
|
Ok(parse_tracker_fields(&body))
|
|
}
|
|
|
|
pub async fn get_artifacts(&self, tracker_id: i32) -> Result<Vec<serde_json::Value>, String> {
|
|
let mut all_artifacts: Vec<serde_json::Value> = Vec::new();
|
|
let mut offset = 0usize;
|
|
let limit = 50usize;
|
|
|
|
loop {
|
|
let url = format!(
|
|
"{}/api/trackers/{}/artifacts?values=all&limit={}&offset={}",
|
|
self.base_url, tracker_id, limit, offset
|
|
);
|
|
let resp = self
|
|
.http
|
|
.get(&url)
|
|
.basic_auth(&self.username, Some(&self.password))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("request failed: {}", e))?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(format!("get artifacts failed: HTTP {}", resp.status()));
|
|
}
|
|
|
|
// Read total size from header before consuming body
|
|
let total: usize = resp
|
|
.headers()
|
|
.get("x-pagination-size")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(0);
|
|
|
|
let body: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("failed to parse response: {}", e))?;
|
|
|
|
let artifacts = body
|
|
.as_array()
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
|
|
let fetched = artifacts.len();
|
|
all_artifacts.extend(artifacts);
|
|
|
|
offset += fetched;
|
|
|
|
if fetched == 0 || offset >= total {
|
|
break;
|
|
}
|
|
}
|
|
|
|
Ok(all_artifacts)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pure parsing functions (unit-testable without HTTP)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn parse_tracker_fields(tracker_json: &serde_json::Value) -> Vec<TrackerField> {
|
|
let fields = match tracker_json.get("fields").and_then(|f| f.as_array()) {
|
|
Some(f) => f,
|
|
None => return vec![],
|
|
};
|
|
|
|
fields
|
|
.iter()
|
|
.filter_map(|field| {
|
|
let field_id = field.get("field_id")?.as_i64()?;
|
|
let label = field.get("label")?.as_str()?.to_string();
|
|
let field_type = field.get("type")?.as_str()?.to_string();
|
|
|
|
let values = match field_type.as_str() {
|
|
"sb" | "msb" | "rb" | "cb" => extract_field_values(field),
|
|
_ => vec![],
|
|
};
|
|
|
|
Some(TrackerField {
|
|
field_id,
|
|
label,
|
|
field_type,
|
|
values,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn extract_field_values(field: &serde_json::Value) -> Vec<FieldValue> {
|
|
// Try "values" first (sb, rb), then "bind_value_objects" (msb)
|
|
let candidates = field
|
|
.get("values")
|
|
.and_then(|v| v.as_array())
|
|
.filter(|arr| !arr.is_empty())
|
|
.or_else(|| {
|
|
field
|
|
.get("bind_value_objects")
|
|
.and_then(|v| v.as_array())
|
|
});
|
|
|
|
let arr = match candidates {
|
|
Some(a) => a,
|
|
None => return vec![],
|
|
};
|
|
|
|
arr.iter()
|
|
.filter_map(|v| {
|
|
let id = v.get("id")?.as_i64()?;
|
|
let label = v
|
|
.get("label")
|
|
.and_then(|l| l.as_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
// Filter out "None" labels
|
|
if label == "None" {
|
|
return None;
|
|
}
|
|
Some(FieldValue { id, label })
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub fn extract_artifact_field_values(
|
|
artifact: &serde_json::Value,
|
|
field_label: &str,
|
|
) -> Vec<String> {
|
|
let values_arr = match artifact.get("values").and_then(|v| v.as_array()) {
|
|
Some(a) => a,
|
|
None => return vec![],
|
|
};
|
|
|
|
// Find the field entry matching the label
|
|
let field_entry = values_arr
|
|
.iter()
|
|
.find(|entry| entry.get("label").and_then(|l| l.as_str()) == Some(field_label));
|
|
|
|
let entry = match field_entry {
|
|
Some(e) => e,
|
|
None => return vec![],
|
|
};
|
|
|
|
let field_type = entry
|
|
.get("type")
|
|
.and_then(|t| t.as_str())
|
|
.unwrap_or("");
|
|
|
|
match field_type {
|
|
"sb" | "rb" => {
|
|
// values[*].label
|
|
entry
|
|
.get("values")
|
|
.and_then(|v| v.as_array())
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|v| v.get("label").and_then(|l| l.as_str()).map(str::to_string))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
"msb" | "cb" => {
|
|
// bind_value_objects[*].display_name, fallback to label
|
|
entry
|
|
.get("bind_value_objects")
|
|
.and_then(|v| v.as_array())
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|v| {
|
|
v.get("display_name")
|
|
.and_then(|d| d.as_str())
|
|
.or_else(|| v.get("label").and_then(|l| l.as_str()))
|
|
.map(str::to_string)
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
"string" | "text" | "int" | "float" => {
|
|
// scalar "value" field
|
|
entry
|
|
.get("value")
|
|
.map(|v| match v {
|
|
serde_json::Value::String(s) => vec![s.clone()],
|
|
serde_json::Value::Number(n) => vec![n.to_string()],
|
|
serde_json::Value::Bool(b) => vec![b.to_string()],
|
|
serde_json::Value::Null => vec![],
|
|
other => vec![other.to_string()],
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
_ => vec![],
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn test_parse_tracker_fields_extracts_sb() {
|
|
let tracker = json!({
|
|
"fields": [
|
|
{
|
|
"field_id": 1,
|
|
"label": "Status",
|
|
"type": "sb",
|
|
"values": [
|
|
{ "id": 0, "label": "None" },
|
|
{ "id": 1, "label": "Nouveau" },
|
|
{ "id": 2, "label": "En cours" }
|
|
]
|
|
}
|
|
]
|
|
});
|
|
|
|
let fields = parse_tracker_fields(&tracker);
|
|
assert_eq!(fields.len(), 1);
|
|
let f = &fields[0];
|
|
assert_eq!(f.label, "Status");
|
|
assert_eq!(f.field_type, "sb");
|
|
// "None" is filtered out
|
|
assert_eq!(f.values.len(), 2);
|
|
assert_eq!(f.values[0].label, "Nouveau");
|
|
assert_eq!(f.values[1].label, "En cours");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_tracker_fields_extracts_msb() {
|
|
let tracker = json!({
|
|
"fields": [
|
|
{
|
|
"field_id": 2,
|
|
"label": "Assigned to",
|
|
"type": "msb",
|
|
"bind_value_objects": [
|
|
{ "id": 10, "label": "Alice" },
|
|
{ "id": 11, "label": "Bob" }
|
|
]
|
|
}
|
|
]
|
|
});
|
|
|
|
let fields = parse_tracker_fields(&tracker);
|
|
assert_eq!(fields.len(), 1);
|
|
let f = &fields[0];
|
|
assert_eq!(f.label, "Assigned to");
|
|
assert_eq!(f.values.len(), 2);
|
|
assert_eq!(f.values[0].label, "Alice");
|
|
assert_eq!(f.values[1].label, "Bob");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_tracker_fields_skips_text_fields() {
|
|
let tracker = json!({
|
|
"fields": [
|
|
{
|
|
"field_id": 3,
|
|
"label": "Summary",
|
|
"type": "text"
|
|
}
|
|
]
|
|
});
|
|
|
|
let fields = parse_tracker_fields(&tracker);
|
|
assert_eq!(fields.len(), 1);
|
|
let f = &fields[0];
|
|
assert_eq!(f.label, "Summary");
|
|
assert_eq!(f.field_type, "text");
|
|
assert!(f.values.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_artifact_field_values_sb() {
|
|
let artifact = json!({
|
|
"values": [
|
|
{
|
|
"field_id": 1,
|
|
"label": "Status",
|
|
"type": "sb",
|
|
"values": [
|
|
{ "id": 1, "label": "Nouveau" }
|
|
]
|
|
}
|
|
]
|
|
});
|
|
|
|
let result = extract_artifact_field_values(&artifact, "Status");
|
|
assert_eq!(result, vec!["Nouveau"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_artifact_field_values_msb() {
|
|
let artifact = json!({
|
|
"values": [
|
|
{
|
|
"field_id": 2,
|
|
"label": "Assigned to",
|
|
"type": "msb",
|
|
"bind_value_objects": [
|
|
{ "id": 10, "display_name": "Team Maintenance" }
|
|
]
|
|
}
|
|
]
|
|
});
|
|
|
|
let result = extract_artifact_field_values(&artifact, "Assigned to");
|
|
assert_eq!(result, vec!["Team Maintenance"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_artifact_field_values_missing_field() {
|
|
let artifact = json!({
|
|
"values": []
|
|
});
|
|
|
|
let result = extract_artifact_field_values(&artifact, "Status");
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_artifact_field_values_string_field() {
|
|
let artifact = json!({
|
|
"values": [
|
|
{
|
|
"field_id": 5,
|
|
"label": "Summary",
|
|
"type": "string",
|
|
"value": "Login broken"
|
|
}
|
|
]
|
|
});
|
|
|
|
let result = extract_artifact_field_values(&artifact, "Summary");
|
|
assert_eq!(result, vec!["Login broken"]);
|
|
}
|
|
}
|