Skip to main content

release_kit/release_engine/
request.rs

1//! `rk release request`: keep the one release request of the pushed base.
2//!
3//! It runs on a push to the trunk or to a release line. It reads the
4//! version through the adapter and proposes the next one; where nothing
5//! remains to release it closes a stale request, and otherwise it writes
6//! the version and the changelog on `rk-release/<base>`, force-pushes it as
7//! the bot, opens or edits the request, and arms it where the projection
8//! arms.
9
10use 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
21/// The report's shape version.
22pub const SCHEMA: &str = "rk.release-request/1";
23
24/// The machine form of `rk release request`.
25#[derive(Debug, Serialize)]
26pub struct RequestReport {
27    /// The shape version of this document.
28    pub schema: &'static str,
29    /// `opened`, `refreshed`, `closed`, `nothing`, or `skip`.
30    pub state: &'static str,
31    /// Why nothing was proposed, where nothing was.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub reason: Option<String>,
34    /// The base the request merges into.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub base: Option<String>,
37    /// The version the base carries, in the driver's spelling.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub version: Option<String>,
40    /// The version the request proposes, in the driver's spelling.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub proposal: Option<String>,
43    /// The tag the proposal releases as.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub tag: Option<String>,
46    /// The request's number on the forge.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub request: Option<u64>,
49    /// Whether the request stands armed after this run.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub armed: Option<bool>,
52    /// Every file the request commit changed.
53    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
73/// Keep the release request of the pushed base.
74///
75/// # Errors
76///
77/// A refusal of the default CI token, of git-cliff off its pin, or of a
78/// prerelease proposal; a forge or a push failure.
79pub 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
177/// Whether the forge holds a published release of `version`: a release
178/// that is not a draft. A tag alone is no release, because a failed
179/// release save on GitLab leaves its tag behind, and a GitHub draft is
180/// unpublished. A read that failed refuses, because a release nobody read
181/// may exist.
182fn released(context: &Context, client: &Client, version: Version) -> Result<bool, RkError> {
183    // SATISFIES release-engine:the-request-never-numbers-a-candidate
184    Ok(client
185        .release(&context.adapter.tag_of(version))?
186        .is_some_and(|release| !release.draft))
187}
188
189/// The proposal for the version the base carries.
190fn 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
218/// Close the open request of `base`, where one is open, and delete its
219/// branch: nothing remains for it to release.
220fn 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
253/// Write the version and the changelog on a fresh `branch` from the pushed
254/// tip, commit them as the bot, force-push, and return the checkout to
255/// where it stood.
256fn 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        // SATISFIES bot-identity:the-bot-identity-reaches-one-project
285        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        // SATISFIES release-engine:one-request-per-base
308        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}