use serde::Deserialize;
use crate::core::component_labels::ComponentLabels;
use crate::core::trusty_tools_config::ResolvedTicketing;
pub const AUDIT_JSON_FIELDS: &str =
"number,milestone,projectItems,labels,comments,state,createdAt,parent,blockedBy,subIssues";
pub const NO_MILESTONE_PREFIX: &str = "no-milestone:";
pub const NO_COMPONENT_LABEL_PREFIX: &str = "no-component-label:";
pub const PROJECT_ATTACH_HINT: &str =
"gh project item-add <number> --owner <owner> --url <issue-url>";
#[derive(Debug, Clone, Default, Deserialize)]
pub struct TitleRef {
#[serde(default)]
pub title: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct NameRef {
#[serde(default)]
pub name: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct CommentRef {
#[serde(default)]
pub body: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct NumberRef {
#[serde(default)]
pub number: u64,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Connection {
#[serde(default)]
pub nodes: Vec<NumberRef>,
#[serde(default)]
pub total_count: u64,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct IssueFacts {
#[serde(default)]
pub number: u64,
#[serde(default)]
pub milestone: Option<TitleRef>,
#[serde(default)]
pub project_items: Vec<TitleRef>,
#[serde(default)]
pub labels: Vec<NameRef>,
#[serde(default)]
pub comments: Vec<CommentRef>,
#[serde(default)]
pub state: String,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub parent: Option<NumberRef>,
#[serde(default)]
pub blocked_by: Connection,
#[serde(default)]
pub sub_issues: Connection,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Pass,
Fail,
Skip,
Info,
}
impl Verdict {
#[must_use]
pub fn tag(self) -> &'static str {
match self {
Self::Pass => "PASS",
Self::Fail => "FAIL",
Self::Skip => "SKIP",
Self::Info => "INFO",
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct AuditRow {
pub requirement: &'static str,
pub verdict: Verdict,
pub detail: String,
}
impl AuditRow {
#[must_use]
pub fn new(requirement: &'static str, verdict: Verdict, detail: impl Into<String>) -> Self {
Self {
requirement,
verdict,
detail: detail.into(),
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct IssueAudit {
pub number: u64,
pub rows: Vec<AuditRow>,
}
impl IssueAudit {
#[must_use]
pub fn failed(&self) -> bool {
self.rows.iter().any(|r| r.verdict == Verdict::Fail)
}
#[must_use]
pub fn failing_requirements(&self) -> Vec<&'static str> {
self.rows
.iter()
.filter(|r| r.verdict == Verdict::Fail)
.map(|r| r.requirement)
.collect()
}
}
const REQ_PROJECT: &str = "project";
const REQ_MILESTONE: &str = "milestone";
const REQ_COMPONENT: &str = "component label";
const REQ_RELATIONSHIPS: &str = "relationships";
pub const SUMMARY_REQUIREMENTS: &[&str] = &[REQ_PROJECT, REQ_MILESTONE, REQ_COMPONENT];
#[must_use]
pub fn audit_issue(
facts: &IssueFacts,
ticketing: &ResolvedTicketing,
components: &ComponentLabels,
) -> IssueAudit {
let rows = vec![
project_row(facts, ticketing),
milestone_row(facts, ticketing),
component_row(facts, components),
relationships_row(facts),
];
IssueAudit {
number: facts.number,
rows,
}
}
fn project_row(facts: &IssueFacts, ticketing: &ResolvedTicketing) -> AuditRow {
let titles: Vec<&str> = facts
.project_items
.iter()
.map(|p| p.title.as_str())
.filter(|t| !t.is_empty())
.collect();
let (verdict, detail) = if titles.is_empty() {
if ticketing.project_required {
(
Verdict::Fail,
format!("no project — `{PROJECT_ATTACH_HINT}` (numbers: `tm issue standard`)"),
)
} else {
(
Verdict::Info,
"no project (project_required: false)".to_string(),
)
}
} else {
(Verdict::Pass, titles.join(", "))
};
AuditRow {
requirement: REQ_PROJECT,
verdict,
detail,
}
}
fn milestone_row(facts: &IssueFacts, ticketing: &ResolvedTicketing) -> AuditRow {
let title = facts
.milestone
.as_ref()
.map(|m| m.title.as_str())
.filter(|t| !t.is_empty());
let (verdict, detail) = match title {
Some(title) => (Verdict::Pass, title.to_string()),
None => match no_milestone_reason(&facts.comments) {
Some(reason) => (Verdict::Skip, format!("({reason})")),
None if ticketing.milestone_required => (
Verdict::Fail,
"no milestone and no `no-milestone: <reason>` comment".to_string(),
),
None => (
Verdict::Info,
"no milestone (milestone_required: false)".to_string(),
),
},
};
AuditRow {
requirement: REQ_MILESTONE,
verdict,
detail,
}
}
fn no_milestone_reason(comments: &[CommentRef]) -> Option<String> {
waiver_reason(comments, NO_MILESTONE_PREFIX)
}
fn no_component_label_reason(comments: &[CommentRef]) -> Option<String> {
waiver_reason(comments, NO_COMPONENT_LABEL_PREFIX)
}
fn waiver_reason(comments: &[CommentRef], prefix: &str) -> Option<String> {
comments.iter().find_map(|c| {
c.body.lines().find_map(|line| {
let line = line.trim();
let head = line.get(..prefix.len())?;
head.eq_ignore_ascii_case(prefix)
.then(|| line[prefix.len()..].trim().to_string())
})
})
}
fn component_row(facts: &IssueFacts, components: &ComponentLabels) -> AuditRow {
let present: Vec<&str> = facts
.labels
.iter()
.map(|l| l.name.as_str())
.filter(|name| components.accepts(name))
.collect();
let (verdict, detail) = if present.is_empty() {
if let Some(reason) = no_component_label_reason(&facts.comments) {
return AuditRow {
requirement: REQ_COMPONENT,
verdict: Verdict::Skip,
detail: format!("({reason})"),
};
}
(
Verdict::Fail,
format!(
"none of [{}] present and no `no-component-label: <reason>` \
comment (labels: {})",
components.names().join(", "),
if facts.labels.is_empty() {
"none".to_string()
} else {
facts
.labels
.iter()
.map(|l| l.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
),
)
} else {
(Verdict::Pass, present.join(", "))
};
AuditRow {
requirement: REQ_COMPONENT,
verdict,
detail,
}
}
fn relationships_row(facts: &IssueFacts) -> AuditRow {
let mut parts = Vec::with_capacity(3);
match &facts.parent {
Some(p) if p.number != 0 => parts.push(format!("parent #{}", p.number)),
_ => parts.push("no parent".to_string()),
}
parts.push(format!("blocked-by {}", facts.blocked_by.total_count));
parts.push(format!("sub-issues {}", facts.sub_issues.total_count));
AuditRow {
requirement: REQ_RELATIONSHIPS,
verdict: Verdict::Info,
detail: parts.join("; "),
}
}
#[must_use]
pub fn created_on_or_after(facts: &IssueFacts, date: &str) -> bool {
facts.created_at.is_empty() || facts.created_at.as_str() >= date
}
#[must_use]
pub fn render_audit(audit: &IssueAudit) -> String {
let width = audit
.rows
.iter()
.map(|r| r.requirement.len())
.max()
.unwrap_or(0);
let mut out = format!("#{}\n", audit.number);
for row in &audit.rows {
out.push_str(&format!(
" {:width$} {} {}\n",
row.requirement,
row.verdict.tag(),
row.detail,
));
}
out
}
#[must_use]
pub fn render_summary(audits: &[IssueAudit]) -> String {
let mut out = String::from("issue project milestone component verdict\n");
for audit in audits {
let tag = |req: &str| {
audit
.rows
.iter()
.find(|r| r.requirement == req)
.map_or("-", |r| r.verdict.tag())
};
out.push_str(&format!(
"{:<9} {:<9} {:<10} {:<10} {}\n",
format!("#{}", audit.number),
tag(REQ_PROJECT),
tag(REQ_MILESTONE),
tag(REQ_COMPONENT),
if audit.failed() { "FAIL" } else { "PASS" },
));
}
let failing = audits.iter().filter(|a| a.failed()).count();
out.push_str(&format!(
"\n{} issue(s) audited, {failing} with a FAIL\n",
audits.len()
));
out
}
#[cfg(test)]
#[path = "issue_audit_tests.rs"]
mod tests;