1use camino::Utf8Path;
14use serde::Serialize;
15
16use crate::diagnostic::{Diagnostic, Reason};
17use crate::error::RkError;
18
19use super::adapter::Version;
20use super::forge::Client;
21use super::guard::{self, Proposal};
22use super::packed::{self, Comparison};
23use super::{Context, Done, cliff, eligible};
24
25pub const SCHEMA: &str = "rk.release-request/2";
27
28#[derive(Debug, Serialize)]
30pub struct RequestReport {
31 pub schema: &'static str,
33 pub state: &'static str,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub reason: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub base: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub version: Option<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
47 pub compared: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub packed: Option<usize>,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub proposal: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub tag: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub request: Option<u64>,
60 pub files: Vec<String>,
62}
63
64impl RequestReport {
65 const fn empty(state: &'static str) -> Self {
66 Self {
67 schema: SCHEMA,
68 state,
69 reason: None,
70 base: None,
71 version: None,
72 compared: None,
73 packed: None,
74 proposal: None,
75 tag: None,
76 request: None,
77 files: Vec::new(),
78 }
79 }
80}
81
82pub fn run(target: &Utf8Path) -> Result<Done<RequestReport>, RkError> {
89 let context = Context::load(target)?;
90 refuse_default_token(&context)?;
91 context.require_tools("request")?;
92 let Some(base) = context
93 .pushed_branch()
94 .filter(|branch| context.is_base(branch))
95 else {
96 let mut report = RequestReport::empty("skip");
97 report.reason = Some("the push is to no release base".to_owned());
98 return Ok(Done {
99 lines: vec!["skip: the push is to neither the trunk nor a release line".to_owned()],
100 report,
101 next: Vec::new(),
102 });
103 };
104 cliff::require_pin(target)?;
105 let client = Client::resolve(context.forge, target, &context.repo)?;
106 let current = context.adapter.read_version(target)?;
107 let branch = super::request_branch(&base);
108 let (proposal, comparison) = decide(&context, &client, target, current)?;
109 let mut report = RequestReport::empty("nothing");
110 report.base = Some(base.clone());
111 report.version = Some(current.spelled(context.driver()));
112 let compared = record_comparison(&mut report, comparison.as_ref());
113 let paths = comparison
114 .map(|comparison| comparison.history)
115 .unwrap_or_default();
116 let next = match proposal {
117 Proposal::Refuse(reason) => {
118 return Err(RkError::refusal(
119 Diagnostic::new(Reason::StateDrift, reason)
120 .expected("a final version for the request to propose")
121 .action("rk lines mint numbers a candidate; the request proposes final versions alone")
122 .target_state("unchanged"),
123 ));
124 }
125 Proposal::Nothing(reason) => {
126 let mut done = close_stale(&context, &client, &base, report, reason)?;
127 done.lines.splice(0..0, compared);
128 return Ok(done);
129 }
130 Proposal::Propose(next) => next,
131 };
132 let tag = context.adapter.tag_of(next);
133 let files = commit_request(&context, &client, next, &tag, &branch, &paths)?;
134 let title = format!("chore(release): {tag}");
135 let body = super::forge_text(
136 "blocks/release-request-body.md.in",
137 &[("RK_TAG", &tag), ("RK_BASE", &base)],
138 )?;
139 let (number, state) = match client.open_request(&base, &branch)? {
140 Some(number) => {
141 client.edit_request(number, &title, &body)?;
142 (number, "refreshed")
143 }
144 None => (
145 client.create_request(&base, &branch, &title, &body)?,
146 "opened",
147 ),
148 };
149 let mut lines = compared;
151 lines.push(format!(
152 "{state} release request #{number} proposing {} on {branch} into {base}",
153 next.spelled(context.driver())
154 ));
155 lines.push("not armed: the request waits for the operator's order to merge".to_owned());
156 report.state = state;
157 report.proposal = Some(next.spelled(context.driver()));
158 report.tag = Some(tag);
159 report.request = Some(number);
160 report.files = files;
161 Ok(Done {
162 report,
163 lines,
164 next: Vec::new(),
165 })
166}
167
168fn refuse_default_token(context: &Context) -> Result<(), RkError> {
170 let Some(var) = super::default_ci_token(context.forge, |name| std::env::var(name).ok()) else {
171 return Ok(());
172 };
173 Err(RkError::refusal(
174 Diagnostic::new(
175 Reason::DefaultCiToken,
176 format!(
177 "the push credential is {var}, the forge's default CI token, and a push with it starts no pipeline"
178 ),
179 )
180 .expected(match context.forge {
181 crate::detect::Forge::Github => "GH_TOKEN set to the release App's token",
182 crate::detect::Forge::Gitlab => "GITLAB_TOKEN set from RELEASE_BOT_TOKEN",
183 })
184 .target_state("unchanged; nothing was pushed"),
185 ))
186}
187
188fn record_comparison(report: &mut RequestReport, comparison: Option<&Comparison>) -> Vec<String> {
191 let Some(comparison) = comparison else {
192 return Vec::new();
193 };
194 report.compared = Some(comparison.tag.clone());
195 report.packed = Some(comparison.paths.len());
196 vec![format!(
197 "compared the {} files the release packs with {}",
198 comparison.paths.len(),
199 comparison.tag
200 )]
201}
202
203fn released(context: &Context, client: &Client, version: Version) -> Result<bool, RkError> {
209 Ok(client
211 .release(&context.adapter.tag_of(version))?
212 .is_some_and(|release| !release.draft))
213}
214
215fn decide(
231 context: &Context,
232 client: &Client,
233 target: &Utf8Path,
234 current: Version,
235) -> Result<(Proposal, Option<Comparison>), RkError> {
236 if current.is_candidate() {
237 return Ok((guard::propose(current, false, None), None));
238 }
239 let was_released = released(context, client, current)?;
240 let tag = context.adapter.tag_of(current);
241 let since = if was_released {
245 Some(tag)
246 } else {
247 packed::stable_tag_below(target, Some(&tag))?
248 };
249 let (verdict, comparison) = eligible::engine(context, since.as_deref())?;
250 if !verdict.eligible {
251 return Ok((Proposal::Nothing(verdict.reason), comparison));
252 }
253 let answer = if guard::needs_cliff(current, was_released) {
254 let paths = comparison
255 .as_ref()
256 .map(|compared| compared.history.clone())
257 .unwrap_or_default();
258 Some(cliff::bumped(target, &paths)?)
259 } else {
260 None
261 };
262 let proposal = guard::propose(current, was_released, answer.as_deref());
263 if let Proposal::Propose(next) = proposal
264 && next != current
265 && released(context, client, next)?
266 {
267 return Ok((
268 Proposal::Nothing(format!("{} is already released", next.tag())),
269 comparison,
270 ));
271 }
272 Ok((proposal, comparison))
273}
274
275fn close_stale(
278 context: &Context,
279 client: &Client,
280 base: &str,
281 mut report: RequestReport,
282 reason: String,
283) -> Result<Done<RequestReport>, RkError> {
284 let branch = super::request_branch(base);
285 let mut lines = vec![format!("nothing to release: {reason}")];
286 if let Some(number) = client.open_request(base, &branch)? {
287 client.close_request(number)?;
288 let held = super::git_ok(
289 &context.target,
290 &["ls-remote", "--heads", "origin", &branch],
291 "an origin remote git can read",
292 )?;
293 if !held.trim().is_empty() {
294 super::push(context, &["--delete", &branch])?;
295 }
296 lines.push(format!(
297 "closed the stale release request #{number} and deleted {branch}"
298 ));
299 report.state = "closed";
300 report.request = Some(number);
301 }
302 report.reason = Some(reason);
303 Ok(Done {
304 report,
305 lines,
306 next: Vec::new(),
307 })
308}
309
310fn commit_request(
314 context: &Context,
315 client: &Client,
316 next: Version,
317 tag: &str,
318 branch: &str,
319 paths: &[String],
320) -> Result<Vec<String>, RkError> {
321 let target = &context.target;
322 let original = super::git(target, &["symbolic-ref", "--short", "-q", "HEAD"])?;
323 let original = if original.status.success() {
324 String::from_utf8_lossy(&original.stdout).trim().to_owned()
325 } else {
326 context.head()?
327 };
328 super::git_ok(
329 target,
330 &["checkout", "-q", "-B", branch],
331 "a fresh request branch from the pushed tip",
332 )?;
333 let committed = (|| {
334 let mut files: Vec<String> = context
335 .adapter
336 .write_version(target, next)?
337 .into_iter()
338 .map(String::from)
339 .collect();
340 cliff::changelog(target, tag, paths)?;
341 files.push("CHANGELOG.md".to_owned());
342 let (name, email) = client.identity()?;
344 let mut add = vec!["add", "--"];
345 add.extend(files.iter().map(String::as_str));
346 super::git_ok(target, &add, "the release files staged")?;
347 let user = format!("user.name={name}");
348 let mail = format!("user.email={email}");
349 let message = format!("chore(release): {tag}");
350 super::git_ok(
351 target,
352 &[
353 "-c",
354 &user,
355 "-c",
356 &mail,
357 "commit",
358 "-q",
359 "--allow-empty",
360 "-m",
361 &message,
362 ],
363 "the release commit",
364 )?;
365 let refspec = format!("HEAD:refs/heads/{branch}");
367 super::push(context, &["--force", &refspec])?;
368 Ok::<_, RkError>(files)
369 })();
370 let restored = super::git_ok(
371 target,
372 &["checkout", "-q", "-f", &original],
373 "the checkout returned to the pushed tip",
374 );
375 let files = committed?;
376 restored?;
377 Ok(files)
378}