-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
docs: Add change log scripts (#1495)
# Summary This PR aims to standardize release notes across Fuel Labs, inspired by the automated release notes system used by the TypeScript SDK team. In a conversation with @digorithm, he mentioned that he currently sifts through all the PRs to extract the breaking change notes from each PR included in the release. The scripts have been rewritten in Rust. To use them, simply specify these items in the env example and execute cargo run in your terminal 1. GITHUB_TOKEN 2. GITHUB_REPOSITORY_OWNER 3. GITHUB_REPOSITORY_NAME https://github.com/user-attachments/assets/07e8e495-f897-4a7d-a656-ad7b8404e2e6 Each PR that should be picked up by the script at the end of each release should have titles like: - chore - fix - feat An exclamation mark following the title indicates a breaking change. For example, bugs can be classified under `fix`, and CI bumps under `chore`, but this can be open for discussion. <img width="911" alt="Screenshot 2024-08-22 at 3 47 21 PM" src="https://github.com/user-attachments/assets/b00eb985-e66c-4ae6-8b1c-f0f21f6f563a"> Within the breaking change section, please specify `before` and `after` code blocks, along with a brief note to describe the breaking changes within each PR if applicable. For example, in this PR: [https://github.com/FuelLabs/fuels-rs/pull/1464](https://github.com/FuelLabs/fuels-rs/pull/1464) It might look like this: **Before:** ![Before Screenshot](https://github.com/user-attachments/assets/8b6ec99b-aca7-4ca5-8fee-912509b7eb8c) **After:** ![After Screenshot](https://github.com/user-attachments/assets/808ecc66-6c7b-4c37-ae0d-73b33cff967a) Finally "in this release we..." should be a one liner to describe your PR if you want it to show up in the Summary notes at the new release. # Checklist - [ ] All **changes** are **covered** by **tests** (or not applicable) - [ ] All **changes** are **documented** (or not applicable) - [ ] I **reviewed** the **entire PR** myself (preferably on GitHub UI) - [ ] I **described** all **Breaking Changes** (or confirmed there are none) --------- Co-authored-by: Oleksii Filonenko <12615679+Br1ght0ne@users.noreply.github.com>
- Loading branch information
1 parent
2c8d46e
commit b670d32
Showing
7 changed files
with
345 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
GITHUB_TOKEN= | ||
GITHUB_REPOSITORY_OWNER= | ||
GITHUB_REPOSITORY_NAME= |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,41 @@ | ||
<!-- | ||
List the issues this PR closes (if any) in a bullet list format, e.g.: | ||
- Closes #ABCD | ||
- Closes #EFGH | ||
--> | ||
|
||
### Checklist | ||
# Release notes | ||
|
||
- [ ] I have linked to any relevant issues. | ||
- [ ] I have updated the documentation. | ||
- [ ] I have added tests that prove my fix is effective or that my feature works. | ||
- [ ] I have added necessary labels. | ||
- [ ] I have done my best to ensure that my PR adheres to [the Fuel Labs Code Review Standards](https://github.com/FuelLabs/rfcs/blob/master/text/code-standards/external-contributors.md). | ||
- [ ] I have requested a review from the relevant team or maintainers. | ||
<!-- | ||
Use this only if this PR requires a mention in the Release | ||
Notes Summary. Valuable features and critical fixes are good | ||
examples. For everything else, please delete the whole section. | ||
--> | ||
|
||
In this release, we: | ||
|
||
- Did this and that <!-- edit this text only --> | ||
|
||
# Summary | ||
|
||
<!-- | ||
Please write a summary of your changes and why you made them. | ||
Not all PRs will be complex or substantial enough to require this | ||
section, so you can remove it if you think it's unnecessary. | ||
--> | ||
|
||
# Breaking Changes | ||
|
||
<!-- | ||
If the PR has breaking changes, please detail them in this section | ||
and remove this comment. | ||
Remove this section if there are no breaking changes. | ||
--> | ||
|
||
# Checklist | ||
|
||
- [ ] All **changes** are **covered** by **tests** (or not applicable) | ||
- [ ] All **changes** are **documented** (or not applicable) | ||
- [ ] I **reviewed** the **entire PR** myself (preferably, on GH UI) | ||
- [ ] I **described** all **Breaking Changes** (or there's none) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
[package] | ||
name = "change-log" | ||
version = "0.1.0" | ||
edition = "2021" | ||
|
||
[dependencies] | ||
regex = { workspace = true } | ||
dotenv = "0.15" | ||
tokio = { workspace = true, features = ["full"] } | ||
octocrab = "0.39" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,236 @@ | ||
use octocrab::Octocrab; | ||
use regex::Regex; | ||
use std::collections::HashSet; | ||
use std::fs::File; | ||
use std::io::{self, Write}; | ||
|
||
#[derive(Debug)] | ||
pub struct ChangelogInfo { | ||
pub is_breaking: bool, | ||
pub pr_type: String, | ||
pub bullet_point: String, | ||
pub migration_note: String, | ||
pub release_notes: String, | ||
pub pr_number: u64, | ||
pub pr_title: String, | ||
pub pr_author: String, | ||
pub pr_url: String, | ||
} | ||
|
||
pub fn capitalize(s: &str) -> String { | ||
let mut c = s.chars(); | ||
match c.next() { | ||
None => String::new(), | ||
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(), | ||
} | ||
} | ||
|
||
pub async fn get_changelog_info( | ||
octocrab: &Octocrab, | ||
owner: &str, | ||
repo: &str, | ||
commit_sha: &str, | ||
) -> Result<ChangelogInfo, Box<dyn std::error::Error>> { | ||
let pr_info = octocrab | ||
.repos(owner, repo) | ||
.list_pulls(commit_sha.to_string()) | ||
.send() | ||
.await?; | ||
|
||
if pr_info.items.is_empty() { | ||
return Err("No PR found for this commit SHA".into()); | ||
} | ||
|
||
let pr = &pr_info.items[0]; | ||
|
||
// Skip PRs from the user "fuel-service-user" | ||
if pr.user.as_ref().map_or("", |user| &user.login) == "fuel-service-user" { | ||
return Err("PR from fuel-service-user ignored".into()); | ||
} | ||
|
||
let pr_type = pr | ||
.title | ||
.as_ref() | ||
.map_or("misc", |title| title.split(':').next().unwrap_or("misc")) | ||
.to_string(); | ||
let is_breaking = pr.title.as_ref().map_or(false, |title| title.contains("!")); | ||
|
||
let title_description = pr | ||
.title | ||
.as_ref() | ||
.map_or("", |title| title.split(':').nth(1).unwrap_or("")) | ||
.trim() | ||
.to_string(); | ||
let pr_number = pr.number; | ||
let pr_title = title_description.clone(); | ||
let pr_author = pr.user.as_ref().map_or("", |user| &user.login).to_string(); | ||
let pr_url = pr.html_url.as_ref().map_or("", |url| url.as_str()).to_string(); | ||
|
||
let bullet_point = format!( | ||
"- [#{}]({}) - {}, by @{}", | ||
pr_number, pr_url, pr_title, pr_author | ||
); | ||
|
||
let breaking_changes_regex = Regex::new(r"(?s)# Breaking Changes\s*(.*)").unwrap(); | ||
let breaking_changes = breaking_changes_regex | ||
.captures(&pr.body.as_ref().unwrap_or(&String::new())) | ||
.map_or_else(|| String::new(), |cap| { | ||
cap.get(1).map_or(String::new(), |m| { | ||
m.as_str() | ||
.split("\n# ") | ||
.next() | ||
.unwrap_or("") | ||
.trim() | ||
.to_string() | ||
}) | ||
}); | ||
|
||
let release_notes_regex = Regex::new(r"(?s)In this release, we:\s*(.*)").unwrap(); | ||
let release_notes = release_notes_regex | ||
.captures(&pr.body.as_ref().unwrap_or(&String::new())) | ||
.map_or_else(|| String::new(), |cap| { | ||
cap.get(1).map_or(String::new(), |m| { | ||
m.as_str() | ||
.split("\n# ") | ||
.next() | ||
.unwrap_or("") | ||
.trim() | ||
.to_string() | ||
}) | ||
}); | ||
|
||
let migration_note = format!( | ||
"### [{} - {}]({})\n\n{}", | ||
pr_number, capitalize(&title_description), pr_url, breaking_changes | ||
); | ||
|
||
Ok(ChangelogInfo { | ||
is_breaking, | ||
pr_type, | ||
bullet_point, | ||
migration_note, | ||
release_notes, | ||
pr_number, | ||
pr_title, | ||
pr_author, | ||
pr_url, | ||
}) | ||
} | ||
|
||
pub async fn get_changelogs( | ||
octocrab: &Octocrab, | ||
owner: &str, | ||
repo: &str, | ||
base: &str, | ||
head: &str, | ||
) -> Result<Vec<ChangelogInfo>, Box<dyn std::error::Error>> { | ||
let comparison = octocrab.commits(owner, repo).compare(base, head).send().await?; | ||
|
||
let mut changelogs = Vec::new(); | ||
|
||
for commit in comparison.commits { | ||
match get_changelog_info(&octocrab, owner, repo, &commit.sha).await { | ||
Ok(info) => changelogs.push(info), | ||
Err(e) => { | ||
println!("Error retrieving PR for commit {}: {}", commit.sha, e); | ||
continue; | ||
} | ||
} | ||
} | ||
|
||
changelogs.sort_by(|a, b| a.pr_type.cmp(&b.pr_type)); | ||
|
||
Ok(changelogs) | ||
} | ||
|
||
pub fn generate_changelog(changelogs: Vec<ChangelogInfo>) -> String { | ||
let mut content = String::new(); | ||
|
||
// Categorize PRs by type | ||
let mut features = Vec::new(); | ||
let mut fixes = Vec::new(); | ||
let mut chores = Vec::new(); | ||
let mut breaking_features = Vec::new(); | ||
let mut breaking_fixes = Vec::new(); | ||
let mut breaking_chores = Vec::new(); | ||
let mut migration_notes = Vec::new(); | ||
let mut summary_set: HashSet<String> = HashSet::new(); | ||
|
||
for changelog in &changelogs { | ||
if changelog.is_breaking { | ||
match changelog.pr_type.as_str() { | ||
"feat!" => breaking_features.push(changelog.bullet_point.clone()), | ||
"fix!" => breaking_fixes.push(changelog.bullet_point.clone()), | ||
"chore!" => breaking_chores.push(changelog.bullet_point.clone()), | ||
_ => {} | ||
} | ||
migration_notes.push(changelog.migration_note.clone()); | ||
} else { | ||
match changelog.pr_type.as_str() { | ||
"feat" => features.push(changelog.bullet_point.clone()), | ||
"fix" => fixes.push(changelog.bullet_point.clone()), | ||
"chore" => chores.push(changelog.bullet_point.clone()), | ||
_ => {} | ||
} | ||
} | ||
|
||
if !changelog.release_notes.is_empty() && !summary_set.contains(&changelog.release_notes) { | ||
summary_set.insert(format!("{}", changelog.release_notes.clone())); | ||
} | ||
} | ||
|
||
if !summary_set.is_empty() { | ||
content.push_str("# Summary\n\nIn this release, we:\n"); | ||
let mut summary_lines: Vec<String> = summary_set.into_iter().collect(); | ||
summary_lines.sort(); | ||
for line in summary_lines { | ||
content.push_str(&format!("{}\n", line)); | ||
} | ||
content.push_str("\n"); | ||
} | ||
|
||
// Generate the breaking changes section | ||
if !breaking_features.is_empty() || !breaking_fixes.is_empty() || !breaking_chores.is_empty() { | ||
content.push_str("# Breaking\n\n"); | ||
if !breaking_features.is_empty() { | ||
content.push_str("- Features\n"); | ||
content.push_str(&format!("\t{}\n\n", breaking_features.join("\n\t"))); | ||
} | ||
if !breaking_fixes.is_empty() { | ||
content.push_str("- Fixes\n"); | ||
content.push_str(&format!("\t{}\n\n", breaking_fixes.join("\n\t"))); | ||
} | ||
if !breaking_chores.is_empty() { | ||
content.push_str("- Chores\n"); | ||
content.push_str(&format!("\t{}\n\n", breaking_chores.join("\n\t"))); | ||
} | ||
} | ||
|
||
// Generate the categorized sections for non-breaking changes | ||
if !features.is_empty() { | ||
content.push_str("# Features\n\n"); | ||
content.push_str(&format!("{}\n\n", features.join("\n\n"))); | ||
} | ||
if !fixes.is_empty() { | ||
content.push_str("# Fixes\n\n"); | ||
content.push_str(&format!("{}\n\n", fixes.join("\n\n"))); | ||
} | ||
if !chores.is_empty() { | ||
content.push_str("# Chores\n\n"); | ||
content.push_str(&format!("{}\n\n", chores.join("\n\n"))); | ||
} | ||
|
||
// Generate the migration notes section | ||
if !migration_notes.is_empty() { | ||
content.push_str("# Migration Notes\n\n"); | ||
content.push_str(&format!("{}\n\n", migration_notes.join("\n\n"))); | ||
} | ||
|
||
content.trim().to_string() | ||
} | ||
|
||
pub fn write_changelog_to_file(changelog: &str, file_path: &str) -> io::Result<()> { | ||
let mut file = File::create(file_path)?; | ||
file.write_all(changelog.as_bytes())?; | ||
Ok(()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
use octocrab::Octocrab; | ||
use dotenv::dotenv; | ||
|
||
pub async fn get_latest_release_tag() -> Result<String, Box<dyn std::error::Error>> { | ||
dotenv().ok(); | ||
|
||
let github_token = std::env::var("GITHUB_TOKEN").ok(); | ||
|
||
if let Some(token) = github_token { | ||
let octocrab = Octocrab::builder().personal_token(token).build()?; | ||
|
||
let repo_owner = | ||
std::env::var("GITHUB_REPOSITORY_OWNER").expect("Repository owner not found"); | ||
let repo_name = std::env::var("GITHUB_REPOSITORY_NAME").expect("Repository name not found"); | ||
|
||
let latest_release = octocrab | ||
.repos(&repo_owner, &repo_name) | ||
.releases() | ||
.get_latest() | ||
.await?; | ||
|
||
Ok(latest_release.tag_name) | ||
} else { | ||
eprintln!("Please add GITHUB_TOKEN to the environment"); | ||
std::process::exit(1); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
mod get_full_changelog; | ||
mod get_latest_release; | ||
|
||
use get_full_changelog::{get_changelogs, generate_changelog, write_changelog_to_file}; | ||
use get_latest_release::get_latest_release_tag; | ||
use octocrab::Octocrab; | ||
|
||
#[tokio::main] | ||
async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
dotenv::dotenv().ok(); | ||
let github_token = std::env::var("GITHUB_TOKEN").expect("GITHUB_TOKEN is not set in the environment"); | ||
let repo_owner = std::env::var("GITHUB_REPOSITORY_OWNER").expect("Repository owner not found"); | ||
let repo_name = std::env::var("GITHUB_REPOSITORY_NAME").expect("Repository name not found"); | ||
|
||
let octocrab = Octocrab::builder().personal_token(github_token).build()?; | ||
|
||
let latest_release_tag = get_latest_release_tag().await?; | ||
|
||
let changelogs = get_changelogs(&octocrab, &repo_owner, &repo_name, &latest_release_tag, "master").await?; | ||
|
||
let full_changelog = generate_changelog(changelogs); | ||
|
||
write_changelog_to_file(&full_changelog, "output_changelog.md")?; | ||
|
||
Ok(()) | ||
} |