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::error::AppError;
|
||||||
use crate::models::credential::{TuleapCredentials, TuleapCredentialsSafe};
|
use crate::models::credential::{TuleapCredentials, TuleapCredentialsSafe};
|
||||||
use crate::services::crypto;
|
use crate::services::crypto;
|
||||||
|
use crate::services::tuleap_client::TuleapClient;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
|
|
@ -64,17 +65,8 @@ pub async fn test_tuleap_connection(state: State<'_, AppState>) -> Result<String
|
||||||
(creds.tuleap_url, creds.username, password)
|
(creds.tuleap_url, creds.username, password)
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = format!("{}/api/projects?limit=1", tuleap_url.trim_end_matches('/'));
|
let client = TuleapClient::new(&state.http_client, &tuleap_url, &username, &password);
|
||||||
|
client.test_connection().await.map_err(AppError::from)?;
|
||||||
state
|
|
||||||
.http_client
|
|
||||||
.get(&url)
|
|
||||||
.basic_auth(&username, Some(&password))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(AppError::from)?
|
|
||||||
.error_for_status()
|
|
||||||
.map_err(AppError::from)?;
|
|
||||||
|
|
||||||
Ok("Connection successful".to_string())
|
Ok("Connection successful".to_string())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -209,4 +209,45 @@ mod tests {
|
||||||
let result = apply_filters(&artifacts, &groups);
|
let result = apply_filters(&artifacts, &groups);
|
||||||
assert_eq!(result.len(), 0);
|
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 serde::{Deserialize, Serialize};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
use tokio::time::{sleep, Duration};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TrackerField {
|
pub struct TrackerField {
|
||||||
|
|
@ -33,36 +34,69 @@ impl TuleapClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_get(&self, url: &str) -> Result<reqwest::Response, String> {
|
async fn send_get(&self, url: &str) -> Result<reqwest::Response, String> {
|
||||||
let started_at = Instant::now();
|
const MAX_ATTEMPTS: u32 = 3;
|
||||||
eprintln!("[tuleap] -> GET {}", url);
|
const BASE_DELAY_MS: u64 = 500;
|
||||||
|
|
||||||
let response = self
|
for attempt in 1..=MAX_ATTEMPTS {
|
||||||
.http
|
let started_at = Instant::now();
|
||||||
.get(url)
|
eprintln!("[tuleap] -> GET {} (attempt {}/{})", url, attempt, MAX_ATTEMPTS);
|
||||||
.basic_auth(&self.username, Some(&self.password))
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match response {
|
let response = self
|
||||||
Ok(resp) => {
|
.http
|
||||||
eprintln!(
|
.get(url)
|
||||||
"[tuleap] <- GET {} | status={} | {}ms",
|
.basic_auth(&self.username, Some(&self.password))
|
||||||
url,
|
.send()
|
||||||
resp.status(),
|
.await;
|
||||||
started_at.elapsed().as_millis()
|
|
||||||
);
|
match response {
|
||||||
Ok(resp)
|
Ok(resp) => {
|
||||||
}
|
let status = resp.status();
|
||||||
Err(err) => {
|
eprintln!(
|
||||||
eprintln!(
|
"[tuleap] <- GET {} | status={} | {}ms",
|
||||||
"[tuleap] xx GET {} | error={} | {}ms",
|
url,
|
||||||
url,
|
status,
|
||||||
err,
|
started_at.elapsed().as_millis()
|
||||||
started_at.elapsed().as_millis()
|
);
|
||||||
);
|
|
||||||
Err(format!("request failed: {}", err))
|
// 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)]
|
#[allow(dead_code)]
|
||||||
|
|
@ -211,6 +245,15 @@ pub fn extract_artifact_field_values(
|
||||||
artifact: &serde_json::Value,
|
artifact: &serde_json::Value,
|
||||||
field_label: &str,
|
field_label: &str,
|
||||||
) -> Vec<String> {
|
) -> 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()) {
|
let values_arr = match artifact.get("values").and_then(|v| v.as_array()) {
|
||||||
Some(a) => a,
|
Some(a) => a,
|
||||||
None => return vec![],
|
None => return vec![],
|
||||||
|
|
@ -233,31 +276,52 @@ pub fn extract_artifact_field_values(
|
||||||
|
|
||||||
match field_type {
|
match field_type {
|
||||||
"sb" | "rb" => {
|
"sb" | "rb" => {
|
||||||
// values[*].label
|
// values[*].id + values[*].label
|
||||||
entry
|
entry
|
||||||
.get("values")
|
.get("values")
|
||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
.map(|arr| {
|
.map(|arr| {
|
||||||
arr.iter()
|
let mut out = Vec::new();
|
||||||
.filter_map(|v| v.get("label").and_then(|l| l.as_str()).map(str::to_string))
|
for v in arr {
|
||||||
.collect()
|
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()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
"msb" | "cb" => {
|
"msb" | "cb" => {
|
||||||
// bind_value_objects[*].display_name, fallback to label
|
// bind_value_objects[*].id + display_name/label
|
||||||
entry
|
entry
|
||||||
.get("bind_value_objects")
|
.get("bind_value_objects")
|
||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
.map(|arr| {
|
.map(|arr| {
|
||||||
arr.iter()
|
let mut out = Vec::new();
|
||||||
.filter_map(|v| {
|
for v in arr {
|
||||||
v.get("display_name")
|
if let Some(id) = v.get("id") {
|
||||||
.and_then(|d| d.as_str())
|
match id {
|
||||||
.or_else(|| v.get("label").and_then(|l| l.as_str()))
|
serde_json::Value::String(s) => push_unique(&mut out, s.clone()),
|
||||||
.map(str::to_string)
|
serde_json::Value::Number(n) => push_unique(&mut out, n.to_string()),
|
||||||
})
|
_ => {}
|
||||||
.collect()
|
}
|
||||||
|
}
|
||||||
|
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()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
@ -376,7 +440,7 @@ mod tests {
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = extract_artifact_field_values(&artifact, "Status");
|
let result = extract_artifact_field_values(&artifact, "Status");
|
||||||
assert_eq!(result, vec!["Nouveau"]);
|
assert_eq!(result, vec!["1", "Nouveau"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -395,7 +459,7 @@ mod tests {
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = extract_artifact_field_values(&artifact, "Assigned to");
|
let result = extract_artifact_field_values(&artifact, "Assigned to");
|
||||||
assert_eq!(result, vec!["Team Maintenance"]);
|
assert_eq!(result, vec!["10", "Team Maintenance"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue