Automate changelog gen and gh release in actions (#2286)

* Automate changelog gen and gh release

* fix tests

* fix test 2

* make pull request pull full depth git information

* fix fetch-depth

* fix inspect action
This commit is contained in:
Julius Lungys 2021-12-20 14:53:07 +02:00 committed by GitHub
parent 90b4e55ebc
commit 50510c5858
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 893 additions and 358 deletions

View File

@ -0,0 +1,36 @@
name: Inspect next changelogs
permissions:
contents: write
on:
workflow_dispatch:
jobs:
generate:
name: Generate changelogs
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Install stable toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
profile: minimal
- name: Build changelog generator
run: cargo build --release -p changelog
- name: Read yew changelog in this step
run: ./target/release/changelog yew minor
- name: Read yew-router changelog in this step
run: ./target/release/changelog yew-router minor
- name: Read yew-agent changelog in this step
run: ./target/release/changelog yew-agent minor

View File

@ -6,13 +6,13 @@ on:
workflow_dispatch:
inputs:
level:
description: 'Version Level major|minor|patch'
description: "Version Level major|minor|patch"
required: true
type: choice
options:
- patch
- minor
- major
- patch
- minor
- major
jobs:
publish:
name: Publish yew-agent
@ -21,12 +21,13 @@ jobs:
- name: Checkout sources
uses: actions/checkout@v2
with:
token: '${{ secrets.YEWTEMPBOT_TOKEN }}'
token: "${{ secrets.YEWTEMPBOT_TOKEN }}"
fetch-depth: 0
- name: Config Git
uses: oleksiyrudenko/gha-git-credentials@v2-latest
with:
token: '${{ secrets.YEWTEMPBOT_TOKEN }}'
token: "${{ secrets.YEWTEMPBOT_TOKEN }}"
- name: Install stable toolchain
uses: actions-rs/toolchain@v1
@ -41,12 +42,27 @@ jobs:
crate: cargo-release
version: 0.18.5
- name: Build changelog generator
run: cargo build --release -p changelog
- name: Generate changelog
uses: mathiasvr/command-output@v1
id: changelog
with:
run: ./target/release/changelog yew-agent ${{ github.event.inputs.level }}
- name: Commit changelog
run: |
git add CHANGELOG.md
git commit -m "update CHANGELOG.md for yew-agent release"
git push origin master
- name: Release yew-agent
run: cargo release ${PUBLISH_LEVEL} --token ${CRATES_TOKEN} --execute --no-confirm --package yew-agent
env:
PUBLISH_LEVEL: ${{ github.event.inputs.level }}
CRATES_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
- name: Get tag
id: gettag
uses: WyriHaximus/github-action-get-previous-tag@v1
@ -57,3 +73,10 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
branch: ${{ steps.gettag.outputs.tag }}
- name: Create a Release
uses: softprops/action-gh-release@v1
with:
token: ${{ secrets.YEWTEMPBOT_TOKEN }}
tag_name: ${{ steps.gettag.outputs.tag }}
body: ${{ steps.changelog.outputs.stdout }}

View File

@ -7,13 +7,13 @@ on:
workflow_dispatch:
inputs:
level:
description: 'Version Level major|minor|patch'
description: "Version Level major|minor|patch"
required: true
type: choice
options:
- patch
- minor
- major
- patch
- minor
- major
jobs:
publish:
name: Publish yew
@ -22,12 +22,13 @@ jobs:
- name: Checkout sources
uses: actions/checkout@v2
with:
token: '${{ secrets.YEWTEMPBOT_TOKEN }}'
token: "${{ secrets.YEWTEMPBOT_TOKEN }}"
fetch-depth: 0
- name: Config Git
uses: oleksiyrudenko/gha-git-credentials@v2-latest
with:
token: '${{ secrets.YEWTEMPBOT_TOKEN }}'
token: "${{ secrets.YEWTEMPBOT_TOKEN }}"
- name: Install stable toolchain
uses: actions-rs/toolchain@v1
@ -42,12 +43,27 @@ jobs:
crate: cargo-release
version: 0.18.5
- name: Build changelog generator
run: cargo build --release -p changelog
- name: Generate changelog
uses: mathiasvr/command-output@v1
id: changelog
with:
run: ./target/release/changelog yew ${{ github.event.inputs.level }}
- name: Commit changelog
run: |
git add CHANGELOG.md
git commit -m "update CHANGELOG.md for yew release"
git push origin master
- name: Release yew
run: cargo release ${PUBLISH_LEVEL} --token ${CRATES_TOKEN} --execute --no-confirm --package yew
env:
PUBLISH_LEVEL: ${{ github.event.inputs.level }}
CRATES_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
- name: Get tag
id: gettag
uses: WyriHaximus/github-action-get-previous-tag@v1
@ -58,3 +74,10 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
branch: ${{ steps.gettag.outputs.tag }}
- name: Create a Release
uses: softprops/action-gh-release@v1
with:
token: ${{ secrets.YEWTEMPBOT_TOKEN }}
tag_name: ${{ steps.gettag.outputs.tag }}
body: ${{ steps.changelog.outputs.stdout }}

View File

@ -6,13 +6,13 @@ on:
workflow_dispatch:
inputs:
level:
description: 'Version Level major|minor|patch'
description: "Version Level major|minor|patch"
required: true
type: choice
options:
- patch
- minor
- major
- patch
- minor
- major
jobs:
publish:
name: Publish yew-router
@ -21,12 +21,13 @@ jobs:
- name: Checkout sources
uses: actions/checkout@v2
with:
token: '${{ secrets.YEWTEMPBOT_TOKEN }}'
token: "${{ secrets.YEWTEMPBOT_TOKEN }}"
fetch-depth: 0
- name: Config Git
uses: oleksiyrudenko/gha-git-credentials@v2-latest
with:
token: '${{ secrets.YEWTEMPBOT_TOKEN }}'
token: "${{ secrets.YEWTEMPBOT_TOKEN }}"
- name: Install stable toolchain
uses: actions-rs/toolchain@v1
@ -41,12 +42,27 @@ jobs:
crate: cargo-release
version: 0.18.5
- name: Build changelog generator
run: cargo build --release -p changelog
- name: Generate changelog
uses: mathiasvr/command-output@v1
id: changelog
with:
run: ./target/release/changelog yew-router ${{ github.event.inputs.level }}
- name: Commit changelog
run: |
git add CHANGELOG.md
git commit -m "update CHANGELOG.md for yew-router release"
git push origin master
- name: Release yew-router
run: cargo release ${PUBLISH_LEVEL} --token ${CRATES_TOKEN} --execute --no-confirm --package yew-router
env:
PUBLISH_LEVEL: ${{ github.event.inputs.level }}
CRATES_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
- name: Get tag
id: gettag
uses: WyriHaximus/github-action-get-previous-tag@v1
@ -57,3 +73,10 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
branch: ${{ steps.gettag.outputs.tag }}
- name: Create a Release
uses: softprops/action-gh-release@v1
with:
token: ${{ secrets.YEWTEMPBOT_TOKEN }}
tag_name: ${{ steps.gettag.outputs.tag }}
body: ${{ steps.changelog.outputs.stdout }}

View File

@ -6,13 +6,13 @@ on:
workflow_dispatch:
inputs:
level:
description: 'Version Level major|minor|patch'
description: "Version Level major|minor|patch"
required: true
type: choice
options:
- patch
- minor
- major
- patch
- minor
- major
jobs:
publish:
name: Publish yew-router
@ -21,12 +21,13 @@ jobs:
- name: Checkout sources
uses: actions/checkout@v2
with:
token: '${{ secrets.YEWTEMPBOT_TOKEN }}'
token: "${{ secrets.YEWTEMPBOT_TOKEN }}"
fetch-depth: 0
- name: Config Git
uses: oleksiyrudenko/gha-git-credentials@v2-latest
with:
token: '${{ secrets.YEWTEMPBOT_TOKEN }}'
token: "${{ secrets.YEWTEMPBOT_TOKEN }}"
- name: Install stable toolchain
uses: actions-rs/toolchain@v1
@ -40,7 +41,22 @@ jobs:
with:
crate: cargo-release
version: 0.18.5
- name: Build changelog generator
run: cargo build --release -p changelog
- name: Generate changelog
uses: mathiasvr/command-output@v1
id: changelog
with:
run: ./target/release/changelog yew-router ${{ github.event.inputs.level }}
- name: Commit changelog
run: |
git add CHANGELOG.md
git commit -m "update CHANGELOG.md for yew-router release"
git push origin master
- name: Release yew-router-macro
run: cargo release ${PUBLISH_LEVEL} --token ${CRATES_TOKEN} --execute --no-confirm --package yew-router-macro
env:
@ -52,7 +68,7 @@ jobs:
env:
PUBLISH_LEVEL: ${{ github.event.inputs.level }}
CRATES_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
- name: Get tag
id: gettag
uses: WyriHaximus/github-action-get-previous-tag@v1
@ -63,3 +79,10 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
branch: ${{ steps.gettag.outputs.tag }}
- name: Create a Release
uses: softprops/action-gh-release@v1
with:
token: ${{ secrets.YEWTEMPBOT_TOKEN }}
tag_name: ${{ steps.gettag.outputs.tag }}
body: ${{ steps.changelog.outputs.stdout }}

View File

@ -6,13 +6,13 @@ on:
workflow_dispatch:
inputs:
level:
description: 'Version Level major|minor|patch'
description: "Version Level major|minor|patch"
required: true
type: choice
options:
- patch
- minor
- major
- patch
- minor
- major
jobs:
publish:
name: Publish yew
@ -21,12 +21,13 @@ jobs:
- name: Checkout sources
uses: actions/checkout@v2
with:
token: '${{ secrets.YEWTEMPBOT_TOKEN }}'
token: "${{ secrets.YEWTEMPBOT_TOKEN }}"
fetch-depth: 0
- name: Config Git
uses: oleksiyrudenko/gha-git-credentials@v2-latest
with:
token: '${{ secrets.YEWTEMPBOT_TOKEN }}'
token: "${{ secrets.YEWTEMPBOT_TOKEN }}"
- name: Install stable toolchain
uses: actions-rs/toolchain@v1
@ -40,7 +41,22 @@ jobs:
with:
crate: cargo-release
version: 0.18.5
- name: Build changelog generator
run: cargo build --release -p changelog
- name: Generate changelog
uses: mathiasvr/command-output@v1
id: changelog
with:
run: ./target/release/changelog yew ${{ github.event.inputs.level }}
- name: Commit changelog
run: |
git add CHANGELOG.md
git commit -m "update CHANGELOG.md for yew release"
git push origin master
- name: Release yew-macro
run: cargo release ${PUBLISH_LEVEL} --token ${CRATES_TOKEN} --execute --no-confirm --package yew-macro
env:
@ -52,7 +68,7 @@ jobs:
env:
PUBLISH_LEVEL: ${{ github.event.inputs.level }}
CRATES_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
- name: Get tag
id: gettag
uses: WyriHaximus/github-action-get-previous-tag@v1
@ -63,3 +79,10 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
branch: ${{ steps.gettag.outputs.tag }}
- name: Create a Release
uses: softprops/action-gh-release@v1
with:
token: ${{ secrets.YEWTEMPBOT_TOKEN }}
tag_name: ${{ steps.gettag.outputs.tag }}
body: ${{ steps.changelog.outputs.stdout }}

View File

@ -194,6 +194,9 @@ jobs:
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- uses: actions-rs/toolchain@v1
with:
toolchain: ${{ matrix.toolchain }}

View File

@ -14,3 +14,6 @@ regex = "1"
reqwest = { version = "0.11", features = ["blocking", "json"] }
serde = { version = "1", features = ["derive"] }
structopt = "0.3"
strum = { version = "0.23", features = ["derive"] }
lazy_static = "1.4.0"
semver = "1.0"

101
tools/changelog/src/cli.rs Normal file
View File

@ -0,0 +1,101 @@
use crate::create_log_lines::create_log_lines;
use crate::get_latest_version::get_latest_version;
use crate::new_version_level::NewVersionLevel;
use crate::stdout_tag_description_changelog::stdout_tag_description_changelog;
use crate::write_changelog_file::write_changelog;
use crate::write_log_lines::write_log_lines;
use crate::write_version_changelog::write_changelog_file;
use crate::yew_package::YewPackage;
use anyhow::bail;
use anyhow::Result;
use semver::Version;
use structopt::StructOpt;
#[derive(StructOpt)]
pub struct Cli {
/// package to generate changelog for
pub package: YewPackage,
/// package to generate changelog for
pub new_version_level: NewVersionLevel,
/// From ref. (ex. commit hash or for tags "refs/tags/yew-v0.19.3") overrides version level arg
pub from: Option<String>,
/// To commit. (ex. commit hash or for tags "refs/tags/yew-v0.19.3")
#[structopt(short, long, default_value = "HEAD")]
pub to: String,
/// Path to changelog file
#[structopt(short = "f", long, default_value = "CHANGELOG.md")]
pub changelog_path: String,
/// Skip writing changelog file
#[structopt(short, long)]
pub skip_file_write: bool,
/// Skip getting the next version
#[structopt(short = "b", long)]
pub skip_get_bump_version: bool,
}
impl Cli {
pub fn run(self) -> Result<()> {
let Cli {
package,
from,
to,
changelog_path,
skip_file_write,
new_version_level,
skip_get_bump_version,
} = self;
let package_labels = package.as_labels();
// set up versions and from ref
let (from_ref, next_version) = if skip_get_bump_version {
let from_ref = match from {
Some(some) => some,
None => bail!("from required when skip_get_bump_version is true"),
};
let version = Version::parse("0.0.0")?;
(from_ref, version)
} else {
let latest_version = get_latest_version(&package)?;
let next_version = new_version_level.bump(latest_version.clone());
let from_ref = match from {
Some(some) => some,
None => format!("refs/tags/{}-v{}", package, latest_version),
};
(from_ref, next_version)
};
// walk over each commit find text, user, issue
let log_lines = create_log_lines(from_ref, to, package_labels)?;
// categorize logs
let (fixes, features): (Vec<_>, Vec<_>) = log_lines
.into_iter()
.partition(|log_line| log_line.message.to_lowercase().contains("fix"));
// create displayable log lines
let fixes_logs = write_log_lines(fixes)?;
let features_logs = write_log_lines(features)?;
if !skip_file_write {
// create version changelog
let version_changelog =
write_changelog_file(&fixes_logs, &features_logs, package, next_version)?;
// write changelog
write_changelog(&changelog_path, &version_changelog)?;
}
// stdout changelog meant for tag description
stdout_tag_description_changelog(&fixes_logs, &features_logs)?;
Ok(())
}
}

View File

@ -0,0 +1,95 @@
use anyhow::anyhow;
use anyhow::Context;
use anyhow::Result;
use git2::Error;
use git2::Oid;
use git2::Repository;
use lazy_static::lazy_static;
use regex::Regex;
use std::sync::Mutex;
use crate::github_issue_labels_fetcher::GitHubIssueLabelsFetcher;
use crate::github_user_fetcher::GitHubUsersFetcher;
use crate::log_line::LogLine;
lazy_static! {
static ref REGEX_FOR_ISSUE_ID_CAPTURE: Regex = Regex::new(r"\s*\(#(\d+)\)").unwrap();
static ref GITHUB_USERS_FETCHER: Mutex<GitHubUsersFetcher> = Default::default();
static ref GITHUB_ISSUE_LABELS_FETCHER: Mutex<GitHubIssueLabelsFetcher> = Default::default();
static ref PACKAGE_LABELS: Vec<String> = vec![];
}
pub fn create_log_line(
repo: &Repository,
package_labels: &'static [&'static str],
oid: Result<Oid, Error>,
) -> Result<Option<LogLine>> {
let oid = oid?;
let commit = repo.find_commit(oid)?;
let commit_first_line = commit
.message()
.context("Invalid UTF-8 in commit message")?
.lines()
.next()
.context("Missing commit message")?
.to_string();
let author = commit.author();
let email = author.email().context("Missing author's email")?;
if email.contains("dependabot") || email.contains("github-action") {
return Ok(None);
}
let mb_captures = REGEX_FOR_ISSUE_ID_CAPTURE
.captures_iter(&commit_first_line)
.last();
let captures = match mb_captures {
Some(some) => some,
None => {
eprintln!("Missing issue for commit: {}", oid);
return Ok(None);
}
};
let match_to_be_stripped = captures.get(0).ok_or_else(|| {
anyhow!("Failed to capture first group - issue part of the message like \" (#2263)\"")
})?;
let mut message = commit_first_line.clone();
message.replace_range(match_to_be_stripped.range(), "");
let issue_id = captures
.get(1)
.ok_or_else(|| anyhow!("Failed to capture second group - issue id like \"2263\""))?
.as_str()
.to_string();
let user = GITHUB_USERS_FETCHER
.lock()
.map_err(|err| anyhow!("Failed to lock GITHUB_USERS_FETCHER: {}", err))?
.fetch_user_by_commit_author(email, oid.to_string())
.with_context(|| format!("Could not find GitHub user for commit: {}", oid))?
.to_string();
let issue_labels = GITHUB_ISSUE_LABELS_FETCHER
.lock()
.map_err(|err| anyhow!("Failed to lock GITHUB_ISSUE_LABELS_FETCHER: {}", err))?
.fetch_issue_labels(issue_id.clone())
.with_context(|| format!("Could not find GitHub labels for issue: {}", issue_id))?;
let is_issue_for_this_package = issue_labels
.into_iter()
.any(|label| package_labels.contains(&label.as_str()));
if !is_issue_for_this_package {
return Ok(None);
}
let log_line = LogLine {
message,
user,
issue_id,
};
Ok(Some(log_line))
}

View File

@ -0,0 +1,35 @@
use anyhow::Context;
use anyhow::Result;
use git2::Repository;
use git2::Sort;
use crate::create_log_line::create_log_line;
use crate::log_line::LogLine;
pub fn create_log_lines(
from: String,
to: String,
package_labels: &'static [&'static str],
) -> Result<Vec<LogLine>> {
let repo = Repository::open_from_env()?;
let from_oid = repo
.revparse_single(&from)
.context("Could not find `from` revision")?
.id();
let to_oid = repo
.revparse_single(&to)
.context("Could not find `to` revision")?
.id();
let mut revwalk = repo.revwalk()?;
revwalk.set_sorting(Sort::TOPOLOGICAL)?;
revwalk.hide(from_oid)?;
revwalk.push(to_oid)?;
revwalk
.into_iter()
.filter_map(|oid| create_log_line(&repo, package_labels, oid).transpose())
.collect()
}

View File

@ -0,0 +1,26 @@
use crate::yew_package::YewPackage;
use anyhow::Result;
use git2::Repository;
use semver::Error;
use semver::Version;
pub fn get_latest_version(package: &YewPackage) -> Result<Version> {
let common_tag_pattern = format!("{}-v", package);
let search_pattern = format!("{}*", common_tag_pattern);
let mut tags: Vec<Version> = Repository::open_from_env()?
.tag_names(Some(&search_pattern))?
.iter()
.filter_map(|mb_tag| {
mb_tag.map(|tag| {
let version = tag.replace(&common_tag_pattern, "");
Version::parse(&version)
})
})
.collect::<Result<Vec<Version>, Error>>()?;
tags.sort();
tags.reverse();
Ok(tags[0].clone())
}

View File

@ -0,0 +1,26 @@
use serde::de::DeserializeOwned;
use std::thread;
use std::time::Duration;
use anyhow::{bail, Result};
use reqwest::blocking::Client;
pub fn github_fetch<T: DeserializeOwned>(url: &str) -> Result<T> {
thread::sleep(Duration::from_secs(1));
let request_client = Client::new();
let resp = request_client
.get(url)
.header("user-agent", "reqwest")
.header("accept", "application/vnd.github.v3+json")
.send()?;
let status = resp.status();
if !status.is_success() {
if let Some(remaining) = resp.headers().get("x-ratelimit-remaining") {
if remaining == "0" {
bail!("GitHub API limit reached.");
}
}
bail!("GitHub API request error: {}", status);
}
Ok(resp.json()?)
}

View File

@ -0,0 +1,40 @@
use anyhow::Result;
use serde::Deserialize;
use std::collections::HashMap;
use super::github_fetch::github_fetch;
#[derive(Deserialize, Debug)]
pub struct BodyListItem {
name: String,
}
#[derive(Debug, Default)]
pub struct GitHubIssueLabelsFetcher {
cache: HashMap<String, Option<Vec<String>>>,
}
impl GitHubIssueLabelsFetcher {
pub fn fetch_issue_labels(&mut self, issue: String) -> Option<Vec<String>> {
self.cache
.entry(issue.clone())
.or_insert_with(|| match Self::inner_fetch(&issue) {
Ok(labels) => labels,
Err(err) => {
eprintln!("fetch_issue_labels Error: {}", err);
None
}
})
.clone()
}
fn inner_fetch(q: &str) -> Result<Option<Vec<String>>> {
let url = format!(
"https://api.github.com/repos/yewstack/yew/issues/{}/labels",
q,
);
let body: Vec<BodyListItem> = github_fetch(&url)?;
let label_names: Vec<String> = body.into_iter().map(|label| label.name).collect();
Ok(Some(label_names))
}
}

