use anodizer_core::config::Config;
use anodizer_core::context::Context;
use anodizer_core::log::StageLogger;
use anodizer_core::retry::{RetryPolicy, SuccessClass, retry_http_async};
use anodizer_core::scm::ScmTokenType;
use anyhow::{Context as _, Result};
struct MilestoneTarget {
name: String,
owner: String,
repo_name: String,
}
fn resolve_milestone_for_close(
milestone_cfg: &anodizer_core::config::MilestoneConfig,
ctx: &Context,
log: &StageLogger,
) -> Result<Option<MilestoneTarget>> {
if !milestone_cfg.resolved_close() {
return Ok(None);
}
let name_template = milestone_cfg.resolved_name_template();
let milestone_name = ctx
.render_template(name_template)
.context("milestone: render name_template")?;
if milestone_name.is_empty() {
ctx.strict_guard(log, "milestone: name_template rendered to empty — skipping")?;
return Ok(None);
}
let (owner, repo_name) = resolve_milestone_repo(milestone_cfg, &ctx.config, ctx.token_type);
if owner.is_empty() || repo_name.is_empty() {
ctx.strict_guard(
log,
"milestone: repo owner/name not resolvable — skipping close",
)?;
return Ok(None);
}
Ok(Some(MilestoneTarget {
name: milestone_name,
owner,
repo_name,
}))
}
pub(super) fn preflight_milestones(
milestones: &[anodizer_core::config::MilestoneConfig],
ctx: &mut Context,
log: &StageLogger,
) -> Result<()> {
for milestone_cfg in milestones {
if let Some(target) = resolve_milestone_for_close(milestone_cfg, ctx, log)? {
log.status(&format!(
"milestone: will close '{}' on {}/{}",
target.name, target.owner, target.repo_name
));
}
}
Ok(())
}
pub(super) fn close_milestones(
milestones: &[anodizer_core::config::MilestoneConfig],
ctx: &mut Context,
dry_run: bool,
log: &StageLogger,
) -> Result<()> {
let token = ctx.options.token.clone().unwrap_or_default();
let rt = tokio::runtime::Runtime::new().context("milestone: create tokio runtime")?;
let policy = ctx.retry_policy();
for milestone_cfg in milestones {
let Some(target) = resolve_milestone_for_close(milestone_cfg, ctx, log)? else {
continue;
};
let MilestoneTarget {
name: milestone_name,
owner,
repo_name,
} = target;
if dry_run {
log.status(&format!(
"(dry-run) would close milestone '{}' on {}/{}",
milestone_name, owner, repo_name
));
continue;
}
log.status(&format!(
"closing milestone '{}' on {}/{}",
milestone_name, owner, repo_name
));
let api_url = resolve_milestone_api_url(milestone_cfg, &ctx.config);
let close_result = match ctx.token_type {
ScmTokenType::GitHub => {
close_milestone_github(&rt, &token, &owner, &repo_name, &milestone_name, &policy)
}
ScmTokenType::GitLab => close_milestone_gitlab(
&rt,
&token,
&owner,
&repo_name,
&milestone_name,
api_url.as_deref(),
&policy,
),
ScmTokenType::Gitea => close_milestone_gitea(
&rt,
&token,
&owner,
&repo_name,
&milestone_name,
api_url.as_deref(),
&policy,
),
};
match close_result {
Ok(MilestoneCloseOutcome::Closed) => {
log.status(&format!("milestone '{}' closed", milestone_name));
}
Ok(MilestoneCloseOutcome::NotFound) => {
log.verbose(&format!(
"milestone '{}' not found on {}/{} (likely already closed)",
milestone_name, owner, repo_name
));
}
Err(e) => {
if milestone_cfg.resolved_fail_on_error() {
return Err(
e.context(format!("milestone: failed to close '{}'", milestone_name))
);
}
log.warn(&format!(
"milestone: could not close '{}': {}",
milestone_name, e
));
}
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MilestoneCloseOutcome {
Closed,
NotFound,
}
fn resolve_milestone_repo(
milestone_cfg: &anodizer_core::config::MilestoneConfig,
config: &Config,
token_type: ScmTokenType,
) -> (String, String) {
if let Some(ref repo_cfg) = milestone_cfg.repo
&& !repo_cfg.owner.is_empty()
&& !repo_cfg.name.is_empty()
{
return (repo_cfg.owner.clone(), repo_cfg.name.clone());
}
let mut fallback: Option<(String, String)> = None;
for crate_cfg in &config.crates {
let Some(ref release_cfg) = crate_cfg.release else {
continue;
};
let preferred = match token_type {
ScmTokenType::GitHub => release_cfg.github.as_ref(),
ScmTokenType::GitLab => release_cfg.gitlab.as_ref(),
ScmTokenType::Gitea => release_cfg.gitea.as_ref(),
};
if let Some(r) = preferred {
return (r.owner.clone(), r.name.clone());
}
if fallback.is_none() {
fallback = release_cfg
.github
.as_ref()
.or(release_cfg.gitlab.as_ref())
.or(release_cfg.gitea.as_ref())
.map(|r| (r.owner.clone(), r.name.clone()));
}
}
if let Some(pair) = fallback {
return pair;
}
if let Ok(pair) = anodizer_core::git::detect_owner_repo() {
return pair;
}
(String::new(), String::new())
}
fn close_milestone_github(
rt: &tokio::runtime::Runtime,
token: &str,
owner: &str,
repo: &str,
milestone_name: &str,
policy: &RetryPolicy,
) -> Result<MilestoneCloseOutcome> {
if token.is_empty() {
anyhow::bail!("no authentication token available for milestone close");
}
rt.block_on(async {
let client = reqwest::Client::new();
let mut page = 1u32;
let mut milestone_number: Option<u64> = None;
loop {
let url = format!(
"https://api.github.com/repos/{}/{}/milestones?state=open&per_page=100&page={}",
owner, repo, page
);
let resp = retry_http_async(
"milestone: list milestones",
policy,
SuccessClass::Strict,
|_| {
client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", anodizer_core::http::USER_AGENT)
.send()
},
|status, body| format!("milestone: list milestones failed (HTTP {status}): {body}"),
)
.await?;
let milestones: Vec<serde_json::Value> = resp
.json()
.await
.context("milestone: parse milestones response")?;
if milestones.is_empty() {
break;
}
if let Some(m) = milestones.iter().find(|m| {
m.get("title")
.and_then(|t| t.as_str())
.is_some_and(|t| t == milestone_name)
}) {
milestone_number = m.get("number").and_then(|n| n.as_u64());
break;
}
if milestones.len() < 100 {
break;
}
page += 1;
}
let milestone_number = match milestone_number {
Some(n) => n,
None => return Ok(MilestoneCloseOutcome::NotFound),
};
let close_url = format!(
"https://api.github.com/repos/{}/{}/milestones/{}",
owner, repo, milestone_number
);
retry_http_async(
"milestone: close milestone",
policy,
SuccessClass::Strict,
|_| {
client
.patch(&close_url)
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", anodizer_core::http::USER_AGENT)
.json(&serde_json::json!({ "state": "closed" }))
.send()
},
|status, body| format!("milestone: close failed (HTTP {status}): {body}"),
)
.await?;
Ok(MilestoneCloseOutcome::Closed)
})
}
use anodizer_core::url::percent_encode_unreserved as url_encode;
fn resolve_milestone_api_url(
_milestone_cfg: &anodizer_core::config::MilestoneConfig,
config: &Config,
) -> Option<String> {
let normalize = |api: &str| api.trim_end_matches('/').to_string();
if let Some(ref gitlab) = config.gitlab_urls
&& let Some(ref api) = gitlab.api
{
return Some(normalize(api));
}
if let Some(ref gitea) = config.gitea_urls
&& let Some(ref api) = gitea.api
{
return Some(normalize(api));
}
None
}
fn close_milestone_gitlab(
rt: &tokio::runtime::Runtime,
token: &str,
owner: &str,
repo: &str,
milestone_name: &str,
api_url: Option<&str>,
policy: &RetryPolicy,
) -> Result<MilestoneCloseOutcome> {
if token.is_empty() {
anyhow::bail!("no authentication token available for GitLab milestone close");
}
let base = api_url.unwrap_or("https://gitlab.com/api/v4");
rt.block_on(async {
let client = reqwest::Client::new();
let project_path = format!("{}/{}", owner, repo);
let encoded_path = url_encode(&project_path);
let url = format!(
"{}/projects/{}/milestones?title={}",
base,
encoded_path,
url_encode(milestone_name)
);
let resp = retry_http_async(
"milestone: GitLab list milestones",
policy,
SuccessClass::Strict,
|_| {
client
.get(&url)
.header("PRIVATE-TOKEN", token)
.header("User-Agent", anodizer_core::http::USER_AGENT)
.send()
},
|status, body| {
format!("milestone: GitLab list milestones failed (HTTP {status}): {body}")
},
)
.await?;
let milestones: Vec<serde_json::Value> = resp
.json()
.await
.context("milestone: parse GitLab milestones")?;
let milestone_id = milestones
.iter()
.find(|m| {
m.get("title")
.and_then(|t| t.as_str())
.is_some_and(|t| t == milestone_name)
})
.and_then(|m| m.get("id").and_then(|i| i.as_u64()));
let milestone_id = match milestone_id {
Some(id) => id,
None => return Ok(MilestoneCloseOutcome::NotFound),
};
let close_url = format!(
"{}/projects/{}/milestones/{}",
base, encoded_path, milestone_id
);
retry_http_async(
"milestone: GitLab close milestone",
policy,
SuccessClass::Strict,
|_| {
client
.put(&close_url)
.header("PRIVATE-TOKEN", token)
.header("User-Agent", anodizer_core::http::USER_AGENT)
.json(&serde_json::json!({ "state_event": "close" }))
.send()
},
|status, body| format!("milestone: GitLab close failed (HTTP {status}): {body}"),
)
.await?;
Ok(MilestoneCloseOutcome::Closed)
})
}
fn close_milestone_gitea(
rt: &tokio::runtime::Runtime,
token: &str,
owner: &str,
repo: &str,
milestone_name: &str,
api_url: Option<&str>,
policy: &RetryPolicy,
) -> Result<MilestoneCloseOutcome> {
if token.is_empty() {
anyhow::bail!("no authentication token available for Gitea milestone close");
}
let base = api_url.unwrap_or("https://gitea.com/api/v1");
rt.block_on(async {
let client = reqwest::Client::new();
let url = format!(
"{}/repos/{}/{}/milestones?state=open&name={}",
base,
owner,
repo,
url_encode(milestone_name)
);
let resp = retry_http_async(
"milestone: Gitea list milestones",
policy,
SuccessClass::Strict,
|_| {
client
.get(&url)
.header("Authorization", format!("token {}", token))
.header("User-Agent", anodizer_core::http::USER_AGENT)
.send()
},
|status, body| {
format!("milestone: Gitea list milestones failed (HTTP {status}): {body}")
},
)
.await?;
let milestones: Vec<serde_json::Value> = resp
.json()
.await
.context("milestone: parse Gitea milestones")?;
let milestone_id = milestones
.iter()
.find(|m| {
m.get("title")
.and_then(|t| t.as_str())
.is_some_and(|t| t == milestone_name)
})
.and_then(|m| m.get("id").and_then(|i| i.as_u64()));
let milestone_id = match milestone_id {
Some(id) => id,
None => return Ok(MilestoneCloseOutcome::NotFound),
};
let close_url = format!(
"{}/repos/{}/{}/milestones/{}",
base, owner, repo, milestone_id
);
match retry_http_async(
"milestone: Gitea close milestone",
policy,
SuccessClass::Strict,
|_| {
client
.patch(&close_url)
.header("Authorization", format!("token {}", token))
.header("User-Agent", anodizer_core::http::USER_AGENT)
.json(&serde_json::json!({ "state": "closed" }))
.send()
},
|status, body| format!("milestone: Gitea close failed (HTTP {status}): {body}"),
)
.await
{
Ok(_) => Ok(MilestoneCloseOutcome::Closed),
Err(err) => {
let status_code = err
.chain()
.find_map(|e| {
e.downcast_ref::<anodizer_core::retry::HttpError>()
.map(|h| h.status)
})
.unwrap_or(0);
if status_code == 404 {
Ok(MilestoneCloseOutcome::NotFound)
} else {
Err(err)
}
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use anodizer_core::config::{
Config, CrateConfig, MilestoneConfig, ReleaseConfig, ScmRepoConfig,
};
use anodizer_core::context::ContextOptions;
fn ctx_with_strict(config: Config, strict: bool) -> Context {
let mut ctx = Context::new(
config,
ContextOptions {
dry_run: true,
strict,
..Default::default()
},
);
ctx.template_vars_mut().set("Tag", "v1.0.0");
ctx
}
fn config_with_resolvable_repo() -> Config {
Config {
crates: vec![CrateConfig {
release: Some(ReleaseConfig {
github: Some(ScmRepoConfig {
owner: "toss45".into(),
name: "anodize".into(),
}),
..Default::default()
}),
..Default::default()
}],
..Default::default()
}
}
fn config_with_empty_release_block() -> Config {
Config {
crates: vec![CrateConfig {
release: Some(ReleaseConfig {
github: Some(ScmRepoConfig {
owner: String::new(),
name: String::new(),
}),
..Default::default()
}),
..Default::default()
}],
..Default::default()
}
}
#[test]
fn empty_name_normal_mode_warns_and_skips() {
let config = config_with_resolvable_repo();
let mut ctx = ctx_with_strict(config, false);
let log = ctx.logger("milestone");
let milestones = vec![MilestoneConfig {
close: Some(true),
name_template: Some(String::new()),
..Default::default()
}];
close_milestones(&milestones, &mut ctx, true, &log)
.expect("normal mode must skip empty rendered name cleanly");
}
#[test]
fn empty_name_strict_mode_errors() {
let config = config_with_resolvable_repo();
let mut ctx = ctx_with_strict(config, true);
let log = ctx.logger("milestone");
let milestones = vec![MilestoneConfig {
close: Some(true),
name_template: Some(String::new()),
..Default::default()
}];
let err = close_milestones(&milestones, &mut ctx, true, &log)
.expect_err("strict mode must error on empty rendered name");
assert!(
err.to_string().contains("rendered to empty"),
"unexpected error: {}",
err
);
}
#[test]
fn unresolvable_repo_normal_mode_warns_and_skips() {
let config = config_with_empty_release_block();
let mut ctx = ctx_with_strict(config, false);
let log = ctx.logger("milestone");
let milestones = vec![MilestoneConfig {
close: Some(true),
name_template: Some("{{ Tag }}".into()),
..Default::default()
}];
close_milestones(&milestones, &mut ctx, true, &log)
.expect("normal mode must skip unresolvable repo cleanly");
}
#[test]
fn unresolvable_repo_strict_mode_errors() {
let config = config_with_empty_release_block();
let mut ctx = ctx_with_strict(config, true);
let log = ctx.logger("milestone");
let milestones = vec![MilestoneConfig {
close: Some(true),
name_template: Some("{{ Tag }}".into()),
..Default::default()
}];
let err = close_milestones(&milestones, &mut ctx, true, &log)
.expect_err("strict mode must error on unresolvable repo");
assert!(
err.to_string().contains("not resolvable"),
"unexpected error: {}",
err
);
}
#[test]
fn unresolvable_repo_ignores_fail_on_error() {
let config = config_with_empty_release_block();
let mut ctx = ctx_with_strict(config, false);
let log = ctx.logger("milestone");
let milestones = vec![MilestoneConfig {
close: Some(true),
fail_on_error: Some(true),
name_template: Some("{{ Tag }}".into()),
..Default::default()
}];
close_milestones(&milestones, &mut ctx, true, &log)
.expect("fail_on_error must not gate config-resolution failures");
}
#[test]
fn preflight_resolvable_milestone_logs_and_returns_ok() {
let config = config_with_resolvable_repo();
let mut ctx = ctx_with_strict(config, false);
let log = ctx.logger("milestone");
let milestones = vec![MilestoneConfig {
close: Some(true),
name_template: Some("{{ Tag }}".into()),
..Default::default()
}];
preflight_milestones(&milestones, &mut ctx, &log)
.expect("resolvable milestone must pre-flight cleanly");
}
#[test]
fn preflight_close_false_is_noop() {
let config = config_with_empty_release_block();
let mut ctx = ctx_with_strict(config, true);
let log = ctx.logger("milestone");
let milestones = vec![MilestoneConfig {
close: Some(false),
name_template: Some("{{ Tag }}".into()),
..Default::default()
}];
preflight_milestones(&milestones, &mut ctx, &log)
.expect("close: false must not trip strict_guard at pre-flight");
}
#[test]
fn preflight_empty_name_normal_mode_warns_and_continues() {
let config = config_with_resolvable_repo();
let mut ctx = ctx_with_strict(config, false);
let log = ctx.logger("milestone");
let milestones = vec![MilestoneConfig {
close: Some(true),
name_template: Some(String::new()),
..Default::default()
}];
preflight_milestones(&milestones, &mut ctx, &log)
.expect("normal mode pre-flight must skip empty rendered name");
}
#[test]
fn preflight_empty_name_strict_mode_errors() {
let config = config_with_resolvable_repo();
let mut ctx = ctx_with_strict(config, true);
let log = ctx.logger("milestone");
let milestones = vec![MilestoneConfig {
close: Some(true),
name_template: Some(String::new()),
..Default::default()
}];
let err = preflight_milestones(&milestones, &mut ctx, &log)
.expect_err("strict mode pre-flight must error on empty rendered name");
assert!(
err.to_string().contains("rendered to empty"),
"unexpected error: {}",
err
);
}
#[test]
fn preflight_unresolvable_repo_normal_mode_warns_and_continues() {
let config = config_with_empty_release_block();
let mut ctx = ctx_with_strict(config, false);
let log = ctx.logger("milestone");
let milestones = vec![MilestoneConfig {
close: Some(true),
name_template: Some("{{ Tag }}".into()),
..Default::default()
}];
preflight_milestones(&milestones, &mut ctx, &log)
.expect("normal mode pre-flight must skip unresolvable repo");
}
#[test]
fn preflight_unresolvable_repo_strict_mode_errors() {
let config = config_with_empty_release_block();
let mut ctx = ctx_with_strict(config, true);
let log = ctx.logger("milestone");
let milestones = vec![MilestoneConfig {
close: Some(true),
name_template: Some("{{ Tag }}".into()),
..Default::default()
}];
let err = preflight_milestones(&milestones, &mut ctx, &log)
.expect_err("strict mode pre-flight must error on unresolvable repo");
assert!(
err.to_string().contains("not resolvable"),
"unexpected error: {}",
err
);
}
#[test]
fn resolve_milestone_for_close_returns_target_with_resolved_fields() {
let config = config_with_resolvable_repo();
let ctx = ctx_with_strict(config, false);
let log = ctx.logger("milestone");
let milestone_cfg = MilestoneConfig {
close: Some(true),
name_template: Some("{{ Tag }}".into()),
..Default::default()
};
let target = resolve_milestone_for_close(&milestone_cfg, &ctx, &log)
.expect("resolution must succeed")
.expect("close: true + resolvable repo must return Some(target)");
assert_eq!(target.name, "v1.0.0");
assert_eq!(target.owner, "toss45");
assert_eq!(target.repo_name, "anodize");
}
#[test]
fn preflight_close_false_with_empty_name_strict_is_noop() {
let config = config_with_resolvable_repo();
let mut ctx = ctx_with_strict(config, true);
let log = ctx.logger("milestone");
let milestones = vec![MilestoneConfig {
close: Some(false),
name_template: Some(String::new()),
..Default::default()
}];
preflight_milestones(&milestones, &mut ctx, &log)
.expect("close: false must short-circuit before name-render check");
}
#[test]
fn preflight_continues_past_unresolvable_in_normal_mode() {
let config = config_with_empty_release_block();
let mut ctx = ctx_with_strict(config, false);
let log = ctx.logger("milestone");
let milestones = vec![
MilestoneConfig {
close: Some(true),
name_template: Some("{{ Tag }}".into()),
..Default::default()
},
MilestoneConfig {
close: Some(true),
name_template: Some("{{ Tag }}".into()),
repo: Some(ScmRepoConfig {
owner: "explicit".into(),
name: "override".into(),
}),
..Default::default()
},
];
preflight_milestones(&milestones, &mut ctx, &log)
.expect("normal mode must not promote a warn on milestone[0] to Err");
}
#[test]
fn preflight_strict_iterates_to_second_milestone() {
let config = config_with_empty_release_block();
let mut ctx = ctx_with_strict(config, true);
let log = ctx.logger("milestone");
let milestones = vec![
MilestoneConfig {
close: Some(true),
name_template: Some("{{ Tag }}".into()),
repo: Some(ScmRepoConfig {
owner: "explicit".into(),
name: "override".into(),
}),
..Default::default()
},
MilestoneConfig {
close: Some(true),
name_template: Some("{{ Tag }}".into()),
..Default::default()
},
];
let err = preflight_milestones(&milestones, &mut ctx, &log)
.expect_err("strict mode must error on milestone[1] unresolvable repo");
assert!(
err.to_string().contains("not resolvable"),
"unexpected error: {}",
err
);
}
use anodizer_core::test_helpers::responder::spawn_oneshot_http_responder;
#[test]
fn close_milestone_gitlab_retries_5xx_then_succeeds() {
use std::sync::atomic::Ordering;
use std::time::Duration;
let (addr, calls) = spawn_oneshot_http_responder(vec![
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 28\r\n\r\n[{\"id\":42,\"title\":\"v1.0.0\"}]",
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 18\r\n\r\n{\"state\":\"closed\"}",
]);
let rt = tokio::runtime::Runtime::new().expect("runtime");
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let api_url = format!("http://{addr}");
let outcome = close_milestone_gitlab(
&rt,
"test-token",
"myorg",
"myrepo",
"v1.0.0",
Some(&api_url),
&policy,
)
.expect("retry past 503 then close");
assert_eq!(outcome, MilestoneCloseOutcome::Closed);
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"expected 3 connections (503 retry GET, 200 GET, 200 PUT)"
);
}
}