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, pub message: String, pub sha: String, } fn parse_cc(subject: &str) -> Option<(String, Option, 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> { 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 { 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 = 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(()) }