//! Pipeline service — shared pipeline-domain logic. //! //! Contains pure functions for parsing and aggregating pipeline status data. //! Used by the gateway service for cross-project aggregation and potentially //! by other consumers that need to reason about pipeline stage counts. use serde_json::{Value, json}; /// Parse a `get_pipeline_status` JSON payload and produce aggregated counts /// plus a list of blocked/failing items. pub fn aggregate_pipeline_counts(pipeline: &Value) -> Value { let active = pipeline .get("active") .and_then(|a| a.as_array()) .cloned() .unwrap_or_default(); let backlog_count = pipeline .get("backlog_count") .and_then(|n| n.as_u64()) .unwrap_or(0); let mut current = 0u64; let mut qa = 0u64; let mut merge = 0u64; let mut done = 0u64; let mut blocked: Vec = Vec::new(); for item in &active { let stage = item .get("stage") .and_then(|s| s.as_str()) .unwrap_or("unknown"); match stage { "current" => current += 1, "qa" => qa += 1, "merge" => merge += 1, "done" => done += 1, _ => {} } let is_blocked = item .get("blocked") .and_then(|b| b.as_bool()) .unwrap_or(false); let merge_failure = item.get("merge_failure"); let has_merge_failure = merge_failure .map(|f| !f.is_null() && f != "") .unwrap_or(false); if is_blocked || has_merge_failure { let story_id = item .get("story_id") .and_then(|s| s.as_str()) .unwrap_or("?") .to_string(); let story_name = item .get("name") .and_then(|s| s.as_str()) .unwrap_or("") .to_string(); let reason = if has_merge_failure { format!( "merge failure: {}", merge_failure.and_then(|f| f.as_str()).unwrap_or("unknown") ) } else { let rc = item .get("retry_count") .and_then(|n| n.as_u64()) .unwrap_or(0); format!("blocked after {rc} retries") }; blocked.push(json!({ "story_id": story_id, "name": story_name, "stage": stage, "reason": reason, })); } } json!({ "counts": { "backlog": backlog_count, "current": current, "qa": qa, "merge": merge, "done": done, }, "blocked": blocked, }) } // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn aggregate_empty_pipeline() { let pipeline = json!({ "active": [], "backlog": [], "backlog_count": 0 }); let result = aggregate_pipeline_counts(&pipeline); assert_eq!(result["counts"]["backlog"], 0); assert_eq!(result["counts"]["current"], 0); assert_eq!(result["counts"]["qa"], 0); assert_eq!(result["counts"]["merge"], 0); assert_eq!(result["counts"]["done"], 0); assert_eq!(result["blocked"].as_array().unwrap().len(), 0); } #[test] fn aggregate_stage_counts_correct() { let pipeline = json!({ "active": [ { "story_id": "1_story_a", "name": "A", "stage": "current" }, { "story_id": "2_story_b", "name": "B", "stage": "current" }, { "story_id": "3_story_c", "name": "C", "stage": "qa" }, { "story_id": "4_story_d", "name": "D", "stage": "done" }, ], "backlog": [{ "story_id": "5_story_e", "name": "E" }, { "story_id": "6_story_f", "name": "F" }], "backlog_count": 2 }); let result = aggregate_pipeline_counts(&pipeline); assert_eq!(result["counts"]["backlog"], 2); assert_eq!(result["counts"]["current"], 2); assert_eq!(result["counts"]["qa"], 1); assert_eq!(result["counts"]["merge"], 0); assert_eq!(result["counts"]["done"], 1); assert_eq!(result["blocked"].as_array().unwrap().len(), 0); } #[test] fn aggregate_blocked_items_captured() { let pipeline = json!({ "active": [ { "story_id": "10_story_blocked", "name": "Blocked", "stage": "current", "blocked": true, "retry_count": 3 }, { "story_id": "20_story_ok", "name": "OK", "stage": "qa" }, ], "backlog": [], "backlog_count": 0 }); let result = aggregate_pipeline_counts(&pipeline); let blocked = result["blocked"].as_array().unwrap(); assert_eq!(blocked.len(), 1); assert_eq!(blocked[0]["story_id"], "10_story_blocked"); assert_eq!(blocked[0]["stage"], "current"); assert!( blocked[0]["reason"] .as_str() .unwrap() .contains("blocked after 3 retries"), ); } }