View File

@ -0,0 +1,48 @@
use anyhow::Result;
use serde::Deserialize;
use std::collections::HashMap;
use super::github_fetch::github_fetch;
#[derive(Deserialize, Debug)]
struct ResponseBody {
author: ResponseBodyAuthor,
}
#[derive(Deserialize, Debug)]
struct ResponseBodyAuthor {
login: String,
}
#[derive(Debug, Default)]
pub struct GitHubUsersFetcher {
cache: HashMap<String, Option<String>>,
}
impl GitHubUsersFetcher {
pub fn fetch_user_by_commit_author(
&mut self,
key: impl Into<String>,
commit: impl AsRef<str>,
) -> Option<&str> {
self.cache
.entry(key.into())
.or_insert_with(|| match Self::inner_fetch(commit) {
Ok(value) => value,
Err(err) => {
eprintln!("fetch_user_by_commit_author Error: {}", err);
None
}
})
.as_deref()
}
fn inner_fetch(commit: impl AsRef<str>) -> Result<Option<String>> {
let url = format!(
"https://api.github.com/repos/yewstack/yew/commits/{}",
commit.as_ref(),
);
let body: ResponseBody = github_fetch(&url)?;
Ok(Some(body.author.login))
}
}

View File

@ -0,0 +1,16 @@
mod cli;
pub mod create_log_line;
pub mod create_log_lines;
pub mod get_latest_version;
pub mod github_fetch;
pub mod github_issue_labels_fetcher;
pub mod github_user_fetcher;
pub mod log_line;
pub mod new_version_level;
pub mod stdout_tag_description_changelog;
pub mod write_changelog_file;
pub mod write_log_lines;
pub mod write_version_changelog;
pub mod yew_package;
pub use cli::Cli;

View File

@ -0,0 +1,5 @@
pub struct LogLine {
pub message: String,
pub user: String,
pub issue_id: String,
}

View File

@ -1,326 +1,7 @@
use anyhow::{anyhow, bail, Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::convert::TryFrom;
use std::convert::TryInto;
use std::fs;
use std::io;
use std::io::Write;
use anyhow::Result;
use changelog::Cli;
use structopt::StructOpt;
fn main() -> Result<()> {
Cli::from_args().run()
}
#[derive(StructOpt)]
pub struct Cli {
/// package to generate changelog for
package: String,
/// From commit.
from: String,
/// To commit.
#[structopt(default_value = "HEAD")]
to: String,
#[structopt(skip = Self::open_repository())]
repo: git2::Repository,
#[structopt(skip)]
github_users: GitHubUsers,
#[structopt(skip)]
github_issue_labels: GitHubIssueLabels,
#[structopt(skip = regex::Regex::new(r"\s*\(#(\d+)\)").unwrap())]
re_issue: regex::Regex,
}
impl Cli {
fn open_repository() -> git2::Repository {
match git2::Repository::open(".") {
Err(err) => {
eprintln!("Error: could not open repository: {}", err);
std::process::exit(1);
}
Ok(repo) => repo,
}
}
fn run(&mut self) -> Result<()> {
let package: Package = self.package.as_str().try_into()?;
let mut old_changelog =
fs::File::open("CHANGELOG.md").context("could not open CHANGELOG.md for reading")?;
let mut f = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open("CHANGELOG.md.new")
.context("could not open CHANGELOG.md.new for writing")?;
let mut revwalk = self.repo.revwalk()?;
revwalk.set_sorting(git2::Sort::TOPOLOGICAL)?;
let from_object = self
.repo
.revparse_single(&self.from)
.context("Could not find `from` revision")?;
let to_object = self
.repo
.revparse_single(&self.to)
.context("Could not find `to` revision")?;
revwalk.hide(from_object.id())?;
revwalk.push(to_object.id())?;
let mut logs = Vec::new();
for oid in revwalk {
let oid = oid?;
let commit = self.repo.find_commit(oid)?;
let first_line = commit
.message()
.context("Invalid UTF-8 in commit message")?
.lines()
.next()
.context("Missing commit message")?;
let author = commit.author();
let email = author.email().context("Missing author's email")?;
if email.contains("dependabot") {
continue;
}
let (issue, first_line) =
if let Some(caps) = self.re_issue.captures_iter(first_line).last() {
let first_line_stripped = vec![
&first_line[..caps.get(0).unwrap().start()],
&first_line[caps.get(0).unwrap().end()..],
]
.join("");
(caps[1].to_string(), first_line_stripped)
} else {
eprintln!("Missing issue for commit: {}", oid);
continue;
};
let user = self
.github_users
.find_user_by_commit_author(email, oid.to_string())
.with_context(|| format!("Could not find GitHub user for commit: {}", oid))?;
let is_issue_for_this_package = self
.github_issue_labels
.is_issue_for_this_package(issue.clone(), package.clone())
.with_context(|| format!("Could not find GitHub issue: {}", issue))?;
if !is_issue_for_this_package {
continue;
}
logs.push((first_line.to_string(), user.to_owned(), issue.to_owned()));
}
let (fixes, features): (Vec<_>, Vec<_>) = logs
.into_iter()
.partition(|(msg, _, _)| msg.to_lowercase().contains("fix"));
writeln!(
f,
"## ✨ {} **x.y.z** *({})*",
self.package,
chrono::Utc::now().format("%Y-%m-%d")
)?;
writeln!(f)?;
writeln!(f, "#### Changelog")?;
writeln!(f)?;
writeln!(f, "- #### 🛠 Fixes")?;
writeln!(f)?;
for (msg, user, issue) in fixes {
writeln!(
f,
" - {msg}. [[@{user}](https://github.com/{user}), [#{issue}](https://github.com/yewstack/yew/pull/{issue})]",
msg = msg,
user = user,
issue = issue
)?;
}
writeln!(f)?;
writeln!(f, "- #### ⚡️ Features")?;
writeln!(f)?;
for (msg, user, issue) in features {
writeln!(
f,
" - {msg}. [[@{user}](https://github.com/{user}), [#{issue}](https://github.com/yewstack/yew/pull/{issue})]",
msg = msg,
user = user,
issue = issue
)?;
}
writeln!(f)?;
io::copy(&mut old_changelog, &mut f)?;
drop(old_changelog);
drop(f);
fs::remove_file("CHANGELOG.md").context("Could not delete CHANGELOG.md")?;
fs::rename("CHANGELOG.md.new", "CHANGELOG.md")
.context("Could not replace CHANGELOG.md with CHANGELOG.md.new")?;
Ok(())
}
}
#[derive(Debug, Default)]
pub struct GitHubUsers {
cache: HashMap<String, Option<String>>,
}
impl GitHubUsers {
pub fn find_user_by_commit_author(
&mut self,
key: impl Into<String>,
commit: impl AsRef<str>,
) -> Option<&str> {
self.cache
.entry(key.into())
.or_insert_with(|| match Self::query_commit(commit) {
Ok(value) => value,
Err(err) => {
eprintln!("Error: {}", err);
None
}
})
.as_deref()
}
fn query_commit(q: impl AsRef<str>) -> Result<Option<String>> {
std::thread::sleep(std::time::Duration::from_secs(1));
let client = reqwest::blocking::Client::new();
let resp = client
.get(format!(
"https://api.github.com/repos/yewstack/yew/commits/{}",
q.as_ref(),
))
.header("user-agent", "reqwest")
.header("accept", "application/vnd.github.v3+json")
.send()?;
let status = resp.status();
if !status.is_success() {
if let Some(remaining) = resp.headers().get("x-ratelimit-remaining") {
if remaining == "0" {
bail!("GitHub API limit reached.");
}
}
bail!("GitHub API request error: {}", status);
}
let body = resp.json::<GitHubCommitApi>()?;
Ok(Some(body.author.login))
}
}
#[derive(Debug, Default)]
pub struct GitHubIssueLabels {
cache: HashMap<String, Option<Vec<String>>>,
}
impl GitHubIssueLabels {
pub fn is_issue_for_this_package(&mut self, issue: String, package: Package) -> Option<bool> {
let labels = self
.cache
.entry(issue.clone())
.or_insert_with(|| match Self::query_issue_labels(&issue) {
Ok(value) => value,
Err(err) => {
eprintln!("Error: {}", err);
None
}
})
.as_deref()?;
let package_labels = package.as_labels();
Some(labels.iter().any(|label| package_labels.contains(label)))
}
fn query_issue_labels(q: &str) -> Result<Option<Vec<String>>> {
std::thread::sleep(std::time::Duration::from_secs(1));
let issue_labels = reqwest::blocking::Client::new();
let resp = issue_labels
.get(format!(
"https://api.github.com/repos/yewstack/yew/issues/{}/labels",
q,
))
.header("user-agent", "reqwest")
.header("accept", "application/vnd.github.v3+json")
.send()?;
let status = resp.status();
if !status.is_success() {
if let Some(remaining) = resp.headers().get("x-ratelimit-remaining") {
if remaining == "0" {
bail!("GitHub API limit reached.");
}
}
bail!("GitHub API request error: {}", status);
}
let body = resp.json::<Vec<GitHubIssueLabelApi>>()?;
let label_names: Vec<String> = body.into_iter().map(|label| label.name).collect();
Ok(Some(label_names))
}
}
#[derive(Deserialize, Debug)]
pub struct GitHubCommitApi {
author: GitHubCommitAuthorApi,
}
#[derive(Deserialize, Debug)]
pub struct GitHubCommitAuthorApi {
login: String,
}
#[derive(Deserialize, Debug)]
pub struct GitHubIssueLabelApi {
name: String,
}
#[derive(Debug, Clone)]
pub enum Package {
Yew,
YewAgent,
YewRouter,
}
impl Package {
fn as_labels(&self) -> Vec<String> {
match self {
Package::Yew => vec![
"A-yew".to_string(),
"A-yew-macro".to_string(),
"macro".to_string(),
],
Package::YewAgent => vec!["A-yew-agent".to_string()],
Package::YewRouter => {
vec!["A-yew-router".to_string(), "A-yew-router-macro".to_string()]
}
}
}
}
impl TryFrom<&str> for Package {
type Error = anyhow::Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value {
"yew" => Ok(Package::Yew),
"yew-agent" => Ok(Package::YewAgent),
"yew-router" => Ok(Package::YewRouter),
_ => Err(anyhow!("{} package is not supported for this cli", value)),
}
}
}

