use std::convert::TryFrom;
use std::fmt;
use std::string::ToString;
use color_eyre::{eyre::eyre, Report, Result};
use serde::Deserialize;
use serde_json::value::Value;
use bugzilla_query::Bug;
use jira_query::Issue;
use crate::config::tracker;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DocTextStatus {
Approved,
InProgress,
NoDocumentation,
}
impl TryFrom<&str> for DocTextStatus {
type Error = color_eyre::eyre::Error;
fn try_from(string: &str) -> Result<Self> {
match string.to_lowercase().as_str() {
"+" | "done" => Ok(Self::Approved),
"?" | "proposed" | "in progress" | "unset" => Ok(Self::InProgress),
"-" | "rejected" | "upstream only" => Ok(Self::NoDocumentation),
_ => Err(eyre!("Unrecognized doc text status value: {:?}", string)),
}
}
}
impl fmt::Display for DocTextStatus {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let display = match self {
Self::Approved => "Done",
Self::InProgress => "WIP",
Self::NoDocumentation => "No docs",
};
write!(f, "{display}")
}
}
#[derive(Clone, Debug)]
pub struct DocsContact(pub Option<String>);
impl fmt::Display for DocsContact {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let display = self.as_str();
write!(f, "{display}")
}
}
impl DocsContact {
pub fn as_str(&self) -> &str {
let placeholder = "Missing docs contact";
match &self.0 {
Some(text) => {
if text.is_empty() {
placeholder
} else {
text
}
}
None => placeholder,
}
}
}
#[derive(Clone, Copy)]
enum Field {
DocType,
DocText,
TargetRelease,
Subsystems,
DocTextStatus,
DocsContact,
}
impl fmt::Display for Field {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::DocType => write!(f, "doc type"),
Self::DocText => write!(f, "doc text"),
Self::TargetRelease => write!(f, "target release"),
Self::Subsystems => write!(f, "subsystems"),
Self::DocTextStatus => write!(f, "doc text status"),
Self::DocsContact => write!(f, "docs contact"),
}
}
}
pub trait ExtraFields {
fn doc_type(&self, config: &impl tracker::FieldsConfig) -> Result<String>;
fn doc_text(&self, config: &impl tracker::FieldsConfig) -> Result<String>;
fn target_releases(&self, config: &impl tracker::FieldsConfig) -> Vec<String>;
fn subsystems(&self, config: &impl tracker::FieldsConfig) -> Result<Vec<String>>;
fn doc_text_status(&self, config: &impl tracker::FieldsConfig) -> DocTextStatus;
fn docs_contact(&self, config: &impl tracker::FieldsConfig) -> DocsContact;
fn url(&self, tracker: &impl tracker::FieldsConfig) -> String;
}
#[derive(Deserialize, Debug)]
struct BzPool {
team: BzTeam,
}
#[derive(Deserialize, Debug)]
struct BzTeam {
name: String,
}
fn extract_field(field_name: Field, extra: &Value, fields: &[String], id: Id, tracker: &impl tracker::FieldsConfig) -> Result<String> {
let mut errors = Vec::new();
let mut empty_fields: Vec<&str> = Vec::new();
for field in fields {
let field_value = extra.get(field);
if let Some(value) = field_value {
if let Value::Null = value {
empty_fields.push(field);
}
let try_string = value.as_str().map(ToString::to_string);
if let Some(string) = try_string {
return Ok(string);
} else {
let error = eyre!("Field `{field}` is not a string: {value:?}");
errors.push(error);
}
} else {
let error = eyre!("Field `{field}` is missing.");
errors.push(error);
}
}
if empty_fields.is_empty() {
let report = error_chain(errors, field_name, fields, id, tracker);
Err(report)
} else {
log::warn!("Fields are empty in {}: {:?}", id, empty_fields);
Ok(String::new())
}
}
#[derive(Clone, Copy)]
enum Id<'a> {
BZ(i32),
Jira(&'a str),
}
impl fmt::Display for Id<'_> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::BZ(id) => write!(f, "bug {id}"),
Self::Jira(id) => write!(f, "ticket {id}"),
}
}
}
impl Id<'_> {
fn url(&self, tracker: &impl tracker::FieldsConfig) -> String {
match self {
Self::BZ(id) => format!("{}/show_bug.cgi?id={}", tracker.host(), id),
Self::Jira(key) => format!("{}/browse/{}", tracker.host(), key),
}
}
}
fn error_chain(mut errors: Vec<Report>, field_name: Field, fields: &[String], id: Id, tracker: &impl tracker::FieldsConfig) -> Report {
let url = id.url(tracker);
let top_error = eyre!(
"The {} field is missing or malformed in {} ({}).\n\
The configured fields for '{}' are: {:?}",
field_name,
id,
url,
field_name,
fields
);
errors.reverse();
let report = errors.into_iter().reduce(Report::wrap_err);
match report {
Some(report) => report.wrap_err(top_error),
None => top_error,
}
}
impl ExtraFields for Bug {
fn doc_type(&self, config: &impl tracker::FieldsConfig) -> Result<String> {
let fields = config.doc_type();
extract_field(Field::DocType, &self.extra, fields, Id::BZ(self.id), config)
}
fn doc_text(&self, config: &impl tracker::FieldsConfig) -> Result<String> {
let fields = config.doc_text();
extract_field(Field::DocText, &self.extra, fields, Id::BZ(self.id), config)
}
fn target_releases(&self, config: &impl tracker::FieldsConfig) -> Vec<String> {
let fields = config.target_release();
let mut errors = Vec::new();
match extract_field(Field::TargetRelease, &self.extra, fields, Id::BZ(self.id), config) {
Ok(release) => {
let empty_values = ["---"];
let in_list = if empty_values.contains(&release.as_str()) {
vec![]
} else {
vec![release]
};
return in_list;
}
Err(error) => {
errors.push(error);
}
}
match &self.target_release {
Some(versions) => versions.clone().into_vec(),
None => {
let report = error_chain(errors, Field::TargetRelease, fields, Id::BZ(self.id), config);
log::warn!("{report}");
Vec::new()
}
}
}
fn subsystems(&self, config: &impl tracker::FieldsConfig) -> Result<Vec<String>> {
let fields = config.subsystems();
let mut errors = Vec::new();
if fields.is_empty() {
let error = eyre!("No subsystems field is configured in the trackers.yaml file.");
errors.push(error);
}
for field in fields {
let pool_field = self.extra.get(field);
if let Some(pool_field) = pool_field {
let pool: Result<BzPool, serde_json::Error> =
serde_json::from_value(pool_field.clone());
match pool {
Ok(pool) => {
return Ok(vec![pool.team.name]);
}
Err(error) => errors.push(error.into()),
}
} else {
let error = eyre!("Field `{}` is missing", field);
errors.push(error);
}
}
let report = error_chain(errors, Field::Subsystems, fields, Id::BZ(self.id), config);
Err(report)
}
fn doc_text_status(&self, config: &impl tracker::FieldsConfig) -> DocTextStatus {
let fields = config.doc_text_status();
let mut errors = Vec::new();
let mut empty_fields: Vec<&str> = Vec::new();
let default_rdt = DocTextStatus::InProgress;
for flag in fields {
if let Some(rdt) = self.get_flag(flag) {
match DocTextStatus::try_from(rdt) {
Ok(status) => {
return status;
}
Err(error) => {
errors.push(eyre!(
"Failed to extract the doc text status from flag {}.",
flag
));
errors.push(error);
}
}
} else {
empty_fields.push(flag);
}
}
if empty_fields.is_empty() {
let report = error_chain(errors, Field::DocTextStatus, fields, Id::BZ(self.id), config);
log::warn!("{}", report);
} else {
log::warn!(
"Flags are empty in {}: {}",
Id::BZ(self.id),
empty_fields.join(", ")
);
}
default_rdt
}
fn docs_contact(&self, config: &impl tracker::FieldsConfig) -> DocsContact {
let fields = config.docs_contact();
let mut errors = Vec::new();
let docs_contact = extract_field(Field::DocsContact, &self.extra, fields, Id::BZ(self.id), config);
match docs_contact {
Ok(docs_contact) => {
return DocsContact(Some(docs_contact));
}
Err(error) => {
errors.push(error);
}
}
if self.docs_contact.is_none() {
let report = error_chain(errors, Field::DocsContact, fields, Id::BZ(self.id), config);
log::warn!("{}", report);
}
DocsContact(self.docs_contact.clone())
}
fn url(&self, tracker: &impl tracker::FieldsConfig) -> String {
format!("{}/show_bug.cgi?id={}", tracker.host(), self.id)
}
}
#[derive(Deserialize, Debug)]
struct TextEntry {
value: String,
}
#[derive(Deserialize, Debug)]
struct Team {
name: String,
}
#[derive(Deserialize, Debug)]
#[serde(untagged)]
enum Subsystems {
Strings(Vec<TextEntry>),
String(TextEntry),
Team(Team),
Teams(Vec<Team>),
}
impl ExtraFields for Issue {
fn doc_type(&self, config: &impl tracker::FieldsConfig) -> Result<String> {
let fields = config.doc_type();
let mut errors = Vec::new();
for field in fields {
let doc_type_field = self.fields.extra.get(field);
if let Some(doc_type_field) = doc_type_field {
let doc_type: Result<TextEntry, serde_json::Error> =
serde_json::from_value(doc_type_field.clone());
match doc_type {
Ok(doc_type) => {
return Ok(doc_type.value);
}
Err(error) => {
errors.push(eyre!(
"The `{}` field has an unexpected structure:\n{:#?}",
field,
doc_type_field
));
errors.push(error.into());
}
}
} else {
errors.push(eyre!("The `{field}` field is missing."));
};
}
let report = error_chain(errors, Field::DocType, fields, Id::Jira(&self.key), config);
Err(report)
}
fn doc_text(&self, config: &impl tracker::FieldsConfig) -> Result<String> {
let fields = config.doc_text();
extract_field(
Field::DocText,
&self.fields.extra,
fields,
Id::Jira(&self.key),
config,
)
}
fn target_releases(&self, config: &impl tracker::FieldsConfig) -> Vec<String> {
let fields = config.target_release();
let mut errors = Vec::new();
for field in fields {
if let Some(value) = self.fields.extra.get(field) {
let jira_versions: Result<Vec<jira_query::Version>, serde_json::Error> =
serde_json::from_value(value.clone());
match jira_versions {
Ok(vec) => {
let versions: Vec<String> =
vec.iter().map(|version| version.name.clone()).collect();
return versions;
}
Err(error) => {
errors.push(error.into());
}
}
let string_versions: Result<Vec<String>, serde_json::Error> =
serde_json::from_value(value.clone());
match string_versions {
Ok(vec) => {
return vec;
}
Err(error) => {
errors.push(error.into());
}
}
let string = extract_field(
Field::TargetRelease,
&self.extra,
&[field.clone()],
Id::Jira(&self.key),
config,
);
match string {
Ok(string) => {
return vec![string];
}
Err(error) => {
errors.push(error);
}
}
} else {
errors.push(eyre!("The `{field}` field is missing"));
}
}
if !errors.is_empty() {
let id = Id::Jira(&self.key);
let report = error_chain(errors, Field::TargetRelease, fields, id, config);
log::warn!("The custom target releases failed in {}. Falling back on the standard fix versions field.", id);
log::debug!("{}", report);
}
let standard_field = self
.fields
.fix_versions
.iter()
.map(|version| version.name.clone())
.collect();
standard_field
}
fn subsystems(&self, config: &impl tracker::FieldsConfig) -> Result<Vec<String>> {
let fields = config.subsystems();
let mut errors = Vec::new();
if fields.is_empty() {
let error = eyre!("No subsystems field is configured in the trackers.yaml file.");
errors.push(error);
}
for field in fields {
let pool = self.fields.extra.get(field);
if let Some(pool) = pool {
let ssts: Result<Subsystems, serde_json::Error> =
serde_json::from_value(pool.clone());
match ssts {
Ok(Subsystems::Strings(values)) => {
let sst_names = values.into_iter().map(|sst| sst.value).collect();
return Ok(sst_names);
}
Ok(Subsystems::String(value)) => {
return Ok(vec![value.value]);
}
Ok(Subsystems::Team(team)) => {
let sst_names = vec![team.name];
return Ok(sst_names);
}
Ok(Subsystems::Teams(teams)) => {
let sst_names = teams.into_iter().map(|team| team.name).collect();
return Ok(sst_names);
}
Err(error) => {
errors.push(error.into());
}
}
}
}
let report = error_chain(errors, Field::Subsystems, fields, Id::Jira(&self.key), config);
Err(report)
}
fn doc_text_status(&self, config: &impl tracker::FieldsConfig) -> DocTextStatus {
let default_status = DocTextStatus::InProgress;
let mut errors = Vec::new();
let fields = config.doc_text_status();
for field in fields {
let rdt_field = self
.fields
.extra
.get(field)
.and_then(|rdt| rdt.get("value"));
if let Some(rdt_field) = rdt_field {
match rdt_field.as_str() {
None => {
let error = eyre!(
"The doc text status field ({}) is empty in {}.",
field,
Id::Jira(&self.key)
);
errors.push(error);
return default_status;
}
Some(string) => match DocTextStatus::try_from(string) {
Ok(status) => {
return status;
}
Err(e) => {
errors.push(e);
return default_status;
}
},
}
};
}
let report = error_chain(errors, Field::DocTextStatus, fields, Id::Jira(&self.key), config);
log::warn!("{}", report);
default_status
}
fn docs_contact(&self, config: &impl tracker::FieldsConfig) -> DocsContact {
let fields = config.docs_contact();
for field in fields {
let contact = self
.fields
.extra
.get(field)
.and_then(|cf| cf.get("emailAddress"))
.and_then(Value::as_str)
.map(ToString::to_string);
if contact.is_some() {
return DocsContact(contact);
}
}
let report = error_chain(Vec::new(), Field::DocsContact, fields, Id::Jira(&self.key), config);
log::warn!("{}", report);
DocsContact(None)
}
fn url(&self, tracker: &impl tracker::FieldsConfig) -> String {
format!("{}/browse/{}", tracker.host(), &self.key)
}
}