1use camino::Utf8Path;
11use serde::Serialize;
12
13use crate::diagnostic::{Diagnostic, Reason};
14use crate::error::RkError;
15
16use super::adapter::Version;
17use super::forge::Client;
18use super::guard::{self, Proposal};
19use super::{Context, Done, cliff};
20
21pub const SCHEMA: &str = "rk.release-request/1";
23
24#[derive(Debug, Serialize)]
26pub struct RequestReport {
27 pub schema: &'static str,
29 pub state: &'static str,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub reason: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub base: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub version: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub proposal: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub tag: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub request: Option<u64>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub armed: Option<bool>,
52 pub files: Vec<String>,
54}
55
56impl RequestReport {
57 const fn empty(state: &'static str) -> Self {
58 Self {
59 schema: SCHEMA,
60 state,
61 reason: None,
62 base: None,
63 version: None,
64 proposal: None,
65 tag: None,
66 request: None,
67 armed: None,
68 files: Vec::new(),
69 }
70 }
71}
72
73pub fn run(target: &Utf8Path) -> Result<Done<RequestReport>, RkError> {
80 let context = Context::load(target)?;
81 if let Some(var) = super::default_ci_token(context.forge, |name| std::env::var(name).ok()) {
82 return Err(RkError::refusal(
83 Diagnostic::new(
84 Reason::DefaultCiToken,
85 format!(
86 "the push credential is {var}, the forge's default CI token, and a push with it starts no pipeline"
87 ),
88 )
89 .expected(match context.forge {
90 crate::detect::Forge::Github => "GH_TOKEN set to the release App's token",
91 crate::detect::Forge::Gitlab => "GITLAB_TOKEN set from RELEASE_BOT_TOKEN",
92 })
93 .target_state("unchanged; nothing was pushed"),
94 ));
95 }
96 let Some(base) = context
97 .pushed_branch()
98 .filter(|branch| context.is_base(branch))
99 else {
100 let mut report = RequestReport::empty("skip");
101 report.reason = Some("the push is to no release base".to_owned());
102 return Ok(Done {
103 lines: vec!["skip: the push is to neither the trunk nor a release line".to_owned()],
104 report,
105 next: Vec::new(),
106 });
107 };
108 cliff::require_pin(target)?;
109 let client = Client::resolve(context.forge, target, &context.repo)?;
110 let current = context.adapter.read_version(target)?;
111 let branch = super::request_branch(&base);
112 let proposal = decide(&context, &client, target, current)?;
113 let mut report = RequestReport::empty("nothing");
114 report.base = Some(base.clone());
115 report.version = Some(current.spelled(context.driver()));
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) => return close_stale(&context, &client, &base, report, reason),
126 Proposal::Propose(next) => next,
127 };
128 let tag = context.adapter.tag_of(next);
129 let files = commit_request(&context, &client, next, &tag, &branch)?;
130 let title = format!("chore(release): {tag}");
131 let body = super::forge_text(
132 "blocks/release-request-body.md.in",
133 &[("RK_TAG", &tag), ("RK_BASE", &base)],
134 )?;
135 let (number, state) = match client.open_request(&base, &branch)? {
136 Some(number) => {
137 client.edit_request(number, &title, &body)?;
138 (number, "refreshed")
139 }
140 None => (
141 client.create_request(&base, &branch, &title, &body)?,
142 "opened",
143 ),
144 };
145 let arms = guard::arms(
146 context.forge,
147 context.integration,
148 context.style,
149 &base,
150 &context.trunk,
151 );
152 let armed = arms && client.arm(number, context.merge_form)?;
153 let mut lines = vec![format!(
154 "{state} release request #{number} proposing {} on {branch} into {base}",
155 next.spelled(context.driver())
156 )];
157 lines.push(match (arms, armed) {
158 (true, true) => "armed: the request merges itself once its checks pass".to_owned(),
159 (true, false) => {
160 "arm deferred: the forge cannot take it before the request's first pipeline; the next push re-arms".to_owned()
161 }
162 (false, _) => "not armed: a person merges this request".to_owned(),
163 });
164 report.state = state;
165 report.proposal = Some(next.spelled(context.driver()));
166 report.tag = Some(tag);
167 report.request = Some(number);
168 report.armed = Some(armed);
169 report.files = files;
170 Ok(Done {
171 report,
172 lines,
173 next: Vec::new(),
174 })
175}
176
177fn released(context: &Context, client: &Client, version: Version) -> Result<bool, RkError> {
183 Ok(client
185 .release(&context.adapter.tag_of(version))?
186 .is_some_and(|release| !release.draft))
187}
188
189fn decide(
191 context: &Context,
192 client: &Client,
193 target: &Utf8Path,
194 current: Version,
195) -> Result<Proposal, RkError> {
196 if current.is_candidate() {
197 return Ok(guard::propose(current, false, None));
198 }
199 let was_released = released(context, client, current)?;
200 let answer = if guard::needs_cliff(current, was_released) {
201 Some(cliff::bumped(target)?)
202 } else {
203 None
204 };
205 let proposal = guard::propose(current, was_released, answer.as_deref());
206 if let Proposal::Propose(next) = proposal
207 && next != current
208 && released(context, client, next)?
209 {
210 return Ok(Proposal::Nothing(format!(
211 "{} is already released",
212 next.tag()
213 )));
214 }
215 Ok(proposal)
216}
217
218fn close_stale(
221 context: &Context,
222 client: &Client,
223 base: &str,
224 mut report: RequestReport,
225 reason: String,
226) -> Result<Done<RequestReport>, RkError> {
227 let branch = super::request_branch(base);
228 let mut lines = vec![format!("nothing to release: {reason}")];
229 if let Some(number) = client.open_request(base, &branch)? {
230 client.close_request(number)?;
231 let held = super::git_ok(
232 &context.target,
233 &["ls-remote", "--heads", "origin", &branch],
234 "an origin remote git can read",
235 )?;
236 if !held.trim().is_empty() {
237 super::push(context, &["--delete", &branch])?;
238 }
239 lines.push(format!(
240 "closed the stale release request #{number} and deleted {branch}"
241 ));
242 report.state = "closed";
243 report.request = Some(number);
244 }
245 report.reason = Some(reason);
246 Ok(Done {
247 report,
248 lines,
249 next: Vec::new(),
250 })
251}
252
253fn commit_request(
257 context: &Context,
258 client: &Client,
259 next: Version,
260 tag: &str,
261 branch: &str,
262) -> Result<Vec<String>, RkError> {
263 let target = &context.target;
264 let original = super::git(target, &["symbolic-ref", "--short", "-q", "HEAD"])?;
265 let original = if original.status.success() {
266 String::from_utf8_lossy(&original.stdout).trim().to_owned()
267 } else {
268 context.head()?
269 };
270 super::git_ok(
271 target,
272 &["checkout", "-q", "-B", branch],
273 "a fresh request branch from the pushed tip",
274 )?;
275 let committed = (|| {
276 let mut files: Vec<String> = context
277 .adapter
278 .write_version(target, next)?
279 .into_iter()
280 .map(String::from)
281 .collect();
282 cliff::changelog(target, tag)?;
283 files.push("CHANGELOG.md".to_owned());
284 let (name, email) = client.identity()?;
286 let mut add = vec!["add", "--"];
287 add.extend(files.iter().map(String::as_str));
288 super::git_ok(target, &add, "the release files staged")?;
289 let user = format!("user.name={name}");
290 let mail = format!("user.email={email}");
291 let message = format!("chore(release): {tag}");
292 super::git_ok(
293 target,
294 &[
295 "-c",
296 &user,
297 "-c",
298 &mail,
299 "commit",
300 "-q",
301 "--allow-empty",
302 "-m",
303 &message,
304 ],
305 "the release commit",
306 )?;
307 let refspec = format!("HEAD:refs/heads/{branch}");
309 super::push(context, &["--force", &refspec])?;
310 Ok::<_, RkError>(files)
311 })();
312 let restored = super::git_ok(
313 target,
314 &["checkout", "-q", "-f", &original],
315 "the checkout returned to the pushed tip",
316 );
317 let files = committed?;
318 restored?;
319 Ok(files)
320}