View File

@ -0,0 +1,5 @@
pub mod github_fetch;
pub mod github_issue_labels_fetcher;
pub mod github_user_fetcher;
pub mod log_line;
pub mod yew_package;

View File

@ -0,0 +1,33 @@
use semver::Version;
use strum::Display;
use strum::EnumString;
#[derive(Debug, Clone, EnumString, Display)]
#[strum(serialize_all = "lowercase")]
pub enum NewVersionLevel {
Patch,
Minor,
Major,
}
impl NewVersionLevel {
pub fn bump(&self, current_version: Version) -> Version {
match self {
NewVersionLevel::Patch => Version {
patch: current_version.patch + 1,
..current_version
},
NewVersionLevel::Minor => Version {
minor: current_version.minor + 1,
patch: 0,
..current_version
},
NewVersionLevel::Major => Version {
major: current_version.major + 1,
minor: 0,
patch: 0,
..current_version
},
}
}
}

View File

@ -0,0 +1,32 @@
use anyhow::Result;
use std::io::stdout;
use std::io::Write;
pub fn stdout_tag_description_changelog(fixes_logs: &[u8], features_logs: &[u8]) -> Result<()> {
let mut tag_changelog = Vec::new();
writeln!(tag_changelog, "# Changelog")?;
writeln!(tag_changelog)?;
if fixes_logs.is_empty() && features_logs.is_empty() {
writeln!(tag_changelog, "No changes")?;
writeln!(tag_changelog)?;
}
if !fixes_logs.is_empty() {
writeln!(tag_changelog, "## 🛠 Fixes")?;
writeln!(tag_changelog)?;
tag_changelog.extend(fixes_logs);
writeln!(tag_changelog)?;
}
if !features_logs.is_empty() {
writeln!(tag_changelog, "## ⚡️ Features")?;
writeln!(tag_changelog)?;
tag_changelog.extend(features_logs);
}
stdout().write_all(&tag_changelog)?;
Ok(())
}

View File

@ -0,0 +1,38 @@
use anyhow::Context;
use anyhow::Result;
use std::fs;
use std::fs::File;
use std::io::BufRead;
use std::io::BufReader;
use std::io::Write;
pub fn write_changelog(changelog_path: &str, version_changelog: &[u8]) -> Result<()> {
let old_changelog = File::open(changelog_path)
.context(format!("could not open {} for reading", changelog_path))?;
let old_changelog_reader = BufReader::new(old_changelog);
let changelog_path_new = &format!("{}.new", changelog_path);
let mut new_changelog = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(changelog_path_new)
.context(format!("could not open {} for writing", changelog_path_new))?;
new_changelog.write_all(version_changelog)?;
for old_line in old_changelog_reader.lines().skip(2) {
writeln!(new_changelog, "{}", old_line?)?;
}
drop(new_changelog);
fs::remove_file(changelog_path).context(format!("Could not delete {}", changelog_path))?;
fs::rename(changelog_path_new, changelog_path).context(format!(
"Could not replace {} with {}",
changelog_path, changelog_path_new
))?;
Ok(())
}

View File

@ -0,0 +1,23 @@
use anyhow::Result;
use std::io::Write;
use crate::log_line::LogLine;
pub fn write_log_lines(log_lines: Vec<LogLine>) -> Result<Vec<u8>> {
let mut logs_list = Vec::default();
for LogLine {
message,
user,
issue_id,
} in log_lines
{
writeln!(
logs_list,
"- {message}. [[@{user}](https://github.com/{user}), [#{issue_id}](https://github.com/yewstack/yew/pull/{issue_id})]",
message = message,
user = user,
issue_id = issue_id
)?;
}
Ok(logs_list)
}

View File

@ -0,0 +1,47 @@
use anyhow::Result;
use semver::Version;
use std::io::Write;
use crate::yew_package::YewPackage;
pub fn write_changelog_file(
fixes_logs: &[u8],
features_logs: &[u8],
package: YewPackage,
next_version: Version,
) -> Result<Vec<u8>> {
let mut version_only_changelog = Vec::default();
writeln!(version_only_changelog, "# Changelog")?;
writeln!(version_only_changelog)?;
writeln!(
version_only_changelog,
"## ✨ {package} **{next_version}** *({release_date})* Changelog",
next_version = next_version,
package = package.to_string(),
release_date = chrono::Utc::now().format("%Y-%m-%d")
)?;
writeln!(version_only_changelog)?;
if fixes_logs.is_empty() && features_logs.is_empty() {
writeln!(version_only_changelog, "No changes")?;
writeln!(version_only_changelog)?;
}
if !fixes_logs.is_empty() {
writeln!(version_only_changelog, "### 🛠 Fixes")?;
writeln!(version_only_changelog)?;
version_only_changelog.extend(fixes_logs);
writeln!(version_only_changelog)?;
}
if !features_logs.is_empty() {
writeln!(version_only_changelog, "### ⚡️ Features")?;
writeln!(version_only_changelog)?;
version_only_changelog.extend(features_logs);
writeln!(version_only_changelog)?;
}
Ok(version_only_changelog)
}

