Skip to content

Commit 547e509

Browse files
committed
Implement a changelog-generator tool and example
1 parent 8fb939b commit 547e509

File tree

6 files changed

+759
-1
lines changed

6 files changed

+759
-1
lines changed

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
### Added
99
- `fetch_position` command in `window` module. [#2280](https://github.com/iced-rs/iced/pull/2280)
1010

11-
Many thanks to...
11+
### Fixed
12+
- Fix `block_on` in `iced_wgpu` hanging Wasm builds. [#2313](https://github.com/iced-rs/iced/pull/2313)
1213

14+
Many thanks to...
15+
- @hecrj
1316
- @n1ght-hunter
1417

1518
## [0.12.1] - 2024-02-22

examples/changelog/Cargo.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[package]
2+
name = "changelog"
3+
version = "0.1.0"
4+
authors = ["Héctor Ramón Jiménez <[email protected]>"]
5+
edition = "2021"
6+
publish = false
7+
8+
[dependencies]
9+
iced.workspace = true
10+
iced.features = ["tokio", "markdown", "highlighter", "debug"]
11+
12+
log.workspace = true
13+
thiserror.workspace = true
14+
tokio.features = ["fs", "process"]
15+
tokio.workspace = true
16+
17+
serde = "1"
18+
webbrowser = "1"
19+
20+
[dependencies.reqwest]
21+
version = "0.12"
22+
default-features = false
23+
features = ["json", "rustls-tls"]
5.63 KB
Binary file not shown.
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
use serde::Deserialize;
2+
use tokio::fs;
3+
use tokio::process;
4+
5+
use std::env;
6+
use std::fmt;
7+
use std::io;
8+
use std::sync::Arc;
9+
10+
#[derive(Debug, Clone)]
11+
pub struct Changelog {
12+
ids: Vec<u64>,
13+
added: Vec<String>,
14+
changed: Vec<String>,
15+
fixed: Vec<String>,
16+
removed: Vec<String>,
17+
authors: Vec<String>,
18+
}
19+
20+
impl Changelog {
21+
pub fn new() -> Self {
22+
Self {
23+
ids: Vec::new(),
24+
added: Vec::new(),
25+
changed: Vec::new(),
26+
fixed: Vec::new(),
27+
removed: Vec::new(),
28+
authors: Vec::new(),
29+
}
30+
}
31+
32+
pub async fn list() -> Result<(Self, Vec<Candidate>), Error> {
33+
let mut changelog = Self::new();
34+
35+
{
36+
let markdown = fs::read_to_string("CHANGELOG.md").await?;
37+
38+
if let Some(unreleased) = markdown.split("\n## ").nth(1) {
39+
let sections = unreleased.split("\n\n");
40+
41+
for section in sections {
42+
if section.starts_with("Many thanks to...") {
43+
for author in section.lines().skip(1) {
44+
let author = author.trim_start_matches("- @");
45+
46+
if author.is_empty() {
47+
continue;
48+
}
49+
50+
changelog.authors.push(author.to_owned());
51+
}
52+
53+
continue;
54+
}
55+
56+
let Some((_, rest)) = section.split_once("### ") else {
57+
continue;
58+
};
59+
60+
let Some((name, rest)) = rest.split_once("\n") else {
61+
continue;
62+
};
63+
64+
let category = match name {
65+
"Added" => Category::Added,
66+
"Fixed" => Category::Fixed,
67+
"Changed" => Category::Changed,
68+
"Removed" => Category::Removed,
69+
_ => continue,
70+
};
71+
72+
for entry in rest.lines() {
73+
let Some((_, id)) = entry.split_once('#') else {
74+
continue;
75+
};
76+
77+
let Some((id, _)) = id.split_once(']') else {
78+
continue;
79+
};
80+
81+
let Ok(id): Result<u64, _> = id.parse() else {
82+
continue;
83+
};
84+
85+
changelog.ids.push(id);
86+
87+
let target = match category {
88+
Category::Added => &mut changelog.added,
89+
Category::Changed => &mut changelog.added,
90+
Category::Fixed => &mut changelog.fixed,
91+
Category::Removed => &mut changelog.removed,
92+
};
93+
94+
target.push(entry.to_owned());
95+
}
96+
}
97+
}
98+
}
99+
100+
let mut candidates = Candidate::list().await?;
101+
102+
for reviewed_entry in changelog.entries() {
103+
candidates.retain(|candidate| candidate.id != reviewed_entry);
104+
}
105+
106+
Ok((changelog, candidates))
107+
}
108+
109+
pub fn len(&self) -> usize {
110+
self.ids.len()
111+
}
112+
113+
pub fn entries(&self) -> impl Iterator<Item = u64> + '_ {
114+
self.ids.iter().copied()
115+
}
116+
117+
pub fn push(&mut self, entry: Entry) {
118+
self.ids.push(entry.id);
119+
120+
let item = format!(
121+
"- {title}. [#{id}](https://github.com/iced-rs/iced/pull/{id})",
122+
title = entry.title,
123+
id = entry.id
124+
);
125+
126+
let target = match entry.category {
127+
Category::Added => &mut self.added,
128+
Category::Changed => &mut self.added,
129+
Category::Fixed => &mut self.fixed,
130+
Category::Removed => &mut self.removed,
131+
};
132+
133+
target.push(item);
134+
135+
if !self.authors.contains(&entry.author) {
136+
self.authors.push(entry.author);
137+
self.authors.sort_by_key(|author| author.to_lowercase());
138+
}
139+
}
140+
}
141+
142+
impl fmt::Display for Changelog {
143+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144+
fn section(category: Category, entries: &[String]) -> String {
145+
if entries.is_empty() {
146+
return String::new();
147+
}
148+
149+
format!("### {category}\n{list}\n", list = entries.join("\n"))
150+
}
151+
152+
fn thank_you<'a>(authors: impl IntoIterator<Item = &'a str>) -> String {
153+
let mut list = String::new();
154+
155+
for author in authors {
156+
list.push_str(&format!("- @{author}\n"));
157+
}
158+
159+
format!("Many thanks to...\n{list}")
160+
}
161+
162+
let changelog = [
163+
section(Category::Added, &self.added),
164+
section(Category::Changed, &self.changed),
165+
section(Category::Fixed, &self.fixed),
166+
section(Category::Removed, &self.removed),
167+
thank_you(self.authors.iter().map(String::as_str)),
168+
]
169+
.into_iter()
170+
.filter(|section| !section.is_empty())
171+
.collect::<Vec<String>>()
172+
.join("\n");
173+
174+
f.write_str(&changelog)
175+
}
176+
}
177+
178+
#[derive(Debug, Clone)]
179+
pub struct Entry {
180+
pub id: u64,
181+
pub title: String,
182+
pub category: Category,
183+
pub author: String,
184+
}
185+
186+
impl Entry {
187+
pub fn new(
188+
title: &str,
189+
category: Category,
190+
pull_request: &PullRequest,
191+
) -> Option<Self> {
192+
let title = title.strip_suffix(".").unwrap_or(title);
193+
194+
if title.is_empty() {
195+
return None;
196+
};
197+
198+
Some(Self {
199+
id: pull_request.id,
200+
title: title.to_owned(),
201+
category,
202+
author: pull_request.author.clone(),
203+
})
204+
}
205+
}
206+
207+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208+
pub enum Category {
209+
Added,
210+
Changed,
211+
Fixed,
212+
Removed,
213+
}
214+
215+
impl Category {
216+
pub const ALL: &'static [Self] =
217+
&[Self::Added, Self::Changed, Self::Fixed, Self::Removed];
218+
219+
pub fn guess(label: &str) -> Option<Self> {
220+
Some(match label {
221+
"feature" | "addition" => Self::Added,
222+
"change" => Self::Changed,
223+
"bug" | "fix" => Self::Fixed,
224+
_ => None?,
225+
})
226+
}
227+
}
228+
229+
impl fmt::Display for Category {
230+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231+
f.write_str(match self {
232+
Category::Added => "Added",
233+
Category::Changed => "Changed",
234+
Category::Fixed => "Fixed",
235+
Category::Removed => "Removed",
236+
})
237+
}
238+
}
239+
240+
#[derive(Debug, Clone)]
241+
pub struct Candidate {
242+
pub id: u64,
243+
}
244+
245+
#[derive(Debug, Clone)]
246+
pub struct PullRequest {
247+
pub id: u64,
248+
pub title: String,
249+
pub description: String,
250+
pub labels: Vec<String>,
251+
pub author: String,
252+
}
253+
254+
impl Candidate {
255+
pub async fn list() -> Result<Vec<Candidate>, Error> {
256+
let output = process::Command::new("git")
257+
.args([
258+
"log",
259+
"--oneline",
260+
"--grep",
261+
"#[0-9]*",
262+
"origin/latest..HEAD",
263+
])
264+
.output()
265+
.await?;
266+
267+
let log = String::from_utf8_lossy(&output.stdout);
268+
269+
Ok(log
270+
.lines()
271+
.filter(|title| !title.is_empty())
272+
.filter_map(|title| {
273+
let (_, pull_request) = title.split_once("#")?;
274+
let (pull_request, _) = pull_request.split_once([')', ' '])?;
275+
276+
Some(Candidate {
277+
id: pull_request.parse().ok()?,
278+
})
279+
})
280+
.collect())
281+
}
282+
283+
pub async fn fetch(self) -> Result<PullRequest, Error> {
284+
let request = reqwest::Client::new()
285+
.request(
286+
reqwest::Method::GET,
287+
format!(
288+
"https://api.github.com/repos/iced-rs/iced/pulls/{}",
289+
self.id
290+
),
291+
)
292+
.header("User-Agent", "iced changelog generator")
293+
.header(
294+
"Authorization",
295+
format!(
296+
"Bearer {}",
297+
env::var("GITHUB_TOKEN")
298+
.map_err(|_| Error::GitHubTokenNotFound)?
299+
),
300+
);
301+
302+
#[derive(Deserialize)]
303+
struct Schema {
304+
title: String,
305+
body: String,
306+
user: User,
307+
labels: Vec<Label>,
308+
}
309+
310+
#[derive(Deserialize)]
311+
struct User {
312+
login: String,
313+
}
314+
315+
#[derive(Deserialize)]
316+
struct Label {
317+
name: String,
318+
}
319+
320+
let schema: Schema = request.send().await?.json().await?;
321+
322+
Ok(PullRequest {
323+
id: self.id,
324+
title: schema.title,
325+
description: schema.body,
326+
labels: schema.labels.into_iter().map(|label| label.name).collect(),
327+
author: schema.user.login,
328+
})
329+
}
330+
}
331+
332+
#[derive(Debug, Clone, thiserror::Error)]
333+
pub enum Error {
334+
#[error("io operation failed: {0}")]
335+
IOFailed(Arc<io::Error>),
336+
337+
#[error("http request failed: {0}")]
338+
RequestFailed(Arc<reqwest::Error>),
339+
340+
#[error("no GITHUB_TOKEN variable was set")]
341+
GitHubTokenNotFound,
342+
}
343+
344+
impl From<io::Error> for Error {
345+
fn from(error: io::Error) -> Self {
346+
Error::IOFailed(Arc::new(error))
347+
}
348+
}
349+
350+
impl From<reqwest::Error> for Error {
351+
fn from(error: reqwest::Error) -> Self {
352+
Error::RequestFailed(Arc::new(error))
353+
}
354+
}

0 commit comments

Comments
 (0)