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:
parent
3cf28babab
commit
73f6909be4
2 changed files with 411 additions and 0 deletions
|
|
@ -1 +1,2 @@
|
|||
pub mod crypto;
|
||||
pub mod tuleap_client;
|
||||
|
|
|
|||
410
src-tauri/src/services/tuleap_client.rs
Normal file
410
src-tauri/src/services/tuleap_client.rs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue