Skip to content

Commit

Permalink
docs: Add change log scripts (#1495)
Browse files Browse the repository at this point in the history
# 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
calldelegation and Br1ght0ne authored Aug 28, 2024
1 parent 2c8d46e commit b670d32
Show file tree
Hide file tree
Showing 7 changed files with 345 additions and 7 deletions.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
GITHUB_TOKEN=
GITHUB_REPOSITORY_OWNER=
GITHUB_REPOSITORY_NAME=
46 changes: 39 additions & 7 deletions .github/PULL_REQUEST_TEMPLATE.md
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)
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ docs/book/
# Don't add out/ files from test Sway projects.
e2e/sway/**/out/
e2e/sway/**/.gitignore

.env

output_changelog.md
10 changes: 10 additions & 0 deletions scripts/change-log/Cargo.toml
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"
236 changes: 236 additions & 0 deletions scripts/change-log/src/get_full_changelog.rs
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(())
}
27 changes: 27 additions & 0 deletions scripts/change-log/src/get_latest_release.rs
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);
}
}
26 changes: 26 additions & 0 deletions scripts/change-log/src/main.rs
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(())
}

0 comments on commit b670d32

Please sign in to comment.