View File

@ -0,0 +1,20 @@
use strum::Display;
use strum::EnumString;
#[derive(Debug, Clone, EnumString, Display)]
#[strum(serialize_all = "kebab-case")]
pub enum YewPackage {
Yew,
YewAgent,
YewRouter,
}
impl YewPackage {
pub fn as_labels(&self) -> &'static [&'static str] {
match self {
YewPackage::Yew => &["A-yew", "A-yew-macro", "macro"],
YewPackage::YewAgent => &["A-yew-agent"],
YewPackage::YewRouter => &["A-yew-router", "A-yew-router-macro"],
}
}
}

View File

@ -0,0 +1,66 @@
use std::fs;
use std::fs::File;
use std::io::BufRead;
use std::io::BufReader;
use std::str::FromStr;
use anyhow::Result;
use changelog::new_version_level::NewVersionLevel;
use changelog::yew_package::YewPackage;
use changelog::Cli;
use chrono::Utc;
struct FileDeleteOnDrop;
impl Drop for FileDeleteOnDrop {
fn drop(&mut self) {
fs::remove_file("tests/test_changelog.md").unwrap();
}
}
#[test]
fn generate_yew_changelog_file() -> Result<()> {
// Setup
let file_delete_on_drop = FileDeleteOnDrop;
fs::copy("tests/test_base.md", "tests/test_changelog.md")?;
// Run
let cli_args = Cli {
package: YewPackage::from_str("yew").unwrap(),
new_version_level: NewVersionLevel::Minor,
from: Some("abeb8bc3f1ffabc8a58bd9ba4430cd091a06335a".to_string()),
to: "d8ec50150ed27e2835bb1def26d2371a8c2ab750".to_string(),
changelog_path: "tests/test_changelog.md".to_string(),
skip_file_write: false,
skip_get_bump_version: true,
};
cli_args.run().unwrap();
// Check
let expected = File::open("tests/test_expected.md")?;
let expected_reader_lines = BufReader::new(expected).lines();
let after = File::open("tests/test_changelog.md")?;
let after_reader_lines = BufReader::new(after).lines();
let lines = expected_reader_lines.zip(after_reader_lines);
for (i, (expected_line, after_line)) in lines.enumerate() {
if i == 2 {
// third line has dynamic things that may break the tests
let expected_third_line = expected_line?.replace(
"date_goes_here",
Utc::now().format("%Y-%m-%d").to_string().as_str(),
);
assert_eq!(expected_third_line, after_line?);
} else {
assert_eq!(expected_line?, after_line?);
}
}
drop(file_delete_on_drop);
Ok(())
}

View File

@ -0,0 +1,15 @@
# Changelog for its generation tests
## ✨ yew **0.19.0** *(2021-11-26)*
#### Changelog
- #### 🛠 Fixes
- Attempt to fix recursion on display. [[@mibes](https://github.com/mibes), [#2149](https://github.com/yewstack/yew/pull/2149)]
- Fix default passive option. [[@mc1098](https://github.com/mc1098), [#2111](https://github.com/yewstack/yew/pull/2111)]
- #### ⚡️ Features
- Check event bubbling cancellation at each step of propagation. [[@rjmac](https://github.com/rjmac), [#2191](https://github.com/yewstack/yew/pull/2191)]
- Add possibility to cancel bubbling. [[@voidpumpkin](https://github.com/voidpumpkin), [#2172](https://github.com/yewstack/yew/pull/2172)]

View File

@ -0,0 +1,26 @@
# Changelog
## ✨ yew **0.0.0** *(date_goes_here)* Changelog
### 🛠 Fixes
- Fix defaulted type parameter.. [[@futursolo](https://github.com/futursolo), [#2284](https://github.com/yewstack/yew/pull/2284)]
### ⚡️ Features
- Silence some warnings from derive(Properties). [[@WorldSEnder](https://github.com/WorldSEnder), [#2266](https://github.com/yewstack/yew/pull/2266)]
- Raw field names in property structs. [[@WorldSEnder](https://github.com/WorldSEnder), [#2273](https://github.com/yewstack/yew/pull/2273)]
## ✨ yew **0.19.0** *(2021-11-26)*
#### Changelog
- #### 🛠 Fixes
- Attempt to fix recursion on display. [[@mibes](https://github.com/mibes), [#2149](https://github.com/yewstack/yew/pull/2149)]
- Fix default passive option. [[@mc1098](https://github.com/mc1098), [#2111](https://github.com/yewstack/yew/pull/2111)]
- #### ⚡️ Features
- Check event bubbling cancellation at each step of propagation. [[@rjmac](https://github.com/rjmac), [#2191](https://github.com/yewstack/yew/pull/2191)]
- Add possibility to cancel bubbling. [[@voidpumpkin](https://github.com/voidpumpkin), [#2172](https://github.com/yewstack/yew/pull/2172)]