feat: Tuleap HTTP client with artifact parsing and field extraction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
thibaud-leclere 2026-04-13 14:28:36 +02:00
parent 3cf28babab
commit 73f6909be4
2 changed files with 411 additions and 0 deletions

View file

@ -1 +1,2 @@
pub mod crypto;
pub mod tuleap_client;

View file

@ -0,0 +1,410 @@
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(),
}
}
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"]);
}
}