summaryrefslogtreecommitdiffstats
path: root/examples/changelog/src/changelog.rs
diff options
context:
space:
mode:
Diffstat (limited to 'examples/changelog/src/changelog.rs')
-rw-r--r--examples/changelog/src/changelog.rs354
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))
+ }
+}