fix: harden Tuleap API client and filter matching
This commit is contained in:
parent
c2636f86c7
commit
10e72ca288
3 changed files with 149 additions and 52 deletions
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue