Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: changelog generator for LTS releases #1602

Merged
merged 14 commits into from
Feb 20, 2025
5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ members = [
"packages/fuels-macros",
"packages/fuels-programs",
"packages/fuels-test-helpers",
"scripts/change-log",
"scripts/check-docs",
"scripts/fuel-core-version",
"scripts/versions-replacer",
"scripts/change-log",
"wasm-tests",
]

Expand All @@ -44,6 +44,7 @@ version = "0.70.0"
[workspace.dependencies]
Inflector = "0.11.4"
anyhow = { version = "1.0", default-features = false }
dialoguer = { version = "0.11", default-features = false }
async-trait = { version = "0.1.74", default-features = false }
bech32 = "0.9.1"
bytes = { version = "1.5.0", default-features = false }
Expand Down Expand Up @@ -83,7 +84,7 @@ trybuild = "1.0.85"
uint = { version = "0.9.5", default-features = false }
which = { version = "6.0.0", default-features = false }
zeroize = "1.7.0"
octocrab = { version = "0.39", default-features = false }
octocrab = { version = "0.43", default-features = false }
dotenv = { version = "0.15", default-features = false }
toml = { version = "0.8", default-features = false }
mockall = { version = "0.13", default-features = false }
Expand Down
6 changes: 4 additions & 2 deletions scripts/change-log/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ repository = { workspace = true }
rust-version = { workspace = true }

[dependencies]
regex = { workspace = true }
dialoguer = { version = "0.11", features = ["fuzzy-select"] }
dotenv = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
octocrab = { workspace = true, features = ["default"] }
regex = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
1 change: 1 addition & 0 deletions scripts/change-log/src/adapters.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod octocrab;
236 changes: 236 additions & 0 deletions scripts/change-log/src/adapters/octocrab.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
use octocrab::{models::pulls::PullRequest, Octocrab};
use regex::Regex;
use serde_json::Value;

use crate::{
domain::{changelog::capitalize, models::ChangelogInfo},
ports::github::GitHubPort,
};

pub struct OctocrabAdapter {
client: Octocrab,
}

impl OctocrabAdapter {
pub fn new(token: &str) -> Self {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()
.unwrap();
Self { client }
}

/// Retrieve the pull request associated with a commit SHA.
async fn get_pr_for_commit(
&self,
owner: &str,
repo: &str,
commit_sha: &str,
) -> Result<PullRequest, Box<dyn std::error::Error>> {
let pr_info = self
.client
.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.into_iter().next().unwrap();

// Ignore PRs from "fuel-service-user"
if pr.user.as_ref().map_or("", |u| &u.login) == "fuel-service-user" {
return Err("PR from fuel-service-user ignored".into());
}

Ok(pr)
}

pub async fn search_branches(
&self,
owner: &str,
repo: &str,
query: &str,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let payload = serde_json::json!({
"query": r#"
query($owner: String!, $repo: String!, $query: String!) {
repository(owner: $owner, name: $repo) {
refs(refPrefix: "refs/heads/", query: $query, first: 100) {
nodes {
name
}
}
}
}
"#,
"variables": {
"owner": owner,
"repo": repo,
"query": query,
}
});

let response: Value = self.client.graphql(&payload).await?;

let nodes = response["data"]["repository"]["refs"]["nodes"]
.as_array()
.ok_or("Could not parse branch nodes from response")?;

let branch_names = nodes
.iter()
.filter_map(|node| node["name"].as_str().map(|s| s.to_owned()))
.collect();

Ok(branch_names)
}

/// Query GitHub for all releases in the repository.
pub async fn get_releases(
&self,
owner: &str,
repo: &str,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let releases = self
.client
.repos(owner, repo)
.releases()
.list()
.per_page(100)
.send()
.await?;

let release_tags = releases
.items
.into_iter()
.map(|release| release.tag_name)
.collect();

Ok(release_tags)
}

/// Build a ChangelogInfo instance from a commit.
async fn build_changelog_info(
&self,
owner: &str,
repo: &str,
commit_sha: &str,
) -> Result<ChangelogInfo, Box<dyn std::error::Error>> {
let pr = self.get_pr_for_commit(owner, repo, commit_sha).await?;

let pr_title_full = pr.title.as_ref().unwrap_or(&"".to_string()).clone();
let pr_type = pr_title_full
.split(':')
.next()
.unwrap_or("misc")
.to_string();
let is_breaking = pr_title_full.contains('!');
let title_description = pr_title_full
.split(':')
.nth(1)
.unwrap_or("")
.trim()
.to_string();
let pr_number = pr.number;
let pr_author = pr.user.as_ref().map_or("", |u| &u.login).to_string();
let pr_url = pr.html_url.map(|u| u.to_string()).unwrap_or_default();

let bullet_point = format!(
"- [#{}]({}) - {}, by @{}",
pr_number, pr_url, title_description, pr_author
);

let breaking_changes_regex = Regex::new(r"(?s)# Breaking Changes\s*(.*)")?;
let breaking_changes = breaking_changes_regex
.captures(pr.body.as_ref().unwrap_or(&String::new()))
.and_then(|cap| cap.get(1))
.map(|m| {
m.as_str()
.split("\n# ")
.next()
.unwrap_or("")
.trim()
.to_string()
})
.unwrap_or_default();

let release_notes_regex = Regex::new(r"(?s)In this release, we:\s*(.*)")?;
let release_notes = release_notes_regex
.captures(pr.body.as_ref().unwrap_or(&String::new()))
.and_then(|cap| cap.get(1))
.map(|m| {
m.as_str()
.split("\n# ")
.next()
.unwrap_or("")
.trim()
.to_string()
})
.unwrap_or_default();

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,
})
}
}

impl GitHubPort for OctocrabAdapter {
async fn get_changelog_infos(
&self,
owner: &str,
repo: &str,
base: &str,
head: &str,
) -> Result<Vec<ChangelogInfo>, Box<dyn std::error::Error>> {
let comparison = self
.client
.commits(owner, repo)
.compare(base, head)
.send()
.await?;

let mut changelogs = Vec::new();

for commit in comparison.commits {
match self.build_changelog_info(owner, repo, &commit.sha).await {
Ok(info) => changelogs.push(info),
Err(e) => {
eprintln!("Error retrieving PR for commit {}: {}", commit.sha, e);
continue;
}
}
}

changelogs.sort_by(|a, b| a.pr_type.cmp(&b.pr_type));

Ok(changelogs)
}

async fn get_latest_release_tag(
&self,
owner: &str,
repo: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let latest_release = self
.client
.repos(owner, repo)
.releases()
.get_latest()
.await?;
Ok(latest_release.tag_name)
}
}
2 changes: 2 additions & 0 deletions scripts/change-log/src/domain.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub mod changelog;
pub mod models;
Loading
Loading