diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 43d347a1de779e63e71571463602e9f76a01baeb..2b6fd7d4d3d82476aefb23e857e7f3cc9f517cfe 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -32,3 +32,5 @@ version-compare = "0.0.11" tera = { version = "1", default-features = false } weight-analyzer = {path = "../resources/weight_analyzer"} scale-value = "0.13.0" +serde_yaml = { version = "0.9.27", default-features = false } +chrono = "0.4.31" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 5efd1857c8a827bd3717f5ed230ae4ec2d0eb677..c65408416ac7a7ff755eec65280fdad7d6e01b05 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -57,7 +57,12 @@ enum DuniterXTaskCommand { /// Update raw specs locally with the files published on a Release UpdateRawSpecs { milestone: String }, /// Project management: show issues for a Milestone - ShowMilestone { milestone: String }, + ShowMilestone { + milestone: String, + #[clap(long)] + /// Previous dump to compare with + compare_with: Option<String>, + }, /// Create asset in a release CreateAssetLink { tag: String, @@ -94,9 +99,10 @@ async fn main() -> Result<()> { DuniterXTaskCommand::ReleaseRuntime { milestone, branch } => { release_runtime::release_runtime(milestone, branch).await } - DuniterXTaskCommand::ShowMilestone { milestone } => { - show_milestone::show_milestone(milestone).await - } + DuniterXTaskCommand::ShowMilestone { + milestone, + compare_with, + } => show_milestone::show_milestone(milestone, compare_with).await, DuniterXTaskCommand::UpdateRawSpecs { milestone } => { release_runtime::update_raw_specs(milestone).await } diff --git a/xtask/src/release_runtime.rs b/xtask/src/release_runtime.rs index ce3f467116f1d789a9f8c897d207e9954ab63c15..d1d1cfaf8c7a63a062158f8446b033359ab5279e 100644 --- a/xtask/src/release_runtime.rs +++ b/xtask/src/release_runtime.rs @@ -100,13 +100,18 @@ pub(super) async fn release_runtime(milestone: String, branch: String) -> Result // Get changes (list of MRs) from gitlab API let changes = get_changes::get_changes(milestone.clone()).await?; + let changes_str = changes + .iter() + .map(|change| change.to_string()) + .collect::<Vec<String>>() + .join("\n"); release_notes.push_str( format!( " # Changes -{changes} +{changes_str} " ) .as_str(), diff --git a/xtask/src/release_runtime/get_changes.rs b/xtask/src/release_runtime/get_changes.rs index a4147cf3fcb667c5d9bd8cdb9aec64c0d36c0a3a..f396898658f93d9ff8df7a95d8a77dbe2d6acc34 100644 --- a/xtask/src/release_runtime/get_changes.rs +++ b/xtask/src/release_runtime/get_changes.rs @@ -25,7 +25,23 @@ use graphql_client::{GraphQLQuery, Response}; )] pub struct GetChangesQuery; -pub(crate) async fn get_changes(milestone: String) -> Result<String> { +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct GitlabMRChange { + pub title: String, + pub mr_number: u32, +} + +impl ToString for GitlabMRChange { + fn to_string(&self) -> String { + format!( + "* {mr_title} (!{mr_number})", + mr_title = self.title, + mr_number = self.mr_number + ) + } +} + +pub(crate) async fn get_changes(milestone: String) -> Result<Vec<GitlabMRChange>> { // this is the important line let request_body = GetChangesQuery::build_query(get_changes_query::Variables { milestone }); @@ -41,13 +57,12 @@ pub(crate) async fn get_changes(milestone: String) -> Result<String> { if let Some(project) = data.project { if let Some(merge_requests) = project.merge_requests { if let Some(nodes) = merge_requests.nodes { - let mut changes = String::new(); + let mut changes = Vec::<GitlabMRChange>::new(); for merge_request in nodes.into_iter().flatten() { - changes.push_str(&format!( - "* {mr_title} (!{mr_number})\n", - mr_title = merge_request.title, - mr_number = merge_request.iid - )); + changes.push(GitlabMRChange { + title: merge_request.title, + mr_number: merge_request.iid.parse()?, + }); } Ok(changes) } else { diff --git a/xtask/src/release_runtime/get_issues.rs b/xtask/src/release_runtime/get_issues.rs index d7bc8f0392706905b6bd82d809187a5d22f46e66..a1080a7fc6bcb7493bee51a417ee17d1a1adac98 100644 --- a/xtask/src/release_runtime/get_issues.rs +++ b/xtask/src/release_runtime/get_issues.rs @@ -16,6 +16,7 @@ use anyhow::{anyhow, Result}; use graphql_client::{GraphQLQuery, Response}; +use std::fmt::Display; #[derive(GraphQLQuery)] #[graphql( @@ -25,14 +26,26 @@ use graphql_client::{GraphQLQuery, Response}; )] pub struct GetIssuesQuery; -pub struct Issue { +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +pub struct GitlabIssue { pub title: String, - pub number: String, + pub number: u32, pub status: String, pub assignees: Vec<String>, } -pub(crate) async fn get_issues(milestone: String) -> Result<Vec<Issue>> { +impl Display for GitlabIssue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let str = format!( + "#{issue_number} — {issue_title}", + issue_title = self.title, + issue_number = self.number + ); + write!(f, "{}", str) + } +} + +pub(crate) async fn get_issues(milestone: String) -> Result<Vec<GitlabIssue>> { // this is the important line let request_body = GetIssuesQuery::build_query(get_issues_query::Variables { milestone }); @@ -50,9 +63,9 @@ pub(crate) async fn get_issues(milestone: String) -> Result<Vec<Issue>> { if let Some(nodes) = issues.nodes { let mut changes = Vec::new(); for issue in nodes.into_iter().flatten() { - changes.push(Issue { + changes.push(GitlabIssue { title: issue.title, - number: issue.iid.expect("iid must exist"), + number: issue.iid.expect("iid must exist").parse()?, status: format!("{:?}", issue.state), assignees: issue .assignees diff --git a/xtask/src/show_milestone.rs b/xtask/src/show_milestone.rs index 5293e217a3ed8b9386caf7acb0d5a290ca42c363..7614e06769b1d7747cf4b225b455a5573f420d5b 100644 --- a/xtask/src/show_milestone.rs +++ b/xtask/src/show_milestone.rs @@ -14,57 +14,236 @@ // You should have received a copy of the GNU Affero General Public License // along with Duniter-v2S. If not, see <https://www.gnu.org/licenses/>. +use crate::release_runtime::get_changes::GitlabMRChange; +use crate::release_runtime::get_issues::GitlabIssue; use crate::release_runtime::{get_changes, get_issues}; use anyhow::Result; +use std::fs; -pub(super) async fn show_milestone(milestone: String) -> Result<()> { - // Get changes (list of MRs) from gitlab API - let changes = get_changes::get_changes(milestone.clone()).await?; +#[derive(Debug, serde::Serialize, serde::Deserialize)] +struct MilestoneDump { + milestone: String, + date: String, + changes: Vec<GitlabMRChange>, + issues: Vec<GitlabIssue>, + new_issues: IssueCounter, + unstarted_issues: IssueCounter, + taken_issues: IssueCounter, + unchanged_issues: IssueCounter, + open_issues: IssueCounter, + closed_issues: IssueCounter, +} - let mut release_notes = format!(""); - release_notes.push_str( - format!( - " -# Changes +#[derive(Debug, serde::Serialize, serde::Deserialize)] +struct IssueCounter { + count: u32, + issues: Vec<u32>, +} -{changes} -" - ) - .as_str(), - ); +impl IssueCounter { + fn new() -> Self { + IssueCounter { + count: 0, + issues: Vec::new(), + } + } +} + +impl From<Vec<GitlabIssue>> for IssueCounter { + fn from(issues: Vec<GitlabIssue>) -> Self { + IssueCounter { + count: issues.len() as u32, + issues: issues.into_iter().map(|issue| issue.number).collect(), + } + } +} + +impl MilestoneDump { + fn new() -> Self { + MilestoneDump { + milestone: "".to_string(), + date: "".to_string(), + changes: Vec::new(), + issues: Vec::new(), + new_issues: IssueCounter::new(), + taken_issues: IssueCounter::new(), + unchanged_issues: IssueCounter::new(), + unstarted_issues: IssueCounter::new(), + open_issues: IssueCounter::new(), + closed_issues: IssueCounter::new(), + } + } +} +pub(super) async fn show_milestone(milestone: String, compare_with: Option<String>) -> Result<()> { // Get changes (list of MRs) from gitlab API + let mut changes = get_changes::get_changes(milestone.clone()).await?; let mut issues = get_issues::get_issues(milestone.clone()).await?; - let mut issues_str = String::new(); - issues.sort_by(|a, b| b.assignees.len().cmp(&a.assignees.len())); - issues.iter().for_each(|issue| { - issues_str.push_str(&format!( - "* [#{issue_number}](https://git.duniter.org/nodes/rust/duniter-v2s/-/issues/{issue_number}) ({issue_status}", - issue_number = issue.number, - issue_status = issue.status - )); - if !issue.assignees.is_empty() { - issues_str.push_str(&format!( - " — assigned to {assignees}) ", - assignees = issue.assignees.join(", ") - )); - } else { - issues_str.push_str(") "); - } - issues_str.push_str(&format!(" {issue_title}\n", issue_title = issue.title)); - }); + let previous_dump: MilestoneDump = compare_with + .map(|file| serde_yaml::from_str(&fs::read_to_string(file).unwrap()).unwrap()) + .unwrap_or(MilestoneDump::new()); - release_notes.push_str( - format!( - " -# Issues + changes.sort_by(|a, b| b.mr_number.cmp(&a.mr_number)); + let dump = MilestoneDump { + milestone: milestone.clone(), + date: chrono::Local::now().format("%Y-%m-%d").to_string(), + changes, + issues: issues.clone(), + new_issues: issues + .clone() + .into_iter() + .filter(|issue| { + !previous_dump + .issues + .iter() + .find(|p| p.number == issue.number) + .is_some() + }) + .collect::<Vec<GitlabIssue>>() + .into(), + taken_issues: issues + .clone() + .into_iter() + .filter(|issue| { + previous_dump + .issues + .iter() + .find(|p| { + p.number == issue.number + && p.status == "opened" + && p.assignees.is_empty() + && !issue.assignees.is_empty() + }) + .is_some() + }) + .collect::<Vec<GitlabIssue>>() + .into(), + unchanged_issues: issues + .clone() + .into_iter() + .filter(|issue| previous_dump.issues.iter().find(|p| *p == issue).is_some()) + .collect::<Vec<GitlabIssue>>() + .into(), + unstarted_issues: issues + .clone() + .into_iter() + .filter(|issue| issue.status == "opened" && issue.assignees.is_empty()) + .collect::<Vec<GitlabIssue>>() + .into(), + open_issues: issues + .clone() + .into_iter() + .filter(|issue| issue.status == "opened") + .collect::<Vec<GitlabIssue>>() + .into(), + closed_issues: issues + .clone() + .into_iter() + .filter(|issue| issue.status == "closed") + .collect::<Vec<GitlabIssue>>() + .into(), + }; + + let mut release_notes = format!(""); + release_notes.push_str(serde_yaml::to_string(&dump)?.as_str()); -{issues_str} -" + // Dump issues ordered by ID (for raw changelog — allows to compare with previous changelog to see new issues) + + let mut issues_str = "## Issues\n\n".to_string(); + issues_str.push_str( + print_issues_section( + "\n### Ouvertes", + &mut dump + .open_issues + .issues + .iter() + .map(|i| get_issue(&issues, *i)) + .collect(), + ) + .as_str(), + ); + // issues_str.push_str(print_issues_section("\n#### Dont non démarrées", + // &mut dump.unstarted_issues.issues.iter().map(|i| get_issue(&issues, *i)).collect()).as_str()); + issues_str.push_str( + print_issues_section( + "\n#### Dont assignées depuis le dernier point", + &mut dump + .taken_issues + .issues + .iter() + .map(|i| get_issue(&issues, *i)) + .filter(|i| !i.assignees.is_empty()) + .collect(), + ) + .as_str(), + ); + issues_str.push_str( + print_issues_section( + "\n#### Dont stagnantes depuis le dernier point", + &mut dump + .unchanged_issues + .issues + .iter() + .map(|i| get_issue(&issues, *i)) + .filter(|i| i.status == "opened") + .collect(), + ) + .as_str(), + ); + // issues_str.push_str(print_issues_section("\n### Fermées", + // &mut dump.closed_issues.issues.iter().map(|i| get_issue(&issues, *i)).collect()).as_str()); + issues_str.push_str( + print_issues_section( + "\n#### Dont fermées depuis le dernier point", + &mut dump + .taken_issues + .issues + .iter() + .map(|i| get_issue(&issues, *i)) + .filter(|i| i.status == "closed") + .chain( + dump.new_issues + .issues + .iter() + .map(|i| get_issue(&issues, *i)) + .filter(|i| i.status == "closed"), + ) + .collect(), ) .as_str(), ); + release_notes.push_str(issues_str.as_str()); println!("{}", release_notes); - Ok(()) } + +fn print_issues_section(title: &str, issues: &mut Vec<GitlabIssue>) -> String { + let mut issues_str = String::new(); + issues_str.push_str(format!("{}\n\n", title).as_str()); + issues_str.push_str(format!("Total : {}\n\n", issues.len()).as_str()); + issues_str.push_str("| ID | Status | Assignees | Title |\n"); + issues_str.push_str("| -- | ------ | --------- | ----- |\n"); + issues.sort_by(|a, b| b.number.cmp(&a.number)); + issues.iter().for_each(|issue_number| { + issues_str.push_str(print_issue(issue_number).as_str()); + }); + issues_str +} + +fn print_issue(issue: &GitlabIssue) -> String { + format!( + "| [#{issue_number}](https://git.duniter.org/nodes/rust/duniter-v2s/-/issues/{issue_number}) | {issue_status} | {assignees} | {issue_title} |\n", + issue_number = issue.number, + issue_status = issue.status, + assignees = issue.assignees.join(", "), + issue_title = issue.title + ) +} + +fn get_issue(issues: &Vec<GitlabIssue>, number: u32) -> GitlabIssue { + issues + .iter() + .find(|issue| issue.number == number) + .unwrap() + .clone() +}