use std::collections::HashMap;
use std::convert::From;
use std::default::Default;
use std::fmt;
use std::ops::Neg;
use std::string::ToString;
use askama::Template;
use color_eyre::eyre::{Result, WrapErr};
use counter::Counter;
use once_cell::sync::Lazy;
use regex::Regex;
use serde::Serialize;
use time::{format_description::well_known::Rfc2822, OffsetDateTime};
use crate::extra_fields::DocTextStatus;
use crate::note::content_lines;
use crate::ticket_abstraction::AbstractTicket;
use crate::REGEX_ERROR;
const UNCHECKED_DOC_TYPES: [&str; 3] = [
"known issue",
"technology preview",
"deprecated functionality",
];
const MAX_TITLE_LENGTH: usize = 120;
static VERSION_XYZ_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(\d+)\.(\d+)\.(\d+)").expect(REGEX_ERROR));
static VERSION_XY_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(\d+)\.(\d+)").expect(REGEX_ERROR));
static VERSION_X_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"(\d+)").expect(REGEX_ERROR));
#[derive(Default, Serialize)]
struct OverallProgress {
all: usize,
complete: usize,
complete_pct: f32,
warnings: usize,
warnings_pct: f32,
incomplete: usize,
incomplete_pct: f32,
}
impl From<&[Checks]> for OverallProgress {
fn from(item: &[Checks]) -> Self {
let all = item.len();
let overall_checks: Vec<Status> = item.iter().map(Checks::overall).collect();
let complete = overall_checks
.iter()
.filter(|status| matches!(status, Status::Ok))
.count();
let complete_pct = percentage(complete, all);
let warnings = overall_checks
.iter()
.filter(|status| matches!(status, Status::Warning(_)))
.count();
let warnings_pct = percentage(warnings, all);
let incomplete = overall_checks
.iter()
.filter(|status| matches!(status, Status::Error(_)))
.count();
let incomplete_pct = percentage(incomplete, all);
Self {
all,
complete,
complete_pct,
warnings,
warnings_pct,
incomplete,
incomplete_pct,
}
}
}
fn percentage(part: usize, total: usize) -> f32 {
(part as f32) / (total as f32) * 100.0
}
#[derive(Default, Serialize)]
struct WriterStats<'a> {
name: &'a str,
total: i32,
complete: i32,
warnings: i32,
incomplete: i32,
}
impl<'a> WriterStats<'a> {
fn update(&mut self, checks: &Checks) {
self.total += 1;
match checks.overall() {
Status::Ok => self.complete += 1,
Status::Warning(_) => self.warnings += 1,
Status::Error(_) => self.incomplete += 1,
}
}
fn percent(&self) -> f32 {
if self.total == 0 {
0.0
} else {
(self.complete as f32) / (self.total as f32) * 100.0
}
}
fn new(name: &'a str, checks: &Checks) -> Self {
let mut stats = WriterStats {
name,
..Default::default()
};
stats.update(checks);
stats
}
}
fn calculate_writer_stats<'a>(
tickets_with_checks: &[(&'a AbstractTicket, &Checks)],
) -> Vec<WriterStats<'a>> {
let mut writers_map: HashMap<&str, WriterStats> = HashMap::new();
for (ticket, checks) in tickets_with_checks {
let name = ticket.docs_contact.as_str();
writers_map
.entry(name)
.and_modify(|stats| stats.update(checks))
.or_insert(WriterStats::new(name, checks));
}
let mut writers: Vec<_> = writers_map.into_values().collect();
writers.sort_by_key(|stats| stats.total.neg());
writers
}
#[derive(Default, Serialize)]
struct Checks {
development: Status,
doc_type: Status,
doc_status: Status,
title_and_text: Status,
target_release: Status,
}
impl Checks {
fn overall(&self) -> Status {
let short_text_error = Status::Error("Bad text.".into());
let text_check = match &self.title_and_text {
Status::Error(_) => &short_text_error,
other => other,
};
let items = [
&self.doc_type,
text_check,
&self.doc_status,
&self.development,
&self.target_release,
];
let errors: Vec<&str> = items
.iter()
.filter_map(|status| match status {
Status::Error(e) => Some(e.as_str()),
_ => None,
})
.collect();
let warnings: Vec<&str> = items
.iter()
.filter_map(|status| match status {
Status::Error(e) => Some(e.as_str()),
_ => None,
})
.collect();
if !errors.is_empty() {
Status::Error(errors.join(" "))
} else if !warnings.is_empty() {
Status::Warning(warnings.join(" "))
} else {
Status::Ok
}
}
}
#[derive(Serialize)]
enum Status {
Ok,
Warning(String),
Error(String),
}
impl Default for Status {
fn default() -> Self {
Self::Ok
}
}
impl Status {
fn message(&self) -> &str {
match self {
Self::Ok => "OK",
Self::Warning(message) | Self::Error(message) => message,
}
}
fn color(&self) -> &'static str {
match self {
Self::Ok => "green",
Self::Warning(_) => "orange",
Self::Error(_) => "red",
}
}
fn from_text(text: &str) -> Self {
let content_lines = content_lines(text);
match content_lines.len() {
0 => Self::Error("Empty RN.".into()),
1 => Self::Error("Text in one paragraph.".into()),
_ => {
let first_content_line = content_lines[0];
Self::from_title(first_content_line)
}
}
}
fn from_title(text: &str) -> Self {
let old_title_regex = Regex::new(r"^ *\.(\S+.*)").expect(REGEX_ERROR);
let new_title_regex = Regex::new(r"^ *(\S+.*)::$").expect(REGEX_ERROR);
let (title, is_legacy) = if let Some(caps) = new_title_regex.captures(text) {
(caps.get(1).map(|m| m.as_str()).unwrap_or(""), false)
} else if let Some(caps) = old_title_regex.captures(text) {
(caps.get(1).map(|m| m.as_str()).unwrap_or(""), true)
} else {
return Self::Error("Missing title.".into());
};
let length = title.chars().count();
if text.starts_with(' ') {
Self::Error("Title starts with a space.".into())
} else if length > MAX_TITLE_LENGTH {
Self::Warning(format!("Long title: {length} characters."))
} else if is_legacy {
Self::Warning("OK (legacy format)".into())
} else {
Self::Ok
}
}
fn from_devel_status(status: &str) -> Self {
match status.to_lowercase().as_str() {
"to do" | "new" | "assigned" | "modified" => Self::Warning("Early development.".into()),
_ => Self::Ok,
}
}
fn from_doc_type(doc_type: &str) -> Self {
match doc_type {
"If docs needed, set a value" => Self::Error("Bad doc type.".into()),
_ => Self::Ok,
}
}
fn from_target_release(
ticket_releases: &[String],
likely_release: Option<Version>,
doc_type: &str,
) -> Self {
if let Some(likely_release) = likely_release {
if ticket_releases
.iter()
.any(|r| Version::from(r) == likely_release)
|| UNCHECKED_DOC_TYPES.contains(&doc_type.to_lowercase().as_str())
{
Self::Ok
} else {
Self::Warning("Check target release.".into())
}
} else {
Self::Ok
}
}
}
impl From<DocTextStatus> for Status {
fn from(item: DocTextStatus) -> Self {
match item {
DocTextStatus::Approved => Self::Ok,
DocTextStatus::InProgress => Self::Error("RN not approved.".into()),
DocTextStatus::NoDocumentation => Self::Error("RN not needed.".into()),
}
}
}
impl AbstractTicket {
fn checks(&self, release: Option<Version>) -> Checks {
Checks {
development: Status::from_devel_status(&self.status),
title_and_text: Status::from_text(&self.doc_text),
doc_type: Status::from_doc_type(&self.doc_type),
doc_status: Status::from(self.doc_text_status),
target_release: Status::from_target_release(
&self.target_releases,
release,
&self.doc_type,
),
}
}
fn docs_contact_short(&self) -> &str {
email_prefix(self.docs_contact.as_str())
}
fn assignee_short(&self) -> &str {
if let Some(assignee) = &self.assignee {
email_prefix(assignee)
} else {
"No assignee"
}
}
fn flags_or_labels(&self) -> String {
if let Some(flags) = &self.flags {
flags.join(", ")
} else if let Some(labels) = &self.labels {
labels.join(", ")
} else {
"No flags or labels".to_string()
}
}
fn display_target_releases(&self) -> String {
if self.target_releases.is_empty() {
"No releases".to_string()
} else {
self.target_releases.join(", ")
}
}
fn display_subsystems(&self) -> String {
match &self.subsystems {
Ok(subsystems) => {
if subsystems.is_empty() {
"No subsystems".to_string()
} else {
subsystems.join(", ")
}
}
Err(_) => "Invalid subsystems".to_string(),
}
}
fn display_components(&self) -> String {
if self.components.is_empty() {
"No components".to_string()
} else {
self.components.join(", ")
}
}
fn display_status(&self) -> String {
if self.status.to_lowercase() == "closed" {
let resolution = match &self.resolution {
Some(resolution) => resolution.as_str(),
None => "no resolution",
};
format!("{}: {}", self.status, resolution)
} else {
self.status.clone()
}
}
}
fn email_prefix(email: &str) -> &str {
if let Some(prefix) = email.split('@').next() {
prefix
} else {
email
}
}
fn most_common_product(tickets: &[AbstractTicket]) -> Option<&str> {
let products: Counter<&str> = tickets
.iter()
.map(|ticket| ticket.product.as_str())
.collect();
products
.k_most_common_ordered(1)
.first()
.map(|(elem, _frequency)| *elem)
}
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Ord, PartialOrd)]
enum Version<'a> {
Raw(&'a str),
Parsed {
x: u32,
y: Option<u32>,
z: Option<u32>,
},
}
impl<'a> Version<'a> {
fn from(release: &'a str) -> Self {
let caps_xyz = VERSION_XYZ_REGEX.captures(release);
if let Some(caps) = caps_xyz {
let x = extract_number(&caps, 1).expect("Regular expression failed.");
let y = extract_number(&caps, 2);
let z = extract_number(&caps, 3);
return Version::Parsed { x, y, z };
}
let caps_xy = VERSION_XY_REGEX.captures(release);
if let Some(caps) = caps_xy {
let x = extract_number(&caps, 1).expect("Regular expression failed.");
let y = extract_number(&caps, 2);
let z = None;
return Version::Parsed { x, y, z };
}
let caps_x = VERSION_X_REGEX.captures(release);
if let Some(caps) = caps_x {
let x = extract_number(&caps, 1).expect("Regular expression failed.");
let y = None;
let z = None;
return Version::Parsed { x, y, z };
}
Version::Raw(release)
}
}
impl fmt::Display for Version<'_> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::Parsed { x, y, z } => {
let mut s = x.to_string();
if let Some(y) = y {
s = format!("{s}.{y}");
}
if let Some(z) = z {
s = format!("{s}.{z}");
}
write!(f, "{s}")
}
Self::Raw(s) => {
write!(f, "{s}")
}
}
}
}
fn extract_number(caps: ®ex::Captures, index: usize) -> Option<u32> {
caps.get(index)
.map(|m| m.as_str())
.and_then(|s| s.parse().ok())
}
fn most_common_release(tickets: &[AbstractTicket]) -> Option<Version<'_>> {
let mut releases: Counter<Version> = Counter::new();
for ticket in tickets {
let extracted_versions = ticket
.target_releases
.iter()
.map(|release| Version::from(release));
releases.update(extracted_versions);
}
let two_versions: Vec<Version<'_>> = releases
.k_most_common_ordered(2)
.iter()
.map(|(elem, _frequency)| *elem)
.collect();
log::debug!("The two most common versions: {:?}", two_versions);
let first = two_versions.get(0);
let second = two_versions.get(1);
if second > first {
log::info!(
"The second most common version, {}, is greater than {}. Switching.",
second.map(ToString::to_string).unwrap_or("None".into()),
first.map(ToString::to_string).unwrap_or("None".into())
);
second.copied()
} else {
log::debug!(
"The most common version, {first:?}, is greater than or equal to {second:?}. Keeping."
);
first.copied()
}
}
#[derive(Template, Serialize)] #[template(path = "status-table.html")] struct StatusTableTemplate<'a> {
products: &'a str,
release: &'a str,
overall_progress: OverallProgress,
tickets_with_checks: &'a [(&'a AbstractTicket, &'a Checks)],
per_writer_stats: &'a [WriterStats<'a>],
generated_date: &'a str,
}
pub fn analyze_status(tickets: &[AbstractTicket]) -> Result<(String, String)> {
let product = most_common_product(tickets);
let release = most_common_release(tickets);
let release_s = release.map(|r| r.to_string());
let releases_display = release_s.as_ref().map_or("no releases", |r| r.as_str());
let products_display = product.unwrap_or("no releases");
let date_today = OffsetDateTime::now_utc()
.format(&Rfc2822)
.expect("Cannot format the current date to RFC2822. This is a bug.");
let checks: Vec<Checks> = tickets
.iter()
.map(|ticket| ticket.checks(release))
.collect();
let tickets_with_checks: Vec<(&AbstractTicket, &Checks)> =
tickets.iter().zip(checks.iter()).collect();
let overall_progress: OverallProgress = checks.as_slice().into();
let writer_stats = calculate_writer_stats(&tickets_with_checks);
let status_table = StatusTableTemplate {
products: products_display,
release: releases_display,
overall_progress,
per_writer_stats: &writer_stats,
tickets_with_checks: &tickets_with_checks,
generated_date: &date_today,
};
let as_html = status_table
.render()
.wrap_err("Failed to prepare the status table.")?;
let as_json = serde_json::to_string(&status_table)
.wrap_err("Failed to prepare the JSON status output.")?;
Ok((as_html, as_json))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_versions() {
let release = "rhel-9.3.0 Beta";
let version_9_3_0 = Version::from(release);
assert_eq!(
version_9_3_0,
Version::Parsed {
x: 9,
y: Some(3),
z: Some(0)
}
);
let release = "rhel-9.3";
let version_9_3 = Version::from(release);
assert_eq!(
version_9_3,
Version::Parsed {
x: 9,
y: Some(3),
z: None
}
);
let release = "RHEL 9";
let version_9 = Version::from(release);
assert_eq!(
version_9,
Version::Parsed {
x: 9,
y: None,
z: None
}
);
let release = "No version here.";
let version_none = Version::from(release);
assert_eq!(version_none, Version::Raw(release));
}
#[test]
fn compare_versions() {
let release = "rhel-9.3.0 Beta";
let version_9_3_0 = Version::from(release);
let release = "rhel-9.3";
let version_9_3 = Version::from(release);
let release = "RHEL 9";
let version_9 = Version::from(release);
let release = "rhel-8.9.1";
let version_8_9_1 = Version::from(release);
let release = "No version here.";
let version_none = Version::from(release);
assert!(version_9_3_0 > version_8_9_1);
assert!(version_9_3_0 > version_9_3);
assert!(version_9_3 > version_9);
assert!(version_8_9_1 > version_none);
assert!(version_9 > version_8_9_1);
}
}