use async_trait::async_trait;
use std::sync::OnceLock;
use url::Url;
use crate::{
analyzer::release::Tag,
config::Config,
error::Result,
file_loader::FileLoader,
forge::{
request::{
Commit, CreateCommitRequest, CreatePrRequest,
CreateReleaseBranchRequest, ForgeCommit, GetFileContentRequest,
GetPrRequest, PrLabelsRequest, PullRequest, ReleaseByTagResponse,
UpdatePrRequest,
},
traits::Forge,
},
};
pub struct ForgeOptions {
pub dry_run: bool,
}
pub struct ForgeManager {
forge: Box<dyn Forge>,
repo_name: OnceLock<String>,
default_branch: OnceLock<String>,
release_link_base_url: OnceLock<Url>,
compare_link_base_url: OnceLock<Url>,
options: ForgeOptions,
}
impl ForgeManager {
pub fn new(forge: Box<dyn Forge>, options: ForgeOptions) -> Self {
Self {
forge,
repo_name: OnceLock::new(),
default_branch: OnceLock::new(),
release_link_base_url: OnceLock::new(),
compare_link_base_url: OnceLock::new(),
options,
}
}
pub fn repo_name(&self) -> &str {
self.repo_name.get_or_init(|| self.forge.repo_name())
}
pub fn release_link_base_url(&self) -> &Url {
self.release_link_base_url
.get_or_init(|| self.forge.release_link_base_url())
}
pub fn compare_link_base_url(&self) -> &Url {
self.compare_link_base_url
.get_or_init(|| self.forge.compare_link_base_url())
}
pub fn default_branch(&self) -> &str {
self.default_branch
.get_or_init(|| self.forge.default_branch())
}
pub async fn get_file_content(
&self,
req: GetFileContentRequest,
) -> Result<Option<String>> {
log::debug!("Loading file: {} (branch: {:?})", req.path, req.branch);
let result = self.forge.get_file_content(req).await;
if let Err(e) = &result {
log::error!("Failed to load file: {}", e);
}
result
}
pub async fn load_config(&self, branch: Option<String>) -> Result<Config> {
log::info!("Loading configuration from forge (branch: {:?})", branch);
let result = self.forge.load_config(branch).await;
if let Err(e) = &result {
log::error!("Failed to load configuration: {}", e);
}
result
}
pub async fn get_release_by_tag(
&self,
tag: &str,
) -> Result<ReleaseByTagResponse> {
self.forge.get_release_by_tag(tag).await
}
pub async fn get_latest_tag_for_prefix(
&self,
prefix: &str,
branch: &str,
) -> Result<Option<Tag>> {
self.forge.get_latest_tag_for_prefix(prefix, branch).await
}
pub async fn get_commits(
&self,
branch: Option<String>,
sha: Option<String>,
) -> Result<Vec<ForgeCommit>> {
log::debug!(
"getting commits for branch [{:?}] starting from sha: {:?}",
branch,
sha
);
self.forge.get_commits(branch, sha).await
}
pub async fn get_open_release_pr(
&self,
req: GetPrRequest,
) -> Result<Option<PullRequest>> {
log::info!(
"Looking for open release PR: base={}, head={}",
req.base_branch,
req.head_branch
);
let result = self.forge.get_open_release_pr(req).await;
match &result {
Ok(Some(pr)) => log::info!("Found open PR #{}", pr.number),
Ok(None) => log::debug!("No open PR found"),
Err(e) => log::error!("Error searching for open PR: {}", e),
}
result
}
pub async fn get_merged_release_pr(
&self,
req: GetPrRequest,
) -> Result<Option<PullRequest>> {
log::info!(
"Looking for merged release PR: base={}, head={}",
req.base_branch,
req.head_branch
);
let result = self.forge.get_merged_release_pr(req).await;
match &result {
Ok(Some(pr)) => log::info!("Found merged PR #{}", pr.number),
Ok(None) => log::warn!("No merged PR found"),
Err(e) => log::error!("Error searching for merged PR: {}", e),
}
result
}
pub async fn create_release_branch(
&self,
req: CreateReleaseBranchRequest,
) -> Result<Commit> {
if self.options.dry_run {
log::warn!("dry_run: would create release branch: req: {:#?}", req);
return Ok(Commit { sha: "fff".into() });
}
log::info!(
"Creating release branch: {} from {}",
req.release_branch,
req.base_branch
);
let result = self.forge.create_release_branch(req).await;
match &result {
Ok(commit) => {
log::info!("Created release branch with commit: {}", commit.sha)
}
Err(e) => log::error!("Failed to create release branch: {}", e),
}
result
}
pub async fn create_commit(
&self,
req: CreateCommitRequest,
) -> Result<Commit> {
if self.options.dry_run {
log::warn!("dry_run: would create commit: req: {:#?}", req);
return Ok(Commit { sha: "fff".into() });
}
log::info!(
"Creating commit on branch: {} ({} file changes)",
req.target_branch,
req.file_changes.len()
);
let result = self.forge.create_commit(req).await;
match &result {
Ok(commit) => log::info!("Created commit: {}", commit.sha),
Err(e) => log::error!("Failed to create commit: {}", e),
}
result
}
pub async fn tag_commit(&self, tag_name: &str, sha: &str) -> Result<()> {
if self.options.dry_run {
log::warn!(
"dry_run: would tag commit: tag={}, sha={}",
tag_name,
sha
);
return Ok(());
}
log::info!("Tagging commit: tag={}, sha={}", tag_name, sha);
let result = self.forge.tag_commit(tag_name, sha).await;
match &result {
Ok(_) => log::info!("Successfully created tag: {}", tag_name),
Err(e) => log::error!("Failed to create tag {}: {}", tag_name, e),
}
result
}
pub async fn create_pr(&self, req: CreatePrRequest) -> Result<PullRequest> {
if self.options.dry_run {
log::warn!(
"dry_run: would create PR: {} -> {}",
req.head_branch,
req.base_branch
);
return Ok(PullRequest {
number: 0,
sha: "fff".into(),
body: req.body,
});
}
log::info!(
"Creating pull request: {} -> {}",
req.head_branch,
req.base_branch
);
let result = self.forge.create_pr(req).await;
match &result {
Ok(pr) => log::info!("Created pull request #{}", pr.number),
Err(e) => log::error!("Failed to create pull request: {}", e),
}
result
}
pub async fn update_pr(&self, req: UpdatePrRequest) -> Result<()> {
if self.options.dry_run {
log::warn!("dry_run: would update PR: req: {:#?}", req);
return Ok(());
}
log::info!("Updating pull request #{}", req.pr_number);
let result = self.forge.update_pr(req).await;
if let Err(e) = &result {
log::error!("Failed to update PR: {}", e);
}
result
}
pub async fn replace_pr_labels(&self, req: PrLabelsRequest) -> Result<()> {
if self.options.dry_run {
log::warn!(
"dry_run: would replace PR #{} labels with: {:?}",
req.pr_number,
req.labels
);
return Ok(());
}
log::info!(
"Replacing labels on PR #{} with: {:?}",
req.pr_number,
req.labels
);
let result = self.forge.replace_pr_labels(req).await;
if let Err(e) = &result {
log::error!("Failed to update labels on PR: {}", e);
}
result
}
pub async fn create_release(
&self,
tag: &str,
sha: &str,
notes: &str,
) -> Result<()> {
if self.options.dry_run {
log::warn!(
"dry_run: would create release: tag: {tag}, sha: {sha}, notes {notes}"
);
return Ok(());
}
log::info!("Creating release: tag={}, sha={}", tag, sha);
let result = self.forge.create_release(tag, sha, notes).await;
match &result {
Ok(_) => log::info!("Successfully created release: {}", tag),
Err(e) => log::error!("Failed to create release {}: {}", tag, e),
}
result
}
}
#[async_trait]
impl FileLoader for ForgeManager {
async fn load_file(
&self,
branch: Option<String>,
path: String,
) -> Result<Option<String>> {
self.get_file_content(GetFileContentRequest { branch, path })
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
file_loader::FileLoader,
forge::{request::GetFileContentRequest, traits::MockForge},
};
#[tokio::test]
async fn file_loader_returns_file_content() {
let mut mock_forge = MockForge::new();
mock_forge
.expect_get_file_content()
.with(mockall::predicate::eq(GetFileContentRequest {
branch: Some("main".to_string()),
path: "package.json".to_string(),
}))
.returning(|_| Ok(Some(r#"{"version":"1.0.0"}"#.to_string())));
let manager = ForgeManager::new(
Box::new(mock_forge),
ForgeOptions { dry_run: false },
);
let result = manager
.load_file(Some("main".to_string()), "package.json".to_string())
.await
.unwrap()
.unwrap();
assert!(result.contains("1.0.0"));
}
#[tokio::test]
async fn file_loader_returns_none_when_file_not_found() {
let mut mock_forge = MockForge::new();
mock_forge.expect_get_file_content().returning(|_| Ok(None));
let manager = ForgeManager::new(
Box::new(mock_forge),
ForgeOptions { dry_run: false },
);
let result = manager
.load_file(None, "missing.txt".to_string())
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn dry_run_prevents_create_release_branch() {
let mock_forge = MockForge::new();
let manager = ForgeManager::new(
Box::new(mock_forge),
ForgeOptions { dry_run: true },
);
let req = CreateReleaseBranchRequest {
base_branch: "main".into(),
release_branch: "release-branch".into(),
message: "chore: release".into(),
file_changes: vec![],
};
let result = manager.create_release_branch(req).await.unwrap();
assert_eq!(result.sha, "fff");
}
#[tokio::test]
async fn dry_run_prevents_tag_commit() {
let mock_forge = MockForge::new();
let manager = ForgeManager::new(
Box::new(mock_forge),
ForgeOptions { dry_run: true },
);
manager.tag_commit("v1.0.0", "abc123").await.unwrap();
}
#[tokio::test]
async fn dry_run_prevents_create_pr() {
let mock_forge = MockForge::new();
let manager = ForgeManager::new(
Box::new(mock_forge),
ForgeOptions { dry_run: true },
);
let req = CreatePrRequest {
title: "test".to_string(),
body: "test body".to_string(),
head_branch: "branch".to_string(),
base_branch: "main".to_string(),
};
let result = manager.create_pr(req).await.unwrap();
assert_eq!(result.number, 0);
assert_eq!(result.sha, "fff");
}
#[tokio::test]
async fn dry_run_prevents_update_pr() {
let mock_forge = MockForge::new();
let manager = ForgeManager::new(
Box::new(mock_forge),
ForgeOptions { dry_run: true },
);
let req = UpdatePrRequest {
pr_number: 42,
title: "Updated title".to_string(),
body: "Updated body".to_string(),
};
manager.update_pr(req).await.unwrap();
}
#[tokio::test]
async fn dry_run_prevents_replace_pr_labels() {
let mock_forge = MockForge::new();
let manager = ForgeManager::new(
Box::new(mock_forge),
ForgeOptions { dry_run: true },
);
let req = PrLabelsRequest {
pr_number: 42,
labels: vec!["release".to_string()],
};
manager.replace_pr_labels(req).await.unwrap();
}
#[tokio::test]
async fn dry_run_prevents_create_release() {
let mock_forge = MockForge::new();
let manager = ForgeManager::new(
Box::new(mock_forge),
ForgeOptions { dry_run: true },
);
manager
.create_release("v1.0.0", "abc123", "Release notes")
.await
.unwrap();
}
}