use serde::Deserialize;
use tokio::fs;
use tokio::process;
use std::collections::BTreeSet;
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<Contribution>), 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 = Contribution::list().await?;
for reviewed_entry in changelog.entries() {
candidates.retain(|candidate| candidate.id != reviewed_entry);
}
Ok((changelog, candidates))
}
pub async fn save(self) -> Result<(), Error> {
let markdown = fs::read_to_string("CHANGELOG.md").await?;
let Some((header, rest)) = markdown.split_once("\n## ") else {
return Err(Error::InvalidFormat);
};
let Some((_unreleased, rest)) = rest.split_once("\n## ") else {
return Err(Error::InvalidFormat);
};
let unreleased = format!(
"\n## [Unreleased]\n{changelog}",
changelog = self.to_string()
);
let rest = format!("\n## {rest}");
let changelog = [header, &unreleased, &rest].concat();
fs::write("CHANGELOG.md", changelog).await?;
Ok(())
}
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 entry.author != "hecrj" && !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, PartialEq, Eq, PartialOrd, Ord)]
pub struct Contribution {
pub id: u64,
}
impl Contribution {
pub async fn list() -> Result<Vec<Contribution>, 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);
let mut contributions: Vec<_> = log
.lines()
.filter(|title| !title.is_empty())
.filter_map(|title| {
let (_, pull_request) = title.split_once("#")?;
let (pull_request, _) = pull_request.split_once([')', ' '])?;
Some(Contribution {
id: pull_request.parse().ok()?,
})
})
.collect();
let mut unique = BTreeSet::from_iter(contributions.clone());
contributions.retain_mut(|contribution| unique.remove(contribution));
Ok(contributions)
}
}
#[derive(Debug, Clone)]
pub struct PullRequest {
pub id: u64,
pub title: String,
pub description: String,
pub labels: Vec<String>,
pub author: String,
}
impl PullRequest {
pub async fn fetch(contribution: Contribution) -> Result<Self, Error> {
let request = reqwest::Client::new()
.request(
reqwest::Method::GET,
format!(
"https://api.github.com/repos/iced-rs/iced/pulls/{}",
contribution.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(Self {
id: contribution.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,
#[error("the changelog format is not valid")]
InvalidFormat,
}
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))
}
}