1use std::collections::HashMap;
2use std::io::Write;
3
4use chrono::{DateTime, Duration, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::error::Result;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct Report {
12 pub generated_at: String,
13 pub currently_suppressed: usize,
14 pub suppressed_this_run: Vec<String>,
15 pub expiring_this_week: usize,
16 pub re_enabled_this_run: Vec<String>,
17 pub quarantine_days_debt: i64,
18}
19
20pub fn build_report(
23 entries: &HashMap<String, Option<DateTime<Utc>>>,
24 suppressed: &[String],
25 re_enabled: &[String],
26) -> Report {
27 let now = Utc::now();
28 let week = now + Duration::days(7);
29 let active: Vec<DateTime<Utc>> = entries
30 .values()
31 .flatten()
32 .copied()
33 .filter(|d| *d > now)
34 .collect();
35 let sorted = |v: &[String]| {
36 let mut v = v.to_vec();
37 v.sort();
38 v.dedup();
39 v
40 };
41 Report {
42 generated_at: now.to_rfc3339(),
43 currently_suppressed: active.len(),
44 suppressed_this_run: sorted(suppressed),
45 expiring_this_week: active.iter().filter(|d| **d <= week).count(),
46 re_enabled_this_run: sorted(re_enabled),
47 quarantine_days_debt: active.iter().map(|d| (*d - now).num_days().max(0)).sum(),
48 }
49}
50
51pub fn build_markdown_summary(r: &Report) -> String {
52 let mut md = format!(
53 "## Skipper Quarantine Report\n\n\
54 | Metric | Value |\n\
55 |--------|-------|\n\
56 | Currently suppressed | {} |\n\
57 | Suppressed this run | {} |\n\
58 | Expiring this week | {} |\n\
59 | Re-enabled this run | {} |\n\
60 | Quarantine-days debt | {} |\n\n",
61 r.currently_suppressed,
62 r.suppressed_this_run.len(),
63 r.expiring_this_week,
64 r.re_enabled_this_run.len(),
65 r.quarantine_days_debt
66 );
67 for (title, ids) in [
68 ("Suppressed this run", &r.suppressed_this_run),
69 ("Re-enabled this run", &r.re_enabled_this_run),
70 ] {
71 if !ids.is_empty() {
72 md.push_str(&format!("### {title}\n\n"));
73 for id in ids {
74 md.push_str(&format!("- `{id}`\n"));
75 }
76 md.push('\n');
77 }
78 }
79 md
80}
81
82pub fn emit_summary(r: &Report) -> Result<()> {
85 let md = build_markdown_summary(r);
86 match std::env::var("GITHUB_STEP_SUMMARY")
87 .ok()
88 .filter(|s| !s.is_empty())
89 {
90 Some(path) => std::fs::OpenOptions::new()
91 .create(true)
92 .append(true)
93 .open(path)?
94 .write_all(md.as_bytes())?,
95 None => println!("{md}"),
96 }
97 std::fs::write("skipper-report.json", serde_json::to_vec_pretty(r)?)?;
98 Ok(())
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 #[test]
106 fn counts_active_quarantine() {
107 let now = Utc::now();
108 let entries = HashMap::from([
109 (
110 "a".to_string(),
111 Some(now + Duration::days(3) + Duration::hours(1)),
112 ),
113 (
114 "b".to_string(),
115 Some(now + Duration::days(30) + Duration::hours(1)),
116 ),
117 ("c".to_string(), Some(now - Duration::days(1))),
118 ("d".to_string(), None),
119 ]);
120 let r = build_report(&entries, &["b".into(), "a".into()], &["c".into()]);
121 assert_eq!(r.currently_suppressed, 2);
122 assert_eq!(r.expiring_this_week, 1);
123 assert_eq!(r.quarantine_days_debt, 33);
124 assert_eq!(r.suppressed_this_run, ["a", "b"]);
125 let md = build_markdown_summary(&r);
126 assert!(md.starts_with("## Skipper Quarantine Report"));
127 assert!(md.contains("### Re-enabled this run\n\n- `c`"));
128 }
129}