rlyx 0.3.1

rlyx is a fast release manager that automatically bumps versions, creates changelogs, tags commits, and publishes GitHub releases across JS, Rust, and Python projects with first class monorepos support.
Documentation
use crate::git::gh_pr_title;
use crate::util::run_capture;
use regex::Regex;
use std::fmt::Write;
use std::path::PathBuf;

pub fn build_release_notes(
    range: &str,
    owner_repo: &str,
) -> anyhow::Result<String> {
    let compare_url = {
        let parts: Vec<_> = range.split("..").collect();
        if parts.len() == 2 {
            format!(
                "https://github.com/{}/compare/{}...{}",
                owner_repo, parts[0], parts[1]
            )
        } else {
            format!(
                "https://github.com/{}/compare/{}",
                owner_repo, range
            )
        }
    };

    let raw = run_capture(
        "git",
        &["log", "--no-merges", "--pretty=- %h %s", range],
    )
    .unwrap_or_default();
    let mut commits: Vec<String> = raw
        .lines()
        .filter(|l| !l.contains("docs(changelog):"))
        .map(|s| s.to_string())
        .collect();
    if commits.is_empty() {
        commits.push("(no changes)".to_string());
    }

    let re = Regex::new(r"\(#(\d+)\)").unwrap();
    let mut pr_nums: Vec<String> = vec![];
    for c in &commits {
        for cap in re.captures_iter(c) {
            pr_nums.push(cap[1].to_string());
        }
    }
    pr_nums.sort();
    pr_nums.dedup();

    let mut prs_section: Vec<String> = vec![];
    for n in &pr_nums {
        if let Some(title) = gh_pr_title(owner_repo, n) {
            prs_section.push(format!("- #{}: {}", n, title));
        } else {
            prs_section.push(format!("- #{}", n));
        }
    }

    let authors_raw = run_capture("git", &["shortlog", "-sn", range])
        .unwrap_or_else(|_| "(no new authors)".to_string());
    let authors = if authors_raw.trim().is_empty() {
        "(no new authors)".to_string()
    } else {
        let mut out = String::new();
        for line in authors_raw.lines() {
            let line = line.trim_start();
            let mut sp = line.splitn(2, ' ');
            let count = sp.next().unwrap_or("0");
            let name = sp.next().unwrap_or("").trim();
            let _ =
                writeln!(&mut out, "- {} ({} commits)", name, count);
        }
        out
    };

    let mut s = String::new();
    writeln!(
        &mut s,
        "### Changes since {}",
        range.split("..").next().unwrap_or("")
    )?;
    for c in &commits {
        writeln!(&mut s, "{}", c)?;
    }
    writeln!(&mut s)?;
    if !prs_section.is_empty() {
        writeln!(&mut s, "### Pull requests")?;
        for p in &prs_section {
            writeln!(&mut s, "{}", p)?;
        }
        writeln!(&mut s)?;
    }
    writeln!(&mut s, "### Authors")?;
    write!(&mut s, "{}", authors)?;
    writeln!(&mut s)?;
    writeln!(&mut s, "### Compare")?;
    writeln!(&mut s, "{}", compare_url)?;
    Ok(s)
}

pub fn build_release_notes_scoped(
    range: &str,
    owner_repo: &str,
    paths: &[PathBuf],
) -> anyhow::Result<String> {
    // Git pathspecs are repo-relative
    let repo_root = crate::util::run_capture(
        "git",
        &["rev-parse", "--show-toplevel"],
    )
    .unwrap_or_else(|_| ".".to_string());
    let repo_root_pb = std::path::PathBuf::from(repo_root);

    let compare_url = {
        let parts: Vec<_> = range.split("..").collect();
        if parts.len() == 2 {
            format!(
                "https://github.com/{}/compare/{}...{}",
                owner_repo, parts[0], parts[1]
            )
        } else {
            format!(
                "https://github.com/{}/compare/{}",
                owner_repo, range
            )
        }
    };

    let mut args =
        vec!["log", "--no-merges", "--pretty=- %h %s", range];
    let mut shortlog_args = vec!["shortlog", "-sn", range];
    if !paths.is_empty() {
        args.push("--");
        shortlog_args.push("--");
    }
    let mut args_vec: Vec<String> =
        args.iter().map(|s| s.to_string()).collect();
    let mut shortlog_vec: Vec<String> =
        shortlog_args.iter().map(|s| s.to_string()).collect();
    for p in paths {
        let rel = if p.is_absolute() {
            p.strip_prefix(&repo_root_pb).unwrap_or(p).to_path_buf()
        } else {
            p.to_path_buf()
        };
        let s = rel.to_string_lossy().to_string();
        args_vec.push(s.clone());
        shortlog_vec.push(s);
    }

    let raw = run_capture(
        "git",
        &args_vec.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
    )
    .unwrap_or_default();
    let mut commits: Vec<String> = raw
        .lines()
        .filter(|l| !l.contains("docs(changelog):"))
        .map(|s| s.to_string())
        .collect();
    if commits.is_empty() {
        commits.push("(no changes)".to_string());
    }

    let re = Regex::new(r"\(#(\d+)\)").unwrap();
    let mut pr_nums: Vec<String> = vec![];
    for c in &commits {
        for cap in re.captures_iter(c) {
            pr_nums.push(cap[1].to_string());
        }
    }
    pr_nums.sort();
    pr_nums.dedup();

    let mut prs_section: Vec<String> = vec![];
    for n in &pr_nums {
        if let Some(title) = gh_pr_title(owner_repo, n) {
            prs_section.push(format!("- #{}: {}", n, title));
        } else {
            prs_section.push(format!("- #{}", n));
        }
    }

    let authors_raw = run_capture(
        "git",
        &shortlog_vec.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
    )
    .unwrap_or_else(|_| "(no new authors)".to_string());
    let authors = if authors_raw.trim().is_empty() {
        "(no new authors)".to_string()
    } else {
        let mut out = String::new();
        for line in authors_raw.lines() {
            let line = line.trim_start();
            let mut sp = line.splitn(2, ' ');
            let count = sp.next().unwrap_or("0");
            let name = sp.next().unwrap_or("").trim();
            let _ =
                writeln!(&mut out, "- {} ({} commits)", name, count);
        }
        out
    };

    let mut s = String::new();
    writeln!(
        &mut s,
        "### Changes since {}",
        range.split("..").next().unwrap_or("")
    )?;
    for c in &commits {
        writeln!(&mut s, "{}", c)?;
    }
    writeln!(&mut s)?;
    if !prs_section.is_empty() {
        writeln!(&mut s, "### Pull requests")?;
        for p in &prs_section {
            writeln!(&mut s, "{}", p)?;
        }
        writeln!(&mut s)?;
    }
    writeln!(&mut s, "### Authors")?;
    write!(&mut s, "{}", authors)?;
    writeln!(&mut s)?;
    writeln!(&mut s, "### Compare")?;
    writeln!(&mut s, "{}", compare_url)?;
    Ok(s)
}