diff options
| author | 2024-09-17 04:44:56 +0200 | |
|---|---|---|
| committer | 2024-09-17 04:44:56 +0200 | |
| commit | 547e509683007b9e0c149d847ac685f3aa770de8 (patch) | |
| tree | 5cb2e9b75c736d116079165d359115bf23eb3bac /examples/changelog/src | |
| parent | 8fb939b5a920e0cd836dbdd24c948f8f2512fc7e (diff) | |
| download | iced-547e509683007b9e0c149d847ac685f3aa770de8.tar.gz iced-547e509683007b9e0c149d847ac685f3aa770de8.tar.bz2 iced-547e509683007b9e0c149d847ac685f3aa770de8.zip  | |
Implement a `changelog-generator` tool and example
Diffstat (limited to '')
| -rw-r--r-- | examples/changelog/src/changelog.rs | 354 | ||||
| -rw-r--r-- | examples/changelog/src/icon.rs | 10 | ||||
| -rw-r--r-- | examples/changelog/src/main.rs | 368 | 
3 files changed, 732 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)) +    } +} diff --git a/examples/changelog/src/icon.rs b/examples/changelog/src/icon.rs new file mode 100644 index 00000000..dd82e5b9 --- /dev/null +++ b/examples/changelog/src/icon.rs @@ -0,0 +1,10 @@ +use iced::widget::{text, Text}; +use iced::Font; + +pub const FONT_BYTES: &[u8] = include_bytes!("../fonts/changelog-icons.ttf"); + +const FONT: Font = Font::with_name("changelog-icons"); + +pub fn copy() -> Text<'static> { +    text('\u{e800}').font(FONT) +} diff --git a/examples/changelog/src/main.rs b/examples/changelog/src/main.rs new file mode 100644 index 00000000..73da0f2c --- /dev/null +++ b/examples/changelog/src/main.rs @@ -0,0 +1,368 @@ +mod changelog; +mod icon; + +use crate::changelog::Changelog; + +use iced::clipboard; +use iced::font; +use iced::widget::{ +    button, center, column, container, markdown, pick_list, progress_bar, +    rich_text, row, scrollable, span, stack, text, text_input, +}; +use iced::{Element, Fill, FillPortion, Font, Task, Theme}; + +pub fn main() -> iced::Result { +    iced::application("Changelog Generator", Generator::update, Generator::view) +        .font(icon::FONT_BYTES) +        .theme(Generator::theme) +        .run_with(Generator::new) +} + +enum Generator { +    Loading, +    Empty, +    Reviewing { +        changelog: Changelog, +        pending: Vec<changelog::Candidate>, +        state: State, +        preview: Vec<markdown::Item>, +    }, +} + +enum State { +    Loading(changelog::Candidate), +    Loaded { +        pull_request: changelog::PullRequest, +        description: Vec<markdown::Item>, +        title: String, +        category: changelog::Category, +    }, +} + +#[derive(Debug, Clone)] +enum Message { +    ChangelogListed( +        Result<(Changelog, Vec<changelog::Candidate>), changelog::Error>, +    ), +    PullRequestFetched(Result<changelog::PullRequest, changelog::Error>), +    UrlClicked(markdown::Url), +    TitleChanged(String), +    CategorySelected(changelog::Category), +    Next, +    OpenPullRequest(u64), +    CopyPreview, +} + +impl Generator { +    fn new() -> (Self, Task<Message>) { +        ( +            Self::Loading, +            Task::perform(Changelog::list(), Message::ChangelogListed), +        ) +    } + +    fn update(&mut self, message: Message) -> Task<Message> { +        match message { +            Message::ChangelogListed(Ok((changelog, mut pending))) => { +                if let Some(candidate) = pending.pop() { +                    let preview = +                        markdown::parse(&changelog.to_string()).collect(); + +                    *self = Self::Reviewing { +                        changelog, +                        pending, +                        state: State::Loading(candidate.clone()), +                        preview, +                    }; + +                    Task::perform( +                        candidate.fetch(), +                        Message::PullRequestFetched, +                    ) +                } else { +                    *self = Self::Empty; + +                    Task::none() +                } +            } +            Message::PullRequestFetched(Ok(pull_request)) => { +                let Self::Reviewing { state, .. } = self else { +                    return Task::none(); +                }; + +                let description = +                    markdown::parse(&pull_request.description).collect(); + +                *state = State::Loaded { +                    title: pull_request.title.clone(), +                    category: pull_request +                        .labels +                        .iter() +                        .map(String::as_str) +                        .filter_map(changelog::Category::guess) +                        .next() +                        .unwrap_or(changelog::Category::Added), +                    pull_request, +                    description, +                }; + +                Task::none() +            } +            Message::ChangelogListed(Err(error)) +            | Message::PullRequestFetched(Err(error)) => { +                log::error!("{error}"); + +                Task::none() +            } +            Message::UrlClicked(url) => { +                let _ = webbrowser::open(url.as_str()); + +                Task::none() +            } +            Message::TitleChanged(new_title) => { +                let Self::Reviewing { state, .. } = self else { +                    return Task::none(); +                }; + +                let State::Loaded { title, .. } = state else { +                    return Task::none(); +                }; + +                *title = new_title; + +                Task::none() +            } +            Message::CategorySelected(new_category) => { +                let Self::Reviewing { state, .. } = self else { +                    return Task::none(); +                }; + +                let State::Loaded { category, .. } = state else { +                    return Task::none(); +                }; + +                *category = new_category; + +                Task::none() +            } +            Message::Next => { +                let Self::Reviewing { +                    changelog, +                    pending, +                    state, +                    preview, +                    .. +                } = self +                else { +                    return Task::none(); +                }; + +                let State::Loaded { +                    title, +                    category, +                    pull_request, +                    .. +                } = state +                else { +                    return Task::none(); +                }; + +                if let Some(entry) = +                    changelog::Entry::new(title, *category, pull_request) +                { +                    changelog.push(entry); + +                    *preview = +                        markdown::parse(&changelog.to_string()).collect(); + +                    if let Some(candidate) = pending.pop() { +                        *state = State::Loading(candidate.clone()); + +                        Task::perform( +                            candidate.fetch(), +                            Message::PullRequestFetched, +                        ) +                    } else { +                        // TODO: We are done! +                        Task::none() +                    } +                } else { +                    Task::none() +                } +            } +            Message::OpenPullRequest(id) => { +                let _ = webbrowser::open(&format!( +                    "https://github.com/iced-rs/iced/pull/{id}" +                )); + +                Task::none() +            } +            Message::CopyPreview => { +                let Self::Reviewing { changelog, .. } = self else { +                    return Task::none(); +                }; + +                clipboard::write(changelog.to_string()) +            } +        } +    } + +    fn view(&self) -> Element<Message> { +        match self { +            Self::Loading => center("Loading...").into(), +            Self::Empty => center("No changes found!").into(), +            Self::Reviewing { +                changelog, +                pending, +                state, +                preview, +            } => { +                let progress = { +                    let total = pending.len() + changelog.len(); + +                    let bar = progress_bar( +                        0.0..=1.0, +                        changelog.len() as f32 / total as f32, +                    ) +                    .style(progress_bar::secondary); + +                    let label = text!( +                        "{amount_reviewed} / {total}", +                        amount_reviewed = changelog.len() +                    ) +                    .font(Font::MONOSPACE) +                    .size(12); + +                    stack![bar, center(label)] +                }; + +                let form: Element<_> = match state { +                    State::Loading(candidate) => { +                        text!("Loading #{}...", candidate.id).into() +                    } +                    State::Loaded { +                        pull_request, +                        description, +                        title, +                        category, +                    } => { +                        let details = { +                            let title = rich_text![ +                                span(&pull_request.title).size(24).link( +                                    Message::OpenPullRequest(pull_request.id) +                                ), +                                span(format!(" by {}", pull_request.author)) +                                    .font(Font { +                                        style: font::Style::Italic, +                                        ..Font::default() +                                    }), +                            ] +                            .font(Font::MONOSPACE); + +                            let description = markdown::view( +                                description, +                                markdown::Settings::default(), +                                markdown::Style::from_palette( +                                    self.theme().palette(), +                                ), +                            ) +                            .map(Message::UrlClicked); + +                            let labels = +                                row(pull_request.labels.iter().map(|label| { +                                    container( +                                        text(label) +                                            .size(10) +                                            .font(Font::MONOSPACE), +                                    ) +                                    .padding(5) +                                    .style(container::rounded_box) +                                    .into() +                                })) +                                .spacing(10) +                                .wrap(); + +                            column![ +                                title, +                                labels, +                                scrollable(description) +                                    .spacing(10) +                                    .width(Fill) +                                    .height(Fill) +                            ] +                            .spacing(10) +                        }; + +                        let title = text_input( +                            "Type a changelog entry title...", +                            title, +                        ) +                        .on_input(Message::TitleChanged); + +                        let category = pick_list( +                            changelog::Category::ALL, +                            Some(category), +                            Message::CategorySelected, +                        ); + +                        let next = button("Next →") +                            .on_press(Message::Next) +                            .style(button::success); + +                        column![ +                            details, +                            row![title, category, next].spacing(10) +                        ] +                        .spacing(10) +                        .into() +                    } +                }; + +                let preview: Element<_> = if preview.is_empty() { +                    center( +                        container( +                            text("The changelog is empty... so far!").size(12), +                        ) +                        .padding(10) +                        .style(container::rounded_box), +                    ) +                    .into() +                } else { +                    let content = container( +                        scrollable( +                            markdown::view( +                                preview, +                                markdown::Settings::with_text_size(12), +                                markdown::Style::from_palette( +                                    self.theme().palette(), +                                ), +                            ) +                            .map(Message::UrlClicked), +                        ) +                        .spacing(10), +                    ) +                    .width(Fill) +                    .padding(10) +                    .style(container::rounded_box); + +                    let copy = button(icon::copy().size(12)) +                        .on_press(Message::CopyPreview) +                        .style(button::text); + +                    center(stack![content, container(copy).align_right(Fill)]) +                        .into() +                }; + +                let review = column![container(form).height(Fill), progress] +                    .spacing(10) +                    .width(FillPortion(2)); + +                row![review, preview].spacing(10).padding(10).into() +            } +        } +    } + +    fn theme(&self) -> Theme { +        Theme::TokyoNightStorm +    } +}  | 
