use std::collections::HashMap;
use std::path::{Path};
use async_trait::async_trait;
use git2::Repository;
use gitlab::api::{projects, AsyncQuery};
use gitlab::Gitlab as GitLabClient;
use octocrab::OctocrabBuilder;
use regex::Regex;
use crate::error::ShipItError;
pub(crate) fn next_version(
commits: &HashMap<String, Vec<String>>,
current: &str,
) -> Option<String> {
let re = Regex::new(r"(\d+)\.(\d+)\.(\d+)").unwrap();
let caps = re.captures(current)?;
let major: u64 = caps[1].parse().ok()?;
let minor: u64 = caps[2].parse().ok()?;
let patch: u64 = caps[3].parse().ok()?;
let has = |key: &str| commits.get(key).is_some_and(|v| !v.is_empty());
let (new_minor, new_patch) = if has("features") {
(minor + 1, 0)
} else if has("bug_fixes") || has("infrastructure") || has("docs") || has("misc") {
(minor, patch + 1)
} else {
(minor, patch)
};
Some(format!("v{}.{}.{}", major, new_minor, new_patch))
}
#[cfg_attr(test, mockall::automock)]
#[async_trait]
pub(crate) trait Platform: Send + Sync {
async fn open_request(
&self,
source: &str,
target: &str,
title: &str,
description: &str,
) -> Result<String, ShipItError>;
async fn enrich_messages(&self, messages: &[String]) -> Vec<String>;
}
pub(crate) struct TetheredGit {
pub(crate) path: std::path::PathBuf,
pub(crate) repo: Repository,
pub(crate) remote_name: String,
pub(crate) platform: Box<dyn Platform>,
pub(crate) source: String,
}
impl TetheredGit {
pub(crate) async fn new(path: &Path, remote: &str, source: &str, domain: &str, token: &str, allow_dirty: bool, yes: bool) -> Result<TetheredGit, ShipItError> {
let repo = Repository::open(path)
.map_err(|e| ShipItError::Error(format!("Failed to find a git repo: {}", e)))?;
tracing::debug!("Found a git repository at {:?}", path);
let remote_obj = repo.find_remote(remote)
.map_err(|e| ShipItError::Error(format!("Failed to find a remote in the git repo: {}", e)))?;
tracing::debug!("Found {:?} as a remote", remote_obj.name());
let remote_url = remote_obj.url().map(|u| u.to_string()).unwrap();
drop(remote_obj);
let repo_path = {
let p = if remote_url.starts_with("git@") {
remote_url.split(':').nth(1).unwrap_or("")
} else {
let without_scheme = remote_url.split_once("//").map(|x| x.1).unwrap_or("");
without_scheme.split_once('/').map(|x| x.1).unwrap_or("")
};
p.trim_end_matches(".git").to_string()
};
let sp = crate::output::start_spinner("Connecting to platform...");
let platform_result = GitPlatform::new(&remote_url, domain, token, &repo_path).await;
sp.finish_and_clear();
let platform = platform_result?;
let branch = repo.find_branch(source, git2::BranchType::Local)
.map_err(|e| ShipItError::Error(format!("Branch not found: {}", e)))?;
tracing::debug!("Found {:?} as a branch", branch.name());
drop(branch);
let tethered_git = Self {
path: path.to_path_buf(),
repo,
platform: Box::new(platform),
remote_name: remote.to_string(),
source: source.to_string(),
};
tethered_git.refresh(allow_dirty, yes)?;
Ok(tethered_git)
}
fn fetch(&self) -> Result<(), ShipItError> {
tracing::info!("Fetching {} from {}", self.source, self.remote_name);
let sp = crate::output::start_spinner(format!("Fetching {}...", self.source).as_str());
let output = std::process::Command::new("git")
.args(["fetch", "--tags", &self.remote_name, &self.source])
.current_dir(&self.path)
.output()
.map_err(|e| ShipItError::Error(format!("Failed to run git fetch: {}", e)));
sp.finish_and_clear();
let output = output?;
if !output.status.success() {
return Err(ShipItError::Error(format!(
"git fetch failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
Ok(())
}
fn refresh(&self, allow_dirty: bool, yes: bool) -> Result<(), ShipItError> {
let _ = self.fetch();
if self.is_dirty()? && !allow_dirty && !yes {
return Err(ShipItError::Error("Working directory has uncommitted changes. Unsafe to continue! Clean up your uncommitted changes or add the --allow-dirty flag.".to_string()));
}
if self.needs_push()? {
tracing::warn!("Local source branch is ahead of remote!");
let confirmed = yes || crate::output::prompt_push(&self.source)
.map_err(|e| ShipItError::Error(format!("Failed to read input: {}", e)))?;
if confirmed {
self.push_branch()?;
} else {
return Err(ShipItError::Error("Aborted: Local source branch is ahead of remote!".to_string()));
}
}
if self.needs_pull()? {
tracing::warn!("Remote is ahead of local source branch!");
let confirmed = yes || crate::output::prompt_pull(&self.source)
.map_err(|e| ShipItError::Error(format!("Failed to read input: {}", e)))?;
if confirmed {
self.pull_branch()?;
} else {
return Err(ShipItError::Error("Aborted: pull the latest changes before continuing.".to_string()));
}
}
Ok(())
}
fn push_branch(&self) -> Result<(), ShipItError> {
let sp = crate::output::start_spinner(format!("Pushing {}...", self.source).as_str());
let output = std::process::Command::new("git")
.args(["push", &self.remote_name, &self.source])
.current_dir(&self.path)
.output()
.map_err(|e| ShipItError::Error(format!("Failed to run git push: {}", e)));
sp.finish_and_clear();
let output = output?;
if !output.status.success() {
return Err(ShipItError::Error(format!(
"git push failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
Ok(())
}
fn pull_branch(&self) -> Result<(), ShipItError> {
let sp = crate::output::start_spinner(format!("Pulling {}...", self.source).as_str());
let output = std::process::Command::new("git")
.args(["pull", &self.remote_name, &self.source])
.current_dir(&self.path)
.output()
.map_err(|e| ShipItError::Error(format!("Failed to run git pull: {}", e)));
sp.finish_and_clear();
let output = output?;
if !output.status.success() {
return Err(ShipItError::Error(format!(
"git pull failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
Ok(())
}
pub(crate) fn is_dirty(&self) -> Result<bool, ShipItError> {
let output = std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(&self.path)
.output()
.map_err(|e| ShipItError::Error(format!("Failed to run git status: {}", e)))?;
Ok(!output.stdout.is_empty())
}
fn _get_remote_ref(
&self,
) -> Result<(String, git2::Oid), ShipItError> {
let branch = self.repo.find_branch(&self.source, git2::BranchType::Local)
.map_err(ShipItError::Git)?;
let local_oid = branch.get().target()
.ok_or_else(|| ShipItError::Git(git2::Error::from_str("Failed to get source branch oid")))?;
Ok((format!("refs/remotes/{}/{}", self.remote_name, self.source), local_oid))
}
pub(crate) fn needs_push(
&self,
) -> Result<bool, ShipItError> {
let (remote_ref, local_oid) = self._get_remote_ref()?;
match self.repo.find_reference(&remote_ref) {
Ok(remote_ref) => match remote_ref.target() {
Some(remote_oid) => {
let (ahead, _) = self.repo.graph_ahead_behind(local_oid, remote_oid)
.map_err(ShipItError::Git)?;
Ok(ahead > 0)
}
None => Ok(true),
},
Err(_) => Ok(true),
}
}
pub(crate) fn needs_pull(
&self,
) -> Result<bool, ShipItError> {
let (remote_ref, local_oid) = self._get_remote_ref()?;
match self.repo.find_reference(&remote_ref) {
Ok(remote_ref) => match remote_ref.target() {
Some(remote_oid) => {
let (_, behind) = self.repo.graph_ahead_behind(local_oid, remote_oid)
.map_err(ShipItError::Git)?;
Ok(behind > 0)
}
None => Ok(false),
},
Err(_) => Ok(false),
}
}
pub(crate) fn collect_commits(&self, target: &str, only_merges: &bool) -> Result<Vec<git2::Oid>, ShipItError> {
let target = self.repo.find_branch(target, git2::BranchType::Local).map_err(ShipItError::Git)?;
let target_oid = target
.get()
.target()
.ok_or_else(|| ShipItError::Git(git2::Error::from_str("Failed to find a valid commit for the target branch!")))?;
let target_oid_on_source = self.repo.find_commit(target_oid).unwrap();
let mut revwalk = self.repo.revwalk().map_err(ShipItError::Git)?;
let full_ref = format!("refs/heads/{}", self.source);
revwalk.push_ref(&full_ref).map_err(ShipItError::Git)?;
let target_oid_hash = target_oid_on_source.id();
revwalk.hide(target_oid_hash).map_err(ShipItError::Git)?;
let mut commits = Vec::new();
for oid in revwalk {
commits.push(oid.map_err(ShipItError::Git)?);
}
let commits: Vec<_> = if *only_merges {
commits.into_iter().filter(|oid| {
self.repo.find_commit(*oid)
.map(|c| c.parent_count() > 1)
.unwrap_or(false)
}).collect()
} else {
commits
};
Ok(commits)
}
pub(crate) fn collect_commits_since_tag(
&self,
tag_name: &str,
only_merges: bool,
) -> Result<Vec<git2::Oid>, ShipItError> {
let tag_ref = format!("refs/tags/{}", tag_name);
let tag_reference = self.repo.find_reference(&tag_ref).map_err(ShipItError::Git)?;
let tag_commit = tag_reference.peel_to_commit().map_err(ShipItError::Git)?;
let tag_oid = tag_commit.id();
let mut revwalk = self.repo.revwalk().map_err(ShipItError::Git)?;
let branch_ref = format!("refs/heads/{}", self.source);
revwalk.push_ref(&branch_ref).map_err(ShipItError::Git)?;
revwalk.hide(tag_oid).map_err(ShipItError::Git)?;
let mut commits = Vec::new();
for oid in revwalk {
commits.push(oid.map_err(ShipItError::Git)?);
}
let commits = if only_merges {
commits
.into_iter()
.filter(|oid| {
self.repo.find_commit(*oid)
.map(|c| c.parent_count() > 1)
.unwrap_or(false)
})
.collect()
} else {
commits
};
Ok(commits)
}
pub(crate) fn collect_messages(&self, target: &str, only_merges: &bool) -> Result<Vec<String>, ShipItError> {
let commits = self.collect_commits(target, only_merges)?;
let mut messages = Vec::new();
for commit in commits {
let release_oid = self.repo.find_commit(commit).unwrap();
let msg = release_oid
.message()
.ok_or_else(|| ShipItError::Git(git2::Error::from_str("Failed to unwrap the message of a release commit!")))?
.to_string();
messages.push(format!("{} {}", msg, release_oid.id()));
}
Ok(messages)
}
pub(crate) fn collect_messages_since_tag(
&self,
tag_name: &str,
only_merges: bool,
) -> Result<Vec<String>, ShipItError> {
let commits = self.collect_commits_since_tag(tag_name, only_merges)?;
let mut messages = Vec::new();
for oid in commits {
let commit = self.repo.find_commit(oid).unwrap();
let msg = commit
.message()
.ok_or_else(|| ShipItError::Git(git2::Error::from_str("Failed to unwrap commit message")))?
.to_string();
messages.push(format!("{} {}", msg, commit.id()));
}
Ok(messages)
}
pub(crate) fn push_tag(&self, tag_name: &str) -> Result<(), ShipItError> {
tracing::info!("Pushing tag {} to {}", tag_name, self.remote_name);
let refspec = format!("refs/tags/{}", tag_name);
let sp = crate::output::start_spinner(format!("Pushing tag {}...", tag_name).as_str());
let output = std::process::Command::new("git")
.args(["push", &self.remote_name, &refspec])
.current_dir(&self.path)
.output()
.map_err(|e| ShipItError::Error(format!("Failed to run git push: {}", e)));
sp.finish_and_clear();
let output = output?;
if !output.status.success() {
return Err(ShipItError::Error(format!(
"git push failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
Ok(())
}
pub(crate) fn create_local_tag(
&self,
tag_name: &str,
branch_oid: git2::Oid,
notes: &str,
) -> Result<(), ShipItError> {
let obj = self.repo
.find_object(branch_oid, Some(git2::ObjectType::Commit))
.map_err(ShipItError::Git)?;
let sig = self.repo.signature()
.map_err(ShipItError::Git)?;
self.repo.tag(tag_name, &obj, &sig, notes, false)
.map_err(ShipItError::Git)?;
Ok(())
}
pub(crate) fn get_latest_tag(&self) -> Result<Option<String>, ShipItError> {
let mut most_recent: Option<(i64, String)> = None;
let branch = self.repo.revparse_single(&self.source).map_err(ShipItError::Git)?;
let refs = self.repo.references().map_err(ShipItError::Git)?;
for reference in refs {
let reference = reference.map_err(ShipItError::Git)?;
if !reference.is_tag() {
continue;
}
let tag_commit = match reference.peel_to_commit() {
Ok(c) => c,
Err(_) => continue,
};
let tag_oid = tag_commit.id();
let merge_base = match self.repo.merge_base(branch.id(), tag_oid) {
Ok(oid) => oid,
Err(_) => continue,
};
if merge_base != tag_oid {
continue;
}
let name = match reference.shorthand() {
Some(n) => n.to_string(),
None => continue,
};
let seconds = tag_commit.time().seconds();
match most_recent {
None => most_recent = Some((seconds, name)),
Some((most_recent_seconds, _)) if seconds > most_recent_seconds => {
most_recent = Some((seconds, name));
}
_ => {}
}
}
Ok(most_recent.map(|(_, name)| name))
}
}
pub(crate) fn categorize_commits(commits: &[&str]) -> HashMap<String, Vec<String>> {
let mut map: HashMap<String, Vec<String>> = ["features", "bug_fixes", "infrastructure", "docs", "misc"]
.iter()
.map(|&k| (k.to_string(), Vec::new()))
.collect();
for &commit in commits {
let category = find_commit_category(commit);
map.entry(category.to_string()).or_default().push(commit.to_string());
}
map
}
fn find_commit_category(text: &str) -> &'static str {
for token in text.split_whitespace() {
let token = token.trim_start_matches('[');
let commit_type = token.split(['(', ':']).next().unwrap_or("").trim().to_lowercase();
match commit_type.as_str() {
"feat" => return "features",
"fix" | "bug" => return "bug_fixes",
"ci" | "infra" | "build" | "chore" | "perf" | "refactor" | "style" | "test" => {
return "infrastructure"
}
"docs" => return "docs",
_ => {}
}
}
"misc"
}
pub(crate) fn generate_summary(commits: &HashMap<String, Vec<String>>) -> String {
let mut keys: Vec<&String> = commits.keys().collect();
keys.sort();
let mut sections: Vec<String> = Vec::new();
for key in keys {
let entries = &commits[key];
if entries.is_empty() {
continue;
}
let heading = key
.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ");
let mut section = format!("## {}\n", heading);
for commit in entries {
section.push_str(&format!("- {}\n", commit));
}
sections.push(section);
}
sections.join("\n")
}
pub(crate) enum GitPlatform {
GitHub(GitHub),
GitLab(GitLab),
}
impl GitPlatform {
pub(crate) async fn new(remote_url: &str, domain: &str, token: &str, path: &str) -> Result<Self, ShipItError> {
if remote_url.contains("github") {
Ok(GitPlatform::GitHub(GitHub::new(domain, token, path).await))
} else if remote_url.contains("gitlab") {
Ok(GitPlatform::GitLab(GitLab::new(domain, token, path).await))
} else {
Err(ShipItError::Error("Unable to detect a supported git platform!".to_string()))
}
}
pub(crate) async fn open_request(&self, source: &str, target: &str, title: &str, description: &str) -> Result<String, ShipItError> {
match self {
GitPlatform::GitHub(gh) => gh.open_request(source, target, title, description).await,
GitPlatform::GitLab(gl) => gl.open_request(source, target, title, description).await,
}
}
pub(crate) async fn get_request_info(&self, id: u64) -> Result<(String, String), ShipItError> {
match self {
GitPlatform::GitHub(gh) => gh.get_request_info(id).await,
GitPlatform::GitLab(gl) => gl.get_request_info(id).await,
}
}
pub(crate) fn parse_request_id(&self, message: &str) -> Option<u64> {
match self {
GitPlatform::GitHub(_) => GitHub::parse_request_id(message),
GitPlatform::GitLab(_) => GitLab::parse_request_id(message),
}
}
pub(crate) async fn enrich_messages(&self, messages: &[String]) -> Vec<String> {
let mut enriched = Vec::with_capacity(messages.len());
for msg in messages {
let replacement = 'enrich: {
if let Some(id) = self.parse_request_id(msg)
&& let Ok((title, link)) = self.get_request_info(id).await {
break 'enrich format!("{} - [#{}]({})", title, id, link);
}
msg.to_string()
};
enriched.push(replacement);
}
enriched
}
}
pub(crate) struct GitHub {
pub domain: String,
pub token: String,
pub project_id: u64,
}
impl GitHub {
async fn new(domain: &str, token: &str, path: &str) -> Self {
let mut platform = Self {
domain: domain.to_string(),
token: token.to_string(),
project_id: 0
};
platform.project_id = platform.parse_project_id(path).await.expect("Project existence already verified!");
platform
}
async fn open_request(&self, source: &str, target: &str, title: &str, description: &str) -> Result<String, ShipItError> {
let mut builder = OctocrabBuilder::new().personal_token(self.token.clone());
if self.domain != "github.com" {
let base_uri = format!("https://{}/api/v3/", self.domain);
builder = builder.base_uri(base_uri)
.map_err(|e| ShipItError::Error(format!("Invalid GitHub domain: {}", e)))?;
}
let octo = builder.build().map_err(|e| ShipItError::GitHub(Box::new(e)))?;
let repo = octo.repos_by_id(self.project_id).get().await.map_err(|e| ShipItError::GitHub(Box::new(e)))?;
let owner = repo.owner.ok_or("No owner found").map_err(|e| ShipItError::Error(e.to_string()))?.login;
let pr = octo
.pulls(&owner, &repo.name)
.create(title, source, target)
.body(description)
.send()
.await
.map_err(|e| ShipItError::GitHub(Box::new(e)))?;
let url = pr.html_url
.ok_or_else(|| ShipItError::Error("Failed to get pr url from GitHub response".to_string()))?;
Ok(url.to_string())
}
async fn get_request_info(&self, id: u64) -> Result<(String, String), ShipItError> {
let mut builder = OctocrabBuilder::new().personal_token(self.token.to_string());
if self.domain != "github.com" {
let base_uri = format!("https://{}/api/v3/", self.domain);
builder = builder
.base_uri(base_uri)
.map_err(|e| ShipItError::Error(format!("Invalid GitHub domain: {}", e)))?;
}
let octo = builder.build().map_err(|e| ShipItError::GitHub(Box::new(e)))?;
let repo = octo.repos_by_id(self.project_id).get().await.map_err(|e| ShipItError::GitHub(Box::new(e)))?;
let owner = repo.owner.ok_or("No owner found").map_err(|e| ShipItError::Error(e.to_string()))?.login;
let pr = octo
.pulls(&owner, &repo.name)
.get(id)
.await
.map_err(|e| ShipItError::GitHub(Box::new(e)))?;
let title = pr
.title
.ok_or_else(|| ShipItError::Error("PR missing title".to_string()))?;
let url = pr
.html_url
.ok_or_else(|| ShipItError::Error("PR missing url".to_string()))?;
Ok((title, url.to_string()))
}
fn parse_request_id(message: &str) -> Option<u64> {
if let Some(idx) = message.find("pull request #") {
let rest = &message[idx + "pull request #".len()..];
let num_str: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if !num_str.is_empty() {
return num_str.parse().ok();
}
}
if let Some(idx) = message.find("(#") {
let rest = &message[idx + 2..];
let num_str: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if !num_str.is_empty()
&& rest.chars().nth(num_str.len()) == Some(')') {
return num_str.parse().ok();
}
}
None
}
async fn parse_project_id(&self, path: &str) -> Result<u64, ShipItError> {
let octo = OctocrabBuilder::new().personal_token(self.token.clone()).build().map_err(|e| ShipItError::GitHub(Box::new(e)))?;
let parts: Vec<&str> = path.split('/').collect();
if parts.len() != 2 {
return Err(ShipItError::Error("Path must be in 'owner/repo' format".to_string()));
}
let owner = parts[0];
let repo_name = parts[1];
let repo = octo.repos(owner, repo_name).get().await.map_err(|e| ShipItError::GitHub(Box::new(e)))?;
Ok(repo.id.0)
}
}
pub(crate) struct GitLab {
pub domain: String,
pub token: String,
pub project_id: u64,
}
impl GitLab {
async fn new(domain: &str, token: &str, path: &str) -> Self {
let mut platform = Self {
domain: domain.to_string(),
token: token.to_string(),
project_id: 0
};
platform.project_id = platform.parse_project_id(path).await.expect("Project existence already verified!");
platform
}
async fn open_request(&self, source: &str, target: &str, title: &str, description: &str) -> Result<String, ShipItError> {
let client = GitLabClient::builder(&self.domain, &self.token)
.build_async()
.await
.map_err(|e| ShipItError::Gitlab(Box::new(e)))?;
let create_mr = projects::merge_requests::CreateMergeRequest::builder()
.project(self.project_id)
.source_branch(source)
.target_branch(target)
.title(title)
.description(description)
.remove_source_branch(true)
.build()
.map_err(|e| ShipItError::Error(format!("Failed to build a GitLab mr: {}", e)))?;
let merge_request: serde_json::Value = create_mr
.query_async(&client)
.await
.map_err(|e| ShipItError::Error(format!("Failed to create a GitLab merge request: {}", e)))?;
merge_request["web_url"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| ShipItError::Error("Failed to get mr url from GitLab response".to_string()))
}
async fn get_request_info(&self, id: u64) -> Result<(String, String), ShipItError> {
use gitlab::api::projects::merge_requests::MergeRequest;
let client = GitLabClient::builder(&self.domain, &self.token)
.build_async()
.await
.map_err(|e| ShipItError::Gitlab(Box::new(e)))?;
let endpoint = MergeRequest::builder()
.project(self.project_id)
.merge_request(id)
.build()
.map_err(|_| ShipItError::Error("Failed to build GitLab MR query".to_string()))?;
let mr: serde_json::Value = endpoint
.query_async(&client)
.await
.map_err(|e| ShipItError::Error(format!("Failed to fetch GitLab MR: {}", e)))?;
let title = mr["title"]
.as_str()
.ok_or_else(|| ShipItError::Error("GitLab MR missing title".to_string()))?
.to_string();
let url = mr["web_url"]
.as_str()
.ok_or_else(|| ShipItError::Error("GitLab MR missing url".to_string()))?
.to_string();
Ok((title, url))
}
fn parse_request_id(message: &str) -> Option<u64> {
if let Some(idx) = message.find("See merge request ") {
let rest = &message[idx + "See merge request ".len()..];
if let Some(bang_idx) = rest.find('!') {
let after_bang = &rest[bang_idx + 1..];
let num_str: String = after_bang
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if !num_str.is_empty() {
return num_str.parse().ok();
}
}
}
None
}
async fn parse_project_id(
&self,
path: &str,
) -> Result<u64, ShipItError> {
let client = GitLabClient::builder(&self.domain, &self.token)
.build_async()
.await
.map_err(|e| ShipItError::Gitlab(Box::new(e)))?;
let endpoint = projects::Project::builder()
.project(path.to_string())
.build()
.map_err(|_| ShipItError::Error("Failed to build GitLab project query".to_string()))?;
let project: serde_json::Value = endpoint
.query_async(&client)
.await
.map_err(|e| ShipItError::Error(format!("Failed to look up GitLab project '{}': {}", path, e)))?;
project["id"]
.as_u64()
.ok_or_else(|| ShipItError::Error("GitLab project response missing 'id' field".to_string()))
}
}
#[async_trait]
impl Platform for GitPlatform {
async fn open_request(
&self,
source: &str,
target: &str,
title: &str,
description: &str,
) -> Result<String, ShipItError> {
GitPlatform::open_request(self, source, target, title, description).await
}
async fn enrich_messages(&self, messages: &[String]) -> Vec<String> {
GitPlatform::enrich_messages(self, messages).await
}
}
#[cfg(test)]
pub(crate) mod test_helpers {
use std::path::Path;
use git2::{Repository, Signature, Time};
pub(crate) fn init_repo_with_remote(work_path: &Path, bare_path: &Path) -> Repository {
Repository::init_bare(bare_path).unwrap();
let repo = Repository::init(work_path).unwrap();
repo.set_head("refs/heads/master").unwrap();
repo.remote("origin", bare_path.to_str().unwrap()).unwrap();
let mut config = repo.config().unwrap();
config.set_str("user.name", "test").unwrap();
config.set_str("user.email", "test@test.com").unwrap();
repo
}
pub(crate) fn make_commit(repo: &Repository, message: &str) -> git2::Oid {
let sig = Signature::new("test", "test@test.com", &Time::new(1_000_000, 0)).unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let parents: Vec<git2::Commit> = repo
.head()
.ok()
.and_then(|h| h.target())
.and_then(|oid| repo.find_commit(oid).ok())
.into_iter()
.collect();
let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parent_refs).unwrap()
}
pub(crate) fn make_tag(repo: &Repository, tag_name: &str, oid: git2::Oid) {
let sig = Signature::new("test", "test@test.com", &Time::new(1_000_000, 0)).unwrap();
let obj = repo.find_object(oid, Some(git2::ObjectType::Commit)).unwrap();
repo.tag(tag_name, &obj, &sig, "release", false).unwrap();
}
pub(crate) fn make_tethered_git(
repo: Repository,
path: std::path::PathBuf,
source: &str,
platform: Box<dyn crate::git::Platform>,
) -> crate::git::TetheredGit {
crate::git::TetheredGit {
path,
repo,
remote_name: "origin".to_string(),
platform,
source: source.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_categorize_all_keys_always_present() {
let map = categorize_commits(&[]);
for key in &["features", "bug_fixes", "infrastructure", "docs", "misc"] {
assert!(map.contains_key(*key), "missing key: {key}");
}
}
#[test]
fn test_categorize_feat_prefix() {
let map = categorize_commits(&["feat: add login"]);
assert_eq!(map["features"], vec!["feat: add login"]);
assert!(map["misc"].is_empty());
}
#[test]
fn test_categorize_fix_and_bug_prefix() {
let map = categorize_commits(&["fix: crash on start", "bug: off-by-one"]);
assert_eq!(map["bug_fixes"].len(), 2);
}
#[test]
fn test_categorize_infrastructure_prefixes() {
let commits = ["ci: update pipeline", "chore: bump deps", "build: makefile", "perf: cache"];
let map = categorize_commits(&commits);
assert_eq!(map["infrastructure"].len(), 4);
}
#[test]
fn test_categorize_docs_prefix() {
let map = categorize_commits(&["docs: update readme"]);
assert_eq!(map["docs"], vec!["docs: update readme"]);
}
#[test]
fn test_categorize_unknown_prefix_goes_to_misc() {
let map = categorize_commits(&["random commit message", "wip: something"]);
assert_eq!(map["misc"].len(), 2);
assert!(map["features"].is_empty());
}
#[test]
fn test_generate_summary_formats_sections() {
let map = categorize_commits(&["feat: login", "fix: crash"]);
let out = generate_summary(&map);
assert!(out.contains("## Bug Fixes"), "missing Bug Fixes heading");
assert!(out.contains("## Features"), "missing Features heading");
assert!(out.contains("- feat: login"));
assert!(out.contains("- fix: crash"));
}
#[test]
fn test_generate_summary_omits_empty_categories() {
let map = categorize_commits(&["feat: only features"]);
let out = generate_summary(&map);
assert!(!out.contains("## Bug Fixes"));
assert!(!out.contains("## Misc"));
}
#[test]
fn test_generate_summary_empty_input_is_empty_string() {
let map = categorize_commits(&[]);
assert_eq!(generate_summary(&map), "");
}
#[test]
fn test_next_version_feature_bumps_minor_resets_patch() {
let map = categorize_commits(&["feat: new thing"]);
assert_eq!(next_version(&map, "v1.2.3"), Some("v1.3.0".to_string()));
}
#[test]
fn test_next_version_bug_fix_bumps_patch() {
let map = categorize_commits(&["fix: crash"]);
assert_eq!(next_version(&map, "v1.2.3"), Some("v1.2.4".to_string()));
}
#[test]
fn test_next_version_infrastructure_bumps_patch() {
let map = categorize_commits(&["ci: update pipeline"]);
assert_eq!(next_version(&map, "v1.2.3"), Some("v1.2.4".to_string()));
}
#[test]
fn test_next_version_no_commits_unchanged() {
let map = categorize_commits(&[]);
assert_eq!(next_version(&map, "v1.2.3"), Some("v1.2.3".to_string()));
}
#[test]
fn test_next_version_feature_beats_bug_fix() {
let map = categorize_commits(&["feat: something", "fix: crash"]);
assert_eq!(next_version(&map, "v2.1.5"), Some("v2.2.0".to_string()));
}
#[test]
fn test_next_version_returns_none_for_invalid_tag() {
let map = categorize_commits(&[]);
assert_eq!(next_version(&map, "not-a-version"), None);
}
#[test]
fn test_next_version_ignores_v_prefix() {
let map = categorize_commits(&["fix: patch"]);
assert_eq!(next_version(&map, "version 0.9.1"), Some("v0.9.2".to_string()));
}
}