use camino::Utf8Path;
use serde::Serialize;
use crate::diagnostic::{Diagnostic, Reason};
use crate::error::RkError;
use super::adapter::Version;
use super::forge::Client;
use super::guard::{self, Proposal};
use super::packed::{self, Comparison};
use super::{Context, Done, cliff, eligible};
pub const SCHEMA: &str = "rk.release-request/2";
#[derive(Debug, Serialize)]
pub struct RequestReport {
pub schema: &'static str,
pub state: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compared: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub packed: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proposal: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request: Option<u64>,
pub files: Vec<String>,
}
impl RequestReport {
const fn empty(state: &'static str) -> Self {
Self {
schema: SCHEMA,
state,
reason: None,
base: None,
version: None,
compared: None,
packed: None,
proposal: None,
tag: None,
request: None,
files: Vec::new(),
}
}
}
pub fn run(target: &Utf8Path) -> Result<Done<RequestReport>, RkError> {
let context = Context::load(target)?;
refuse_default_token(&context)?;
context.require_tools("request")?;
let Some(base) = context
.pushed_branch()
.filter(|branch| context.is_base(branch))
else {
let mut report = RequestReport::empty("skip");
report.reason = Some("the push is to no release base".to_owned());
return Ok(Done {
lines: vec!["skip: the push is to neither the trunk nor a release line".to_owned()],
report,
next: Vec::new(),
});
};
cliff::require_pin(target)?;
let client = Client::resolve(context.forge, target, &context.repo)?;
let current = context.adapter.read_version(target)?;
let branch = super::request_branch(&base);
let (proposal, comparison) = decide(&context, &client, target, current)?;
let mut report = RequestReport::empty("nothing");
report.base = Some(base.clone());
report.version = Some(current.spelled(context.driver()));
let compared = record_comparison(&mut report, comparison.as_ref());
let paths = comparison
.map(|comparison| comparison.history)
.unwrap_or_default();
let next = match proposal {
Proposal::Refuse(reason) => {
return Err(RkError::refusal(
Diagnostic::new(Reason::StateDrift, reason)
.expected("a final version for the request to propose")
.action("rk lines mint numbers a candidate; the request proposes final versions alone")
.target_state("unchanged"),
));
}
Proposal::Nothing(reason) => {
let mut done = close_stale(&context, &client, &base, report, reason)?;
done.lines.splice(0..0, compared);
return Ok(done);
}
Proposal::Propose(next) => next,
};
let tag = context.adapter.tag_of(next);
let files = commit_request(&context, &client, next, &tag, &branch, &paths)?;
let title = format!("chore(release): {tag}");
let body = super::forge_text(
"blocks/release-request-body.md.in",
&[("RK_TAG", &tag), ("RK_BASE", &base)],
)?;
let (number, state) = match client.open_request(&base, &branch)? {
Some(number) => {
client.edit_request(number, &title, &body)?;
(number, "refreshed")
}
None => (
client.create_request(&base, &branch, &title, &body)?,
"opened",
),
};
let mut lines = compared;
lines.push(format!(
"{state} release request #{number} proposing {} on {branch} into {base}",
next.spelled(context.driver())
));
lines.push("not armed: the request waits for the operator's order to merge".to_owned());
report.state = state;
report.proposal = Some(next.spelled(context.driver()));
report.tag = Some(tag);
report.request = Some(number);
report.files = files;
Ok(Done {
report,
lines,
next: Vec::new(),
})
}
fn refuse_default_token(context: &Context) -> Result<(), RkError> {
let Some(var) = super::default_ci_token(context.forge, |name| std::env::var(name).ok()) else {
return Ok(());
};
Err(RkError::refusal(
Diagnostic::new(
Reason::DefaultCiToken,
format!(
"the push credential is {var}, the forge's default CI token, and a push with it starts no pipeline"
),
)
.expected(match context.forge {
crate::detect::Forge::Github => "GH_TOKEN set to the release App's token",
crate::detect::Forge::Gitlab => "GITLAB_TOKEN set from RELEASE_BOT_TOKEN",
})
.target_state("unchanged; nothing was pushed"),
))
}
fn record_comparison(report: &mut RequestReport, comparison: Option<&Comparison>) -> Vec<String> {
let Some(comparison) = comparison else {
return Vec::new();
};
report.compared = Some(comparison.tag.clone());
report.packed = Some(comparison.paths.len());
vec![format!(
"compared the {} files the release packs with {}",
comparison.paths.len(),
comparison.tag
)]
}
fn released(context: &Context, client: &Client, version: Version) -> Result<bool, RkError> {
Ok(client
.release(&context.adapter.tag_of(version))?
.is_some_and(|release| !release.draft))
}
fn decide(
context: &Context,
client: &Client,
target: &Utf8Path,
current: Version,
) -> Result<(Proposal, Option<Comparison>), RkError> {
if current.is_candidate() {
return Ok((guard::propose(current, false, None), None));
}
let was_released = released(context, client, current)?;
let tag = context.adapter.tag_of(current);
let since = if was_released {
Some(tag)
} else {
packed::stable_tag_below(target, Some(&tag))?
};
let (verdict, comparison) = eligible::engine(context, since.as_deref())?;
if !verdict.eligible {
return Ok((Proposal::Nothing(verdict.reason), comparison));
}
let answer = if guard::needs_cliff(current, was_released) {
let paths = comparison
.as_ref()
.map(|compared| compared.history.clone())
.unwrap_or_default();
Some(cliff::bumped(target, &paths)?)
} else {
None
};
let proposal = guard::propose(current, was_released, answer.as_deref());
if let Proposal::Propose(next) = proposal
&& next != current
&& released(context, client, next)?
{
return Ok((
Proposal::Nothing(format!("{} is already released", next.tag())),
comparison,
));
}
Ok((proposal, comparison))
}
fn close_stale(
context: &Context,
client: &Client,
base: &str,
mut report: RequestReport,
reason: String,
) -> Result<Done<RequestReport>, RkError> {
let branch = super::request_branch(base);
let mut lines = vec![format!("nothing to release: {reason}")];
if let Some(number) = client.open_request(base, &branch)? {
client.close_request(number)?;
let held = super::git_ok(
&context.target,
&["ls-remote", "--heads", "origin", &branch],
"an origin remote git can read",
)?;
if !held.trim().is_empty() {
super::push(context, &["--delete", &branch])?;
}
lines.push(format!(
"closed the stale release request #{number} and deleted {branch}"
));
report.state = "closed";
report.request = Some(number);
}
report.reason = Some(reason);
Ok(Done {
report,
lines,
next: Vec::new(),
})
}
fn commit_request(
context: &Context,
client: &Client,
next: Version,
tag: &str,
branch: &str,
paths: &[String],
) -> Result<Vec<String>, RkError> {
let target = &context.target;
let original = super::git(target, &["symbolic-ref", "--short", "-q", "HEAD"])?;
let original = if original.status.success() {
String::from_utf8_lossy(&original.stdout).trim().to_owned()
} else {
context.head()?
};
super::git_ok(
target,
&["checkout", "-q", "-B", branch],
"a fresh request branch from the pushed tip",
)?;
let committed = (|| {
let mut files: Vec<String> = context
.adapter
.write_version(target, next)?
.into_iter()
.map(String::from)
.collect();
cliff::changelog(target, tag, paths)?;
files.push("CHANGELOG.md".to_owned());
let (name, email) = client.identity()?;
let mut add = vec!["add", "--"];
add.extend(files.iter().map(String::as_str));
super::git_ok(target, &add, "the release files staged")?;
let user = format!("user.name={name}");
let mail = format!("user.email={email}");
let message = format!("chore(release): {tag}");
super::git_ok(
target,
&[
"-c",
&user,
"-c",
&mail,
"commit",
"-q",
"--allow-empty",
"-m",
&message,
],
"the release commit",
)?;
let refspec = format!("HEAD:refs/heads/{branch}");
super::push(context, &["--force", &refspec])?;
Ok::<_, RkError>(files)
})();
let restored = super::git_ok(
target,
&["checkout", "-q", "-f", &original],
"the checkout returned to the pushed tip",
);
let files = committed?;
restored?;
Ok(files)
}