Skip to content

Commit 3ce214c

Browse files
committed
Changelog generator (#533)
Generates a changelog based on the pr descriptions. Also fixed a few issues with the markdown generation.
1 parent 3404beb commit 3ce214c

File tree

6 files changed

+242
-107
lines changed

6 files changed

+242
-107
lines changed

generate-release/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ out.md
66
migration-guide.md
77
release-notes.md
88
release-notes-website.md
9+
changelog.md

generate-release/src/changelog.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
use crate::{
2+
github_client::{GithubClient, GithubIssuesResponse},
3+
helpers::{get_merged_prs, get_pr_area},
4+
markdown::write_markdown_section,
5+
};
6+
use std::{collections::BTreeMap, fmt::Write, path::PathBuf};
7+
8+
pub fn generate_changelog(
9+
from: &str,
10+
to: &str,
11+
path: PathBuf,
12+
client: &mut GithubClient,
13+
) -> anyhow::Result<()> {
14+
let mut out = String::new();
15+
16+
let mut areas = BTreeMap::<String, Vec<(String, GithubIssuesResponse)>>::new();
17+
18+
let merged_prs = get_merged_prs(client, from, to, None)?;
19+
for (pr, _, title) in &merged_prs {
20+
let area = get_pr_area(pr);
21+
areas
22+
.entry(area)
23+
.or_insert(Vec::new())
24+
.push((title.clone(), pr.clone()));
25+
}
26+
27+
writeln!(out, "# Changelog")?;
28+
29+
let mut count = 0;
30+
for (area, prs) in areas {
31+
writeln!(out, "## {area}")?;
32+
33+
let mut prs = prs;
34+
prs.sort_by_key(|k| k.1.closed_at);
35+
36+
for (title, pr) in prs {
37+
println!("# {title}");
38+
39+
if let Some(body) = pr.body.as_ref() {
40+
let heading = format!(
41+
"\n### [{}](https://github.com/bevyengine/bevy/pull/{})",
42+
title, pr.number
43+
);
44+
writeln!(&mut out, "{heading}")?;
45+
46+
if write_markdown_section(body, "changelog", &mut out, false)? {
47+
count += 1;
48+
} else {
49+
// Changelog not found so remove heading
50+
// We need to do this because we don't know if there's a changelog when writing the heading
51+
out = out.replace(&heading, "");
52+
}
53+
}
54+
}
55+
}
56+
57+
println!("\nFound {count} PRs with a changelog");
58+
59+
std::fs::write(path, out)?;
60+
61+
Ok(())
62+
}

generate-release/src/github_client.rs

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -119,17 +119,6 @@ impl GithubClient {
119119
.set("Authorization", &format!("bearer {}", self.token))
120120
}
121121

122-
pub fn get_branch_sha(&self, branch_name: &str) -> anyhow::Result<String> {
123-
let request = self.get("branches");
124-
let reponse: Vec<GithubBranchesResponse> = request.call()?.into_json()?;
125-
for branch in &reponse {
126-
if branch.name == branch_name {
127-
return Ok(branch.commit.sha.clone());
128-
}
129-
}
130-
bail!("commit sha not found for main branch")
131-
}
132-
133122
/// Gets the list of all commits between two git ref
134123
pub fn compare_commits(
135124
&self,

generate-release/src/main.rs

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
use changelog::generate_changelog;
12
use clap::{Parser as ClapParser, Subcommand};
23
use migration_guide::generate_migration_guide;
34
use release_notes::generate_release_note;
45
use release_notes_website::generate_release_notes_website;
56
use std::path::PathBuf;
67

8+
mod changelog;
79
mod github_client;
810
mod helpers;
11+
mod markdown;
912
mod migration_guide;
1013
mod release_notes;
1114
mod release_notes_website;
@@ -40,9 +43,13 @@ struct Args {
4043
#[derive(Subcommand)]
4144
enum Commands {
4245
MigrationGuide {
43-
/// Date of the release of the previous version. Format: YYYY-MM-DD
44-
#[arg(short, long)]
45-
date: String,
46+
/// The name of the branch / tag to start from
47+
#[arg(long)]
48+
from: String,
49+
50+
/// The name of the branch / tag to end on
51+
#[arg(long)]
52+
to: String,
4653

4754
/// Title of the frontmatter
4855
#[arg(short, long)]
@@ -82,6 +89,19 @@ enum Commands {
8289
#[arg(short, long)]
8390
path: Option<std::path::PathBuf>,
8491
},
92+
Changelog {
93+
/// The name of the branch / tag to start from
94+
#[arg(short, long)]
95+
from: String,
96+
97+
/// The name of the branch / tag to end on
98+
#[arg(short, long)]
99+
to: String,
100+
101+
/// Path used to output the generated file. Defaults to ./changelog.md
102+
#[arg(short, long)]
103+
path: Option<std::path::PathBuf>,
104+
},
85105
}
86106

87107
fn main() -> anyhow::Result<()> {
@@ -100,14 +120,16 @@ fn main() -> anyhow::Result<()> {
100120

101121
match args.command {
102122
Commands::MigrationGuide {
103-
date,
123+
from,
124+
to,
104125
title,
105126
weight,
106127
path,
107128
} => generate_migration_guide(
108129
&title,
109130
weight,
110-
&date,
131+
&from,
132+
&to,
111133
path.unwrap_or_else(|| PathBuf::from("./migration-guide.md")),
112134
&mut client,
113135
)?,
@@ -123,6 +145,12 @@ fn main() -> anyhow::Result<()> {
123145
path.unwrap_or_else(|| PathBuf::from("./release-notes-website.md")),
124146
&mut client,
125147
)?,
148+
Commands::Changelog { from, to, path } => generate_changelog(
149+
&from,
150+
&to,
151+
path.unwrap_or_else(|| PathBuf::from("./changelog.md")),
152+
&mut client,
153+
)?,
126154
};
127155

128156
Ok(())

generate-release/src/markdown.rs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag};
2+
use std::fmt::Write;
3+
4+
/// Writes the markdown section of the givent section header to the output.
5+
/// The header name needs to be in lower case.
6+
pub fn write_markdown_section(
7+
body: &str,
8+
section_header: &str,
9+
output: &mut String,
10+
write_todo: bool,
11+
) -> anyhow::Result<bool> {
12+
// Parse the body of the PR
13+
let mut options = Options::empty();
14+
options.insert(Options::ENABLE_TABLES);
15+
options.insert(Options::ENABLE_SMART_PUNCTUATION);
16+
let mut markdown = Parser::new_ext(body, options);
17+
let mut section_found = false;
18+
19+
while let Some(event) = markdown.next() {
20+
if section_found {
21+
break;
22+
}
23+
24+
let Event::Start(Tag::Heading(heading_level, _, _)) = event else {
25+
continue;
26+
};
27+
28+
// Find the section header
29+
// Sometimes people will write code in the header
30+
if let Some(Event::Text(heading_text) | Event::Code(heading_text)) = markdown.next() {
31+
if !heading_text.to_lowercase().contains(section_header) {
32+
continue;
33+
}
34+
}
35+
36+
section_found = true;
37+
markdown.next(); // skip heading end event
38+
39+
// Write the section's content
40+
let mut list_item_level = 0;
41+
for event in markdown.by_ref() {
42+
match event {
43+
Event::Start(Tag::Heading(level, _, _)) => {
44+
if level <= heading_level {
45+
// go until next heading
46+
break;
47+
}
48+
}
49+
Event::Start(Tag::List(_)) => list_item_level += 1,
50+
Event::End(Tag::List(_)) => list_item_level -= 1,
51+
Event::End(Tag::Heading(level, _, _)) => {
52+
if level == heading_level {
53+
println!("!!! end of heading !!!");
54+
}
55+
}
56+
Event::Start(Tag::Link(_, _, _)) => {
57+
write!(output, "[")?;
58+
continue;
59+
}
60+
Event::End(Tag::Link(_, ref link, _)) => {
61+
write!(output, "]({link})")?;
62+
continue;
63+
}
64+
_ => {}
65+
}
66+
write_markdown_event(&event, output, list_item_level - 1)?;
67+
}
68+
}
69+
70+
if !section_found {
71+
// Someone didn't write a migration guide 😢
72+
if write_todo {
73+
writeln!(output, "\n<!-- TODO -->")?;
74+
println!("\x1b[93m{section_header} not found!\x1b[0m");
75+
}
76+
Ok(false)
77+
} else {
78+
Ok(true)
79+
}
80+
}
81+
82+
/// Write the markdown Event based on the Tag
83+
/// This handles some edge cases like some code blocks not having a specified lang
84+
/// This also makes sure the result has a more consistent formatting
85+
fn write_markdown_event(
86+
event: &Event,
87+
output: &mut String,
88+
list_item_level: i32,
89+
) -> anyhow::Result<()> {
90+
match event {
91+
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) => writeln!(
92+
output,
93+
"\n```{}",
94+
if lang.is_empty() {
95+
"rust".to_string()
96+
} else {
97+
lang.to_string()
98+
}
99+
)?,
100+
Event::End(Tag::CodeBlock(CodeBlockKind::Fenced(_))) => writeln!(output, "```")?,
101+
Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => writeln!(output, "\n```",)?,
102+
Event::End(Tag::CodeBlock(CodeBlockKind::Indented)) => writeln!(output, "```")?,
103+
Event::Start(Tag::Emphasis) | Event::End(Tag::Emphasis) => write!(output, "_")?,
104+
Event::Start(Tag::Heading(_, _, _)) => {
105+
// A few guides used headings for emphasis,
106+
// since we use headings for the actual header of the guide, we need to use a different way to convey emphasis
107+
write!(output, "\n__")?
108+
}
109+
Event::End(Tag::Heading(_, _, _)) => writeln!(output, "__")?,
110+
// FIXME List currently always assume they are unordered
111+
Event::Start(Tag::List(_)) => writeln!(output)?,
112+
Event::End(Tag::List(_)) => {}
113+
Event::Start(Tag::Item) => {
114+
// Add indentation
115+
for _ in 0..list_item_level {
116+
write!(output, " ")?;
117+
}
118+
write!(output, "- ")?
119+
}
120+
Event::End(Tag::Item) => writeln!(output)?,
121+
Event::Start(Tag::Paragraph) => writeln!(output)?,
122+
Event::End(Tag::Paragraph) => writeln!(output)?,
123+
Event::Text(text) => write!(output, "{text}")?,
124+
Event::Code(text) => write!(output, "`{text}`")?,
125+
Event::SoftBreak => writeln!(output)?,
126+
Event::Start(Tag::BlockQuote) => write!(output, "\n> ")?,
127+
Event::End(Tag::BlockQuote) => writeln!(output)?,
128+
Event::Html(html) => write!(output, "{html}")?,
129+
Event::Rule => writeln!(output, "---")?,
130+
_ => println!("\x1b[93mUnknown event: {event:?}\x1b[0m"),
131+
};
132+
Ok(())
133+
}

0 commit comments

Comments
 (0)