Files
vulp/src/release/mod.rs
T
2026-06-10 12:41:14 +02:00

161 lines
6.9 KiB
Rust

pub mod ui;
use crate::auth::load_accounts;
use crate::cli::{ReleaseAction, ReleaseArgs};
use crate::config::AppConfig;
use crate::providers::make_provider;
use anyhow::Result;
use git2::{Repository, Signature};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct ChangelogEntry {
pub kind: String,
pub scope: Option<String>,
pub message: String,
pub sha: String,
}
fn parse_cc(subject: &str) -> Option<(String, Option<String>, String)> {
let (head, tail) = subject.split_once(':')?;
let msg = tail.trim().to_string();
if let Some((kind, scope_raw)) = head.split_once('(') {
Some((kind.to_string(), Some(scope_raw.trim_end_matches(')').to_string()), msg))
} else {
Some((head.to_string(), None, msg))
}
}
pub fn generate_changelog(repo: &Repository, since_tag: Option<&str>) -> Result<Vec<ChangelogEntry>> {
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
let stop_id = since_tag.and_then(|t| repo.revparse_single(t).ok().map(|o| o.id()));
let mut entries = Vec::new();
for id in revwalk {
let id = id?;
if stop_id.map(|s| s == id).unwrap_or(false) { break; }
let commit = repo.find_commit(id)?;
if let Some((kind, scope, message)) = parse_cc(commit.summary().unwrap_or("")) {
entries.push(ChangelogEntry { kind, scope, message, sha: id.to_string()[..7].to_string() });
}
}
Ok(entries)
}
pub fn format_changelog_md(entries: &[ChangelogEntry], version: &str) -> String {
let mut out = format!("## {}\n\n", version);
for (section, header) in &[("feat","### Features"),("fix","### Bug Fixes"),("refactor","### Refactoring"),("chore","### Chores"),("docs","### Documentation")] {
let group: Vec<_> = entries.iter().filter(|e| e.kind == *section).collect();
if group.is_empty() { continue; }
out.push_str(&format!("{}\n\n", header));
for e in group {
let scope_str = e.scope.as_ref().map(|s| format!("**{}**: ", s)).unwrap_or_default();
out.push_str(&format!("- {}{} ({})\n", scope_str, e.message, e.sha));
}
out.push('\n');
}
out
}
fn latest_tag(repo: &Repository) -> Option<String> {
repo.tag_names(None).ok()?.iter().flatten().map(|s| s.to_string()).last()
}
fn bump_version(tag: Option<&str>, release_type: &str) -> String {
let v = tag.unwrap_or("v0.0.0").trim_start_matches('v');
let p: Vec<u64> = v.split('.').filter_map(|x| x.parse().ok()).collect();
let (mj, mn, pt) = (p.first().copied().unwrap_or(0), p.get(1).copied().unwrap_or(0), p.get(2).copied().unwrap_or(0));
match release_type { "major" => format!("v{}.0.0", mj+1), "minor" => format!("v{}.{}.0", mj, mn+1), _ => format!("v{}.{}.{}", mj, mn, pt+1) }
}
fn create_local_tag(repo: &Repository, version: &str, cfg: &AppConfig) -> Result<()> {
let head = repo.head()?.peel_to_commit()?;
let sig = Signature::now(&cfg.username, &cfg.email)?;
repo.tag(version, head.as_object(), &sig, &format!("Release {}", version), false)?;
Ok(())
}
fn push_tag(repo: &Repository, token: &str, version: &str) -> Result<()> {
let t = token.to_string();
let mut remote = repo.find_remote("origin")?;
let mut cb = git2::RemoteCallbacks::new();
cb.credentials(move |_url, user, allowed| {
if allowed.contains(git2::CredentialType::SSH_KEY) { git2::Cred::ssh_key_from_agent(user.unwrap_or("git")) }
else { git2::Cred::userpass_plaintext(user.unwrap_or("git"), &t) }
});
let mut opts = git2::PushOptions::new();
opts.remote_callbacks(cb);
let refspec = format!("refs/tags/{0}:refs/tags/{0}", version);
remote.push(&[&refspec], Some(&mut opts))?;
Ok(())
}
fn tag_one_repo(path: &Path, version: &str, token: &str, cfg: &AppConfig) -> Result<()> {
let repo = Repository::open(path)?;
create_local_tag(&repo, version, cfg)?;
push_tag(&repo, token, version)?;
Ok(())
}
pub async fn run(args: ReleaseArgs, cfg: &AppConfig) -> Result<()> {
let cwd = std::env::current_dir()?;
let repo = Repository::open(&cwd)?;
let accounts = load_accounts(cfg)?;
let account = crate::providers::default_account(&accounts, None)
.ok_or_else(|| anyhow::anyhow!("no account configured — run: fxg auth add"))?;
let token = crate::auth::get_token(&account.id())?;
match args.action {
None => {
let entries = generate_changelog(&repo, latest_tag(&repo).as_deref())?;
if let Some((version, title, body, draft)) = ui::run_release_tui(&repo, entries)? {
println!(" Publishing {}…", version);
create_local_tag(&repo, &version, cfg)?;
push_tag(&repo, &token, &version)?;
println!(" ✓ Tag pushed");
let provider = make_provider(account)?;
let repo_name = cwd.file_name().unwrap_or_default().to_string_lossy();
match provider.create_release(&repo_name, &version, &title, &body, draft).await {
Ok(url) => println!(" ✓ Release created: {}", url),
Err(e) => println!(" ✗ Release API: {}", e),
}
}
}
Some(ReleaseAction::Create { version, release_type, generate_changelog: gc }) => {
let last = latest_tag(&repo);
let ver = version.unwrap_or_else(|| bump_version(last.as_deref(), &release_type));
let body = if gc { let e = generate_changelog(&repo, last.as_deref())?; format_changelog_md(&e, &ver) } else { String::new() };
create_local_tag(&repo, &ver, cfg)?;
println!("✓ Created tag {}", ver);
if let Err(e) = push_tag(&repo, &token, &ver) { println!(" ⚠ push tag: {}", e); } else { println!("✓ Pushed tag"); }
if !body.is_empty() {
let provider = make_provider(account)?;
let repo_name = cwd.file_name().unwrap_or_default().to_string_lossy();
match provider.create_release(&repo_name, &ver, &ver, &body, false).await {
Ok(url) => println!("✓ Release: {}", url),
Err(e) => println!("⚠ Release API: {}", e),
}
}
}
Some(ReleaseAction::List) => {
let names = repo.tag_names(None)?;
for name in names.iter().flatten() { println!(" {}", name); }
}
Some(ReleaseAction::Tag { version, repos }) => {
let targets: Vec<_> = if repos.is_empty() {
vec![cwd.clone()]
} else {
repos.iter().map(|r| cfg.workspace_root.join(r)).collect()
};
for path in targets {
let name = path.file_name().unwrap_or_default().to_string_lossy();
match tag_one_repo(&path, &version, &token, cfg) {
Ok(()) => println!(" ✓ {} tagged {}", name, version),
Err(e) => println!(" ✗ {}: {}", name, e),
}
}
}
}
Ok(())
}