fix: harden Tuleap API client and filter matching

This commit is contained in:
thibaud-leclere 2026-04-14 15:11:31 +02:00
parent c2636f86c7
commit 10e72ca288
3 changed files with 149 additions and 52 deletions

View file

@ -1,6 +1,7 @@
use crate::error::AppError;
use crate::models::credential::{TuleapCredentials, TuleapCredentialsSafe};
use crate::services::crypto;
use crate::services::tuleap_client::TuleapClient;
use crate::AppState;
use tauri::State;
@ -64,17 +65,8 @@ pub async fn test_tuleap_connection(state: State<'_, AppState>) -> Result<String
(creds.tuleap_url, creds.username, password)
};
let url = format!("{}/api/projects?limit=1", tuleap_url.trim_end_matches('/'));
state
.http_client
.get(&url)
.basic_auth(&username, Some(&password))
.send()
.await
.map_err(AppError::from)?
.error_for_status()
.map_err(AppError::from)?;
let client = TuleapClient::new(&state.http_client, &tuleap_url, &username, &password);
client.test_connection().await.map_err(AppError::from)?;
Ok("Connection successful".to_string())
}

View file

@ -209,4 +209,45 @@ mod tests {
let result = apply_filters(&artifacts, &groups);
assert_eq!(result.len(), 0);
}
#[test]
fn test_in_filter_matches_on_value_id() {
let artifacts = vec![
json!({
"id": 123,
"title": "A",
"values": [
{
"field_id": 1,
"label": "Status",
"type": "sb",
"values": [{ "id": 1, "label": "Nouveau" }]
}
]
}),
json!({
"id": 124,
"title": "B",
"values": [
{
"field_id": 1,
"label": "Status",
"type": "sb",
"values": [{ "id": 9, "label": "Ferme" }]
}
]
}),
];
let groups = vec![FilterGroup {
conditions: vec![Filter {
field: "Status".to_string(),
operator: "In".to_string(),
value: vec!["1".to_string()],
}],
}];
let result = apply_filters(&artifacts, &groups);
assert_eq!(result.len(), 1);
assert_eq!(result[0]["values"][0]["values"][0]["label"], "Nouveau");
}
}

View file

@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use std::time::Instant;
use tokio::time::{sleep, Duration};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrackerField {
@ -33,36 +34,69 @@ impl TuleapClient {
}
async fn send_get(&self, url: &str) -> Result<reqwest::Response, String> {
let started_at = Instant::now();
eprintln!("[tuleap] -> GET {}", url);
const MAX_ATTEMPTS: u32 = 3;
const BASE_DELAY_MS: u64 = 500;
let response = self
.http
.get(url)
.basic_auth(&self.username, Some(&self.password))
.send()
.await;
for attempt in 1..=MAX_ATTEMPTS {
let started_at = Instant::now();
eprintln!("[tuleap] -> GET {} (attempt {}/{})", url, attempt, MAX_ATTEMPTS);
match response {
Ok(resp) => {
eprintln!(
"[tuleap] <- GET {} | status={} | {}ms",
url,
resp.status(),
started_at.elapsed().as_millis()
);
Ok(resp)
}
Err(err) => {
eprintln!(
"[tuleap] xx GET {} | error={} | {}ms",
url,
err,
started_at.elapsed().as_millis()
);
Err(format!("request failed: {}", err))
let response = self
.http
.get(url)
.basic_auth(&self.username, Some(&self.password))
.send()
.await;
match response {
Ok(resp) => {
let status = resp.status();
eprintln!(
"[tuleap] <- GET {} | status={} | {}ms",
url,
status,
started_at.elapsed().as_millis()
);
// Retry transient HTTP failures.
if (status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error())
&& attempt < MAX_ATTEMPTS
{
let delay_ms = BASE_DELAY_MS * 2u64.pow(attempt - 1);
eprintln!(
"[tuleap] ~~ retry GET {} in {}ms (status={})",
url, delay_ms, status
);
sleep(Duration::from_millis(delay_ms)).await;
continue;
}
return Ok(resp);
}
Err(err) => {
eprintln!(
"[tuleap] xx GET {} | error={} | {}ms",
url,
err,
started_at.elapsed().as_millis()
);
if attempt < MAX_ATTEMPTS {
let delay_ms = BASE_DELAY_MS * 2u64.pow(attempt - 1);
eprintln!(
"[tuleap] ~~ retry GET {} in {}ms (error={})",
url, delay_ms, err
);
sleep(Duration::from_millis(delay_ms)).await;
continue;
}
return Err(format!("request failed: {}", err));
}
}
}
Err("request failed after retries".to_string())
}
#[allow(dead_code)]
@ -211,6 +245,15 @@ pub fn extract_artifact_field_values(
artifact: &serde_json::Value,
field_label: &str,
) -> Vec<String> {
fn push_unique(values: &mut Vec<String>, value: String) {
if value.is_empty() {
return;
}
if !values.iter().any(|v| v == &value) {
values.push(value);
}
}
let values_arr = match artifact.get("values").and_then(|v| v.as_array()) {
Some(a) => a,
None => return vec![],
@ -233,31 +276,52 @@ pub fn extract_artifact_field_values(
match field_type {
"sb" | "rb" => {
// values[*].label
// values[*].id + 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()
let mut out = Vec::new();
for v in arr {
if let Some(id) = v.get("id") {
match id {
serde_json::Value::String(s) => push_unique(&mut out, s.clone()),
serde_json::Value::Number(n) => push_unique(&mut out, n.to_string()),
_ => {}
}
}
if let Some(label) = v.get("label").and_then(|l| l.as_str()) {
push_unique(&mut out, label.to_string());
}
}
out
})
.unwrap_or_default()
}
"msb" | "cb" => {
// bind_value_objects[*].display_name, fallback to label
// bind_value_objects[*].id + display_name/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()
let mut out = Vec::new();
for v in arr {
if let Some(id) = v.get("id") {
match id {
serde_json::Value::String(s) => push_unique(&mut out, s.clone()),
serde_json::Value::Number(n) => push_unique(&mut out, n.to_string()),
_ => {}
}
}
if let Some(label) = v
.get("display_name")
.and_then(|d| d.as_str())
.or_else(|| v.get("label").and_then(|l| l.as_str()))
{
push_unique(&mut out, label.to_string());
}
}
out
})
.unwrap_or_default()
}
@ -376,7 +440,7 @@ mod tests {
});
let result = extract_artifact_field_values(&artifact, "Status");
assert_eq!(result, vec!["Nouveau"]);
assert_eq!(result, vec!["1", "Nouveau"]);
}
#[test]
@ -395,7 +459,7 @@ mod tests {
});
let result = extract_artifact_field_values(&artifact, "Assigned to");
assert_eq!(result, vec!["Team Maintenance"]);
assert_eq!(result, vec!["10", "Team Maintenance"]);
}
#[test]