use std::collections::BTreeMap;
use std::path::Path;
use monochange_core::MonochangeError;
use monochange_core::MonochangeResult;
use monochange_core::ReleaseRecordDiscovery;
use monochange_core::RetargetOperation;
use monochange_core::RetargetPlan;
use monochange_core::RetargetProviderOperation;
use monochange_core::RetargetProviderResult;
use monochange_core::RetargetResult;
use monochange_core::RetargetTagResult;
use monochange_core::SourceConfiguration;
use monochange_core::SourceProvider;
use monochange_core::parse_release_record_json;
use monochange_core::release_record_release_tag_names;
use monochange_core::release_record_tag_names;
use serde::Serialize;
use crate::OutputFormat;
use crate::git_support::create_git_tag;
use crate::git_support::find_release_record_files_at_commit;
use crate::git_support::first_parent_commits;
use crate::git_support::git_is_ancestor;
use crate::git_support::move_git_tag;
use crate::git_support::push_git_tags;
use crate::git_support::push_git_tags_without_force;
use crate::git_support::read_git_file_at_commit;
use crate::git_support::resolve_git_commit_ref;
use crate::git_support::resolve_git_tag_commit;
use crate::hosted_sources;
pub(crate) async fn render_release_record_discovery(
root: &Path,
from: &str,
format: OutputFormat,
) -> MonochangeResult<String> {
let discovery = discover_release_record(root, from).await?;
match format {
OutputFormat::Json => {
serde_json::to_string_pretty(&discovery)
.map_err(|error| MonochangeError::Discovery(error.to_string()))
}
OutputFormat::Markdown | OutputFormat::Text => {
Ok(text_release_record_discovery(&discovery))
}
}
}
#[tracing::instrument(skip_all, fields(from))]
pub async fn discover_release_record(
root: &Path,
from: &str,
) -> MonochangeResult<ReleaseRecordDiscovery> {
let resolved_commit = resolve_git_commit_ref(root, from).await?;
for (distance, commit) in first_parent_commits(root, &resolved_commit)
.await?
.into_iter()
.enumerate()
{
tracing::trace!(
commit = &commit[..7],
distance,
"scanning for release record files"
);
let files = find_release_record_files_at_commit(root, &commit).await?;
if files.is_empty() {
continue;
}
let record_path = files.into_iter().next().unwrap_or_default();
let json_text = read_git_file_at_commit(root, &commit, &record_path).await?;
match parse_release_record_json(&json_text) {
Ok(record) => {
return Ok(ReleaseRecordDiscovery {
input_ref: from.to_string(),
resolved_commit: resolved_commit.clone(),
record_commit: commit,
distance,
record,
});
}
Err(monochange_core::ReleaseRecordError::UnsupportedSchemaVersionValue(version)) => {
return Err(MonochangeError::Discovery(format!(
"release record in commit {} uses unsupported schemaVersion {} (upgrade monochange to read this record)",
crate::short_commit_sha(&commit),
version
)));
}
Err(_) => {}
}
}
Err(MonochangeError::Discovery(format!(
"no monochange release record found in first-parent ancestry from `{from}`"
)))
}
pub async fn plan_release_retarget(
root: &Path,
discovery: &ReleaseRecordDiscovery,
target: &str,
force: bool,
sync_provider: bool,
dry_run: bool,
source: Option<&SourceConfiguration>,
) -> MonochangeResult<RetargetPlan> {
let target_commit = resolve_git_commit_ref(root, target).await?;
let is_descendant = git_is_ancestor(root, &discovery.record_commit, &target_commit).await?;
if !is_descendant && !force {
return Err(MonochangeError::Config(format!(
"target commit {} is not a descendant of release-record commit {}; rerun with --force to override",
crate::short_commit_sha(&target_commit),
crate::short_commit_sha(&discovery.record_commit)
)));
}
validate_retarget_provider(discovery, source)?;
let mut git_tag_updates = Vec::new();
for tag_name in release_record_tag_names(&discovery.record) {
let from_commit = resolve_git_tag_commit(root, &tag_name).await?;
git_tag_updates.push(RetargetTagResult {
tag_name,
operation: if from_commit == target_commit {
RetargetOperation::AlreadyUpToDate
} else {
RetargetOperation::Planned
},
from_commit,
to_commit: target_commit.clone(),
message: None,
});
}
let provider = source.map(|configured| configured.provider).or_else(|| {
discovery
.record
.provider
.as_ref()
.map(|provider| provider.kind)
});
let provider_updates = if sync_provider {
match provider {
Some(provider) => {
let planned_provider_tags = release_record_release_tag_names(&discovery.record)
.into_iter()
.map(|tag_name| {
RetargetTagResult {
tag_name,
operation: RetargetOperation::Planned,
from_commit: discovery.record_commit.clone(),
to_commit: target_commit.clone(),
message: None,
}
})
.collect::<Vec<_>>();
hosted_sources::hosted_source_adapter(provider)
.plan_retargeted_releases(&planned_provider_tags)
}
None => Vec::new(),
}
} else {
Vec::new()
};
Ok(RetargetPlan {
record_commit: discovery.record_commit.clone(),
target_commit,
is_descendant,
force,
git_tag_updates,
provider_updates,
sync_provider,
dry_run,
})
}
pub async fn execute_release_retarget(
root: &Path,
source: Option<&SourceConfiguration>,
plan: &RetargetPlan,
) -> MonochangeResult<RetargetResult> {
let mut git_tag_results = plan.git_tag_updates.clone();
if !plan.dry_run {
for update in &mut git_tag_results {
if update.from_commit == update.to_commit {
update.operation = RetargetOperation::AlreadyUpToDate;
continue;
}
move_git_tag(root, &update.tag_name, &update.to_commit).await?;
update.operation = RetargetOperation::Moved;
}
let moved_tags = git_tag_results
.iter()
.filter(|update| update.operation == RetargetOperation::Moved)
.map(|update| update.tag_name.as_str())
.collect::<Vec<_>>();
if !moved_tags.is_empty() {
push_git_tags(root, &moved_tags).await?;
}
}
let provider_results = if !plan.sync_provider {
Vec::new()
} else if plan
.provider_updates
.iter()
.any(|result| result.operation == RetargetProviderOperation::Unsupported)
{
if plan.dry_run {
plan.provider_updates.clone()
} else {
let provider = plan
.provider_updates
.first()
.map_or(SourceProvider::GitHub, |result| result.provider);
return Err(MonochangeError::Config(format!(
"provider sync is not yet supported for {provider} release retargeting"
)));
}
} else if let Some(source) = source {
sync_retargeted_provider_releases(source, &git_tag_results, plan.dry_run).await?
} else {
Vec::new()
};
Ok(RetargetResult {
record_commit: plan.record_commit.clone(),
target_commit: plan.target_commit.clone(),
force: plan.force,
git_tag_results,
provider_results,
sync_provider: plan.sync_provider,
dry_run: plan.dry_run,
})
}
pub async fn retarget_release(
root: &Path,
discovery: &ReleaseRecordDiscovery,
target: &str,
force: bool,
sync_provider: bool,
dry_run: bool,
source: Option<&SourceConfiguration>,
) -> MonochangeResult<RetargetResult> {
let plan = plan_release_retarget(
root,
discovery,
target,
force,
sync_provider,
dry_run,
source,
)
.await?;
execute_release_retarget(root, source, &plan).await
}
fn validate_retarget_provider(
discovery: &ReleaseRecordDiscovery,
source: Option<&SourceConfiguration>,
) -> MonochangeResult<()> {
let Some(source) = source else {
return Ok(());
};
let Some(provider) = &discovery.record.provider else {
return Ok(());
};
if provider.kind != source.provider {
return Err(MonochangeError::Config(format!(
"release record provider `{}` does not match configured source provider `{}`",
provider.kind, source.provider
)));
}
if provider.owner != source.owner || provider.repo != source.repo {
return Err(MonochangeError::Config(format!(
"release record repository `{}/{}` does not match configured source repository `{}/{}`",
provider.owner, provider.repo, source.owner, source.repo
)));
}
Ok(())
}
pub(crate) async fn sync_retargeted_provider_releases(
source: &SourceConfiguration,
tag_results: &[RetargetTagResult],
dry_run: bool,
) -> MonochangeResult<Vec<RetargetProviderResult>> {
hosted_sources::configured_hosted_source_adapter(source)
.sync_retargeted_releases(source, tag_results, dry_run)
.await
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ReleaseTagOperation {
Planned,
Created,
AlreadyUpToDate,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReleaseTagResult {
pub tag_name: String,
pub target_commit: String,
#[serde(default)]
pub existing_commit: Option<String>,
pub operation: ReleaseTagOperation,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReleaseTagReport {
pub from: String,
pub resolved_from_commit: String,
pub record_commit: String,
pub distance: usize,
pub push: bool,
pub dry_run: bool,
pub tag_results: Vec<ReleaseTagResult>,
pub tags: BTreeMap<String, String>,
pub status: String,
}
pub(crate) async fn render_release_tag_report(
root: &Path,
from: &str,
format: OutputFormat,
push: bool,
dry_run: bool,
) -> MonochangeResult<String> {
let discovery = discover_release_record(root, from).await?;
let report = create_release_tags(root, &discovery, push, dry_run).await?;
match format {
OutputFormat::Json => {
serde_json::to_string_pretty(&report)
.map_err(|error| MonochangeError::Discovery(error.to_string()))
}
OutputFormat::Markdown | OutputFormat::Text => Ok(text_release_tag_report(&report)),
}
}
pub(crate) async fn create_release_tags(
root: &Path,
discovery: &ReleaseRecordDiscovery,
push: bool,
dry_run: bool,
) -> MonochangeResult<ReleaseTagReport> {
if discovery.distance != 0 {
return Err(MonochangeError::Config(format!(
"resolved ref `{}` points to commit {}, but the nearest monochange release record is in ancestor commit {} at distance {}; `tag-release` only tags a commit that is itself the release commit",
discovery.input_ref,
crate::short_commit_sha(&discovery.resolved_commit),
crate::short_commit_sha(&discovery.record_commit),
discovery.distance,
)));
}
let mut tag_results = Vec::new();
for tag_name in release_record_tag_names(&discovery.record) {
let existing_commit = resolve_git_tag_commit(root, &tag_name).await.ok();
let operation = if existing_commit.as_deref() == Some(discovery.record_commit.as_str()) {
ReleaseTagOperation::AlreadyUpToDate
} else {
ReleaseTagOperation::Planned
};
tag_results.push(ReleaseTagResult {
tag_name,
target_commit: discovery.record_commit.clone(),
existing_commit,
operation,
});
}
for tag_result in &tag_results {
if let Some(existing_commit) = &tag_result.existing_commit
&& existing_commit != &tag_result.target_commit
{
return Err(MonochangeError::Config(format!(
"tag `{}` already points to commit {}; use `mc step:retarget-release` if you need to move an existing release tag",
tag_result.tag_name,
crate::short_commit_sha(existing_commit),
)));
}
}
if !dry_run {
for tag_result in &mut tag_results {
if tag_result.operation == ReleaseTagOperation::AlreadyUpToDate {
continue;
}
create_git_tag(root, &tag_result.tag_name, &tag_result.target_commit).await?;
tag_result.operation = ReleaseTagOperation::Created;
}
if push {
let tags_to_push = tag_results
.iter()
.filter(|tag_result| tag_result.operation == ReleaseTagOperation::Created)
.map(|tag_result| tag_result.tag_name.as_str())
.collect::<Vec<_>>();
if !tags_to_push.is_empty() {
push_git_tags_without_force(root, &tags_to_push).await?;
}
}
}
let tags = release_tag_map(discovery);
let status = if dry_run {
"dry_run"
} else if tag_results.is_empty() {
"no_tags_declared"
} else if tag_results
.iter()
.all(|tag_result| tag_result.operation == ReleaseTagOperation::AlreadyUpToDate)
{
"already_up_to_date"
} else {
"completed"
};
Ok(ReleaseTagReport {
from: discovery.input_ref.clone(),
resolved_from_commit: discovery.resolved_commit.clone(),
record_commit: discovery.record_commit.clone(),
distance: discovery.distance,
push,
dry_run,
tag_results,
tags,
status: status.to_string(),
})
}
fn release_tag_map(discovery: &ReleaseRecordDiscovery) -> BTreeMap<String, String> {
discovery
.record
.release_targets
.iter()
.filter(|target| target.tag)
.map(|target| (target.id.clone(), target.tag_name.clone()))
.collect()
}
pub(crate) fn text_release_tag_report(report: &ReleaseTagReport) -> String {
let mut lines = vec!["release tags:".to_string()];
lines.push(format!(" from: {}", report.from));
lines.push(format!(
" resolved commit: {}",
crate::short_commit_sha(&report.resolved_from_commit)
));
lines.push(format!(
" record commit: {}",
crate::short_commit_sha(&report.record_commit)
));
lines.push(format!(
" push: {}",
if report.push { "yes" } else { "no" }
));
if report.tag_results.is_empty() {
lines.push(" tags: none declared by release record".to_string());
} else {
lines.push(" tags:".to_string());
for tag_result in &report.tag_results {
let existing = tag_result
.existing_commit
.as_ref()
.map_or("missing".to_string(), |commit| {
crate::short_commit_sha(commit)
});
lines.push(format!(
" - {} (existing: {}, target: {}) [{}]",
tag_result.tag_name,
existing,
crate::short_commit_sha(&tag_result.target_commit),
match tag_result.operation {
ReleaseTagOperation::Planned => "planned",
ReleaseTagOperation::Created => "created",
ReleaseTagOperation::AlreadyUpToDate => "already_up_to_date",
},
));
}
}
lines.push(format!(" status: {}", report.status.replace('_', "-")));
lines.join("\n")
}
pub(crate) fn text_release_record_discovery(discovery: &ReleaseRecordDiscovery) -> String {
let mut lines = vec!["release record:".to_string()];
lines.push(format!(" input ref: {}", discovery.input_ref));
lines.push(format!(
" resolved commit: {}",
crate::short_commit_sha(&discovery.resolved_commit)
));
lines.push(format!(
" record commit: {}",
crate::short_commit_sha(&discovery.record_commit)
));
lines.push(format!(" distance: {}", discovery.distance));
if let Some(version) = &discovery.record.version {
lines.push(format!(" version: {version}"));
}
if !discovery.record.versions.is_empty() {
lines.push(" versions:".to_string());
for (id, version) in &discovery.record.versions {
lines.push(format!(" - {id}: {version}"));
}
}
if !discovery.record.release_targets.is_empty() {
lines.push(" targets:".to_string());
for target in &discovery.record.release_targets {
lines.push(format!(
" - {} {} -> {} (tag: {})",
target.kind, target.id, target.version, target.tag_name
));
}
}
if !discovery.record.released_packages.is_empty() {
lines.push(" packages:".to_string());
for package in &discovery.record.released_packages {
lines.push(format!(" - {package}"));
}
}
if let Some(provider) = &discovery.record.provider {
lines.push(format!(
" provider: {} {}/{}",
provider.kind, provider.owner, provider.repo
));
}
lines.join("\n")
}