diff options
Diffstat (limited to 'examples/changelog/src/changelog.rs')
-rw-r--r-- | examples/changelog/src/changelog.rs | 354 |
1 files changed, 354 insertions, 0 deletions
diff --git a/examples/changelog/src/changelog.rs b/examples/changelog/src/changelog.rs new file mode 100644 index 00000000..39cbb42c --- /dev/null +++ b/examples/changelog/src/changelog.rs @@ -0,0 +1,354 @@ +use serde::Deserialize; +use tokio::fs; +use tokio::process; + +use std::env; +use std::fmt; +use std::io; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub struct Changelog { + ids: Vec<u64>, + added: Vec<String>, + changed: Vec<String>, + fixed: Vec<String>, + removed: Vec<String>, + authors: Vec<String>, +} + +impl Changelog { + pub fn new() -> Self { + Self { + ids: Vec::new(), + added: Vec::new(), + changed: Vec::new(), + fixed: Vec::new(), + removed: Vec::new(), + authors: Vec::new(), + } + } + + pub async fn list() -> Result<(Self, Vec<Candidate>), Error> { + let mut changelog = Self::new(); + + { + let markdown = fs::read_to_string("CHANGELOG.md").await?; + + if let Some(unreleased) = markdown.split("\n## ").nth(1) { + let sections = unreleased.split("\n\n"); + + for section in sections { + if section.starts_with("Many thanks to...") { + for author in section.lines().skip(1) { + let author = author.trim_start_matches("- @"); + + if author.is_empty() { + continue; + } + + changelog.authors.push(author.to_owned()); + } + + continue; + } + + let Some((_, rest)) = section.split_once("### ") else { + continue; + }; + + let Some((name, rest)) = rest.split_once("\n") else { + continue; + }; + + let category = match name { + "Added" => Category::Added, + "Fixed" => Category::Fixed, + "Changed" => Category::Changed, + "Removed" => Category::Removed, + _ => continue, + }; + + for entry in rest.lines() { + let Some((_, id)) = entry.split_once('#') else { + continue; + }; + + let Some((id, _)) = id.split_once(']') else { + continue; + }; + + let Ok(id): Result<u64, _> = id.parse() else { + continue; + }; + + changelog.ids.push(id); + + let target = match category { + Category::Added => &mut changelog.added, + Category::Changed => &mut changelog.added, + Category::Fixed => &mut changelog.fixed, + Category::Removed => &mut changelog.removed, + }; + + target.push(entry.to_owned()); + } + } + } + } + + let mut candidates = Candidate::list().await?; + + for reviewed_entry in changelog.entries() { + candidates.retain(|candidate| candidate.id != reviewed_entry); + } + + Ok((changelog, candidates)) + } + + pub fn len(&self) -> usize { + self.ids.len() + } + + pub fn entries(&self) -> impl Iterator<Item = u64> + '_ { + self.ids.iter().copied() + } + + pub fn push(&mut self, entry: Entry) { + self.ids.push(entry.id); + + let item = format!( + "- {title}. [#{id}](https://github.com/iced-rs/iced/pull/{id})", + title = entry.title, + id = entry.id + ); + + let target = match entry.category { + Category::Added => &mut self.added, + Category::Changed => &mut self.added, + Category::Fixed => &mut self.fixed, + Category::Removed => &mut self.removed, + }; + + target.push(item); + + if !self.authors.contains(&entry.author) { + self.authors.push(entry.author); + self.authors.sort_by_key(|author| author.to_lowercase()); + } + } +} + +impl fmt::Display for Changelog { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fn section(category: Category, entries: &[String]) -> String { + if entries.is_empty() { + return String::new(); + } + + format!("### {category}\n{list}\n", list = entries.join("\n")) + } + + fn thank_you<'a>(authors: impl IntoIterator<Item = &'a str>) -> String { + let mut list = String::new(); + + for author in authors { + list.push_str(&format!("- @{author}\n")); + } + + format!("Many thanks to...\n{list}") + } + + let changelog = [ + section(Category::Added, &self.added), + section(Category::Changed, &self.changed), + section(Category::Fixed, &self.fixed), + section(Category::Removed, &self.removed), + thank_you(self.authors.iter().map(String::as_str)), + ] + .into_iter() + .filter(|section| !section.is_empty()) + .collect::<Vec<String>>() + .join("\n"); + + f.write_str(&changelog) + } +} + +#[derive(Debug, Clone)] +pub struct Entry { + pub id: u64, + pub title: String, + pub category: Category, + pub author: String, +} + +impl Entry { + pub fn new( + title: &str, + category: Category, + pull_request: &PullRequest, + ) -> Option<Self> { + let title = title.strip_suffix(".").unwrap_or(title); + + if title.is_empty() { + return None; + }; + + Some(Self { + id: pull_request.id, + title: title.to_owned(), + category, + author: pull_request.author.clone(), + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Category { + Added, + Changed, + Fixed, + Removed, +} + +impl Category { + pub const ALL: &'static [Self] = + &[Self::Added, Self::Changed, Self::Fixed, Self::Removed]; + + pub fn guess(label: &str) -> Option<Self> { + Some(match label { + "feature" | "addition" => Self::Added, + "change" => Self::Changed, + "bug" | "fix" => Self::Fixed, + _ => None?, + }) + } +} + +impl fmt::Display for Category { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Category::Added => "Added", + Category::Changed => "Changed", + Category::Fixed => "Fixed", + Category::Removed => "Removed", + }) + } +} + +#[derive(Debug, Clone)] +pub struct Candidate { + pub id: u64, +} + +#[derive(Debug, Clone)] +pub struct PullRequest { + pub id: u64, + pub title: String, + pub description: String, + pub labels: Vec<String>, + pub author: String, +} + +impl Candidate { + pub async fn list() -> Result<Vec<Candidate>, Error> { + let output = process::Command::new("git") + .args([ + "log", + "--oneline", + "--grep", + "#[0-9]*", + "origin/latest..HEAD", + ]) + .output() + .await?; + + let log = String::from_utf8_lossy(&output.stdout); + + Ok(log + .lines() + .filter(|title| !title.is_empty()) + .filter_map(|title| { + let (_, pull_request) = title.split_once("#")?; + let (pull_request, _) = pull_request.split_once([')', ' '])?; + + Some(Candidate { + id: pull_request.parse().ok()?, + }) + }) + .collect()) + } + + pub async fn fetch(self) -> Result<PullRequest, Error> { + let request = reqwest::Client::new() + .request( + reqwest::Method::GET, + format!( + "https://api.github.com/repos/iced-rs/iced/pulls/{}", + self.id + ), + ) + .header("User-Agent", "iced changelog generator") + .header( + "Authorization", + format!( + "Bearer {}", + env::var("GITHUB_TOKEN") + .map_err(|_| Error::GitHubTokenNotFound)? + ), + ); + + #[derive(Deserialize)] + struct Schema { + title: String, + body: String, + user: User, + labels: Vec<Label>, + } + + #[derive(Deserialize)] + struct User { + login: String, + } + + #[derive(Deserialize)] + struct Label { + name: String, + } + + let schema: Schema = request.send().await?.json().await?; + + Ok(PullRequest { + id: self.id, + title: schema.title, + description: schema.body, + labels: schema.labels.into_iter().map(|label| label.name).collect(), + author: schema.user.login, + }) + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum Error { + #[error("io operation failed: {0}")] + IOFailed(Arc<io::Error>), + + #[error("http request failed: {0}")] + RequestFailed(Arc<reqwest::Error>), + + #[error("no GITHUB_TOKEN variable was set")] + GitHubTokenNotFound, +} + +impl From<io::Error> for Error { + fn from(error: io::Error) -> Self { + Error::IOFailed(Arc::new(error)) + } +} + +impl From<reqwest::Error> for Error { + fn from(error: reqwest::Error) -> Self { + Error::RequestFailed(Arc::new(error)) + } +} |