Skip to main content

release_kit/
release_state.rs

1//! Where a landed target's release stands: the release request, the last
2//! release, and the symptoms a stuck release shows.
3//!
4//! The judgments are pure and take forge answers as JSON values, so each
5//! rule is testable without a forge. The reads spawn the forge CLI the
6//! setup verbs resolve, `gh api` or `glab api`, and the registry reads
7//! spawn `curl`. A read that fails for any reason other than a definite
8//! not-found answers `unknown`, because a credential this runtime cannot
9//! reach is an observation boundary and never a state of the release.
10//!
11//! SATISFIES release-state:a-forge-field-reads-unknown-without-credentials
12
13use std::path::{Path, PathBuf};
14
15use camino::Utf8Path;
16use serde::Serialize;
17use serde_json::Value;
18
19use crate::detect::Forge;
20use crate::landing::manifest::version_is_newer;
21
22/// One answer a forge or a registry gives: a field the runtime could not
23/// read is `unknown`, and a field the landing makes meaningless is
24/// `not-applicable`.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "kebab-case")]
27pub enum Answer {
28    /// The fact holds.
29    Yes,
30    /// The fact does not hold.
31    No,
32    /// The runtime could not read it.
33    Unknown,
34    /// The landing gives the fact no meaning, such as artifacts on a pair
35    /// that builds none.
36    NotApplicable,
37}
38
39impl Answer {
40    /// The word the JSON and the human report use.
41    #[must_use]
42    pub const fn as_str(self) -> &'static str {
43        match self {
44            Self::Yes => "yes",
45            Self::No => "no",
46            Self::Unknown => "unknown",
47            Self::NotApplicable => "not-applicable",
48        }
49    }
50
51    /// `yes` for true, `no` for false.
52    #[must_use]
53    pub const fn of(value: bool) -> Self {
54        if value { Self::Yes } else { Self::No }
55    }
56}
57
58/// Where a request's checks stand.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60#[serde(rename_all = "kebab-case")]
61pub enum Checks {
62    /// A check is still running, or none has started.
63    Pending,
64    /// Every check concluded without a failure.
65    Passing,
66    /// A check concluded with a failure.
67    Failing,
68    /// The runtime could not read them.
69    Unknown,
70}
71
72impl Checks {
73    /// The word the JSON and the human report use.
74    #[must_use]
75    pub const fn as_str(self) -> &'static str {
76        match self {
77            Self::Pending => "pending",
78            Self::Passing => "passing",
79            Self::Failing => "failing",
80            Self::Unknown => "unknown",
81        }
82    }
83}
84
85/// The open release request, as the forge reports it.
86#[derive(Debug, Clone, Serialize)]
87pub struct Request {
88    /// `none`, `open`, or `unknown`.
89    pub state: &'static str,
90    /// The request's number on the forge.
91    pub number: Option<u64>,
92    /// The version its title proposes.
93    pub proposed_version: Option<String>,
94    /// Whether the request merges itself once its checks pass.
95    pub armed: Answer,
96    /// Where its checks stand.
97    pub checks: Checks,
98    /// Whether the trunk moved past the commit the request was cut from.
99    pub behind_trunk: Answer,
100}
101
102impl Request {
103    /// A request the runtime could not read.
104    #[must_use]
105    pub const fn unknown() -> Self {
106        Self {
107            state: "unknown",
108            number: None,
109            proposed_version: None,
110            armed: Answer::Unknown,
111            checks: Checks::Unknown,
112            behind_trunk: Answer::Unknown,
113        }
114    }
115
116    /// No open release request.
117    #[must_use]
118    pub const fn none() -> Self {
119        Self {
120            state: "none",
121            number: None,
122            proposed_version: None,
123            armed: Answer::NotApplicable,
124            checks: Checks::Unknown,
125            behind_trunk: Answer::NotApplicable,
126        }
127    }
128}
129
130/// The newest release and whether it reached its consumers.
131#[derive(Debug, Clone, Serialize)]
132pub struct LastRelease {
133    /// The newest release tag, absent while none exists.
134    pub tag: Option<String>,
135    /// Whether the registry, or the forge where the pair has no registry,
136    /// serves the version.
137    pub published: Answer,
138    /// Whether the release page carries its artifacts, each attested.
139    pub artifacts_verified: Answer,
140}
141
142/// One symptom a release shows, with the recovery section that owns it.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
144pub struct Symptom {
145    /// The stable code a reader branches on.
146    pub code: &'static str,
147    /// What was observed, one line.
148    pub evidence: String,
149    /// The `rk method recovery` section that owns the path.
150    pub recovery: &'static str,
151}
152
153/// The code for a merged release request that published nothing.
154pub const MERGED_NOTHING_PUBLISHED: &str = "request-merged-nothing-published";
155/// The code for a published version whose release page has no artifact.
156pub const PUBLISHED_NO_ARTIFACTS: &str = "published-no-artifacts";
157/// The code for an artifact no attestation covers.
158pub const ARTIFACT_WITHOUT_ATTESTATION: &str = "artifact-without-attestation";
159
160/// The code for a release tag with no complete release page.
161pub const TAG_WITHOUT_RELEASE: &str = "tag-without-release";
162
163/// The recovery section for a tag whose release page is missing or
164/// incomplete.
165const RECOVERY_TAG_WITHOUT_RELEASE: &str = "The tag exists and the release does not";
166
167/// The recovery section for a merged request that published nothing.
168const RECOVERY_NOTHING_PUBLISHED: &str = "The release request merged and nothing published";
169/// The recovery section for an artifact build that left nothing, or
170/// nothing attested.
171const RECOVERY_ARTIFACT_BUILD: &str = "The artifact build failed";
172
173/// What the landed pair releases through, from the record.
174#[derive(Debug, Clone)]
175pub struct Pair {
176    /// The release driver.
177    pub driver: String,
178    /// The forge.
179    pub forge: Forge,
180    /// The project path on the forge.
181    pub repo: String,
182    /// The trunk branch.
183    pub trunk: String,
184    /// Whether the landing builds artifacts the release page carries.
185    pub artifacts: bool,
186    /// The package name the registry knows, where the pair has one.
187    pub package: Option<String>,
188    /// The recorded `profile.release.registry` answer, absent where the
189    /// target never answered it.
190    pub registry_answer: Option<crate::profile::RegistryAnswer>,
191}
192
193impl Pair {
194    /// The head branch prefix the driver's release request carries.
195    #[must_use]
196    pub fn request_prefix(&self) -> Option<&'static str> {
197        match self.driver.as_str() {
198            "rust" => Some("release-plz-"),
199            "python" => Some("release-please--"),
200            "bash" => Some("chore/release-v"),
201            _ => None,
202        }
203    }
204
205    /// The workflow file that tags and publishes, on GitHub.
206    #[must_use]
207    pub fn publish_workflow(&self) -> Option<&'static str> {
208        match (self.forge, self.driver.as_str()) {
209            (Forge::Github, "rust") => Some("release-plz.yml"),
210            (Forge::Github, "python") => Some("release-please.yml"),
211            (Forge::Github, "bash") => Some("release.yml"),
212            _ => None,
213        }
214    }
215
216    /// The workflow file that builds the artifacts, on GitHub, where the
217    /// landing builds any.
218    #[must_use]
219    pub fn artifact_workflow(&self) -> Option<&'static str> {
220        if !self.artifacts {
221            return None;
222        }
223        match (self.forge, self.driver.as_str()) {
224            (Forge::Github, "rust" | "bash") => Some("release.yml"),
225            _ => None,
226        }
227    }
228
229    /// The registry the driver publishes to, where it has one.
230    #[must_use]
231    pub fn registry(&self) -> Option<Registry> {
232        registry_for(&self.driver, self.registry_answer)
233    }
234}
235
236/// The registry a release publishes to, from its driver and its answer.
237///
238/// The one owner of the mapping from a driver and the recorded
239/// `profile.release.registry` answer, so the release status and the setup
240/// milestones read one registry. A rust release reads crates.io unless it
241/// answered `none`; a python release reads `PyPI`; every other driver
242/// publishes to no registry.
243#[must_use]
244pub fn registry_for(
245    driver: &str,
246    answer: Option<crate::profile::RegistryAnswer>,
247) -> Option<Registry> {
248    match (driver, answer) {
249        ("rust", Some(crate::profile::RegistryAnswer::None)) => None,
250        ("rust", _) => Some(Registry::CratesIo),
251        ("python", _) => Some(Registry::Pypi),
252        _ => None,
253    }
254}
255
256/// A registry with a public read-back.
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub enum Registry {
259    /// crates.io.
260    CratesIo,
261    /// The Python Package Index.
262    Pypi,
263}
264
265impl Registry {
266    /// The public URL that answers 200 for a published version and 404
267    /// for an unpublished one; with no version, for the package itself.
268    #[must_use]
269    pub fn url(self, package: &str, version: Option<&str>) -> String {
270        match (self, version) {
271            (Self::CratesIo, Some(version)) => {
272                format!("https://crates.io/api/v1/crates/{package}/{version}")
273            }
274            (Self::CratesIo, None) => format!("https://crates.io/api/v1/crates/{package}"),
275            (Self::Pypi, Some(version)) => {
276                format!("https://pypi.org/pypi/{package}/{version}/json")
277            }
278            (Self::Pypi, None) => format!("https://pypi.org/pypi/{package}/json"),
279        }
280    }
281}
282
283/// The version a release request's title proposes: its last
284/// whitespace-separated token that reads as a version, with any leading
285/// `v` dropped.
286#[must_use]
287pub fn proposed_version(title: &str) -> Option<String> {
288    title
289        .split_whitespace()
290        .rev()
291        .map(|token| token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '.'))
292        .map(|token| token.strip_prefix('v').unwrap_or(token))
293        .find(|token| is_version(token))
294        .map(str::to_owned)
295}
296
297/// Whether `text` reads as `<major>.<minor>.<patch>`, with an optional
298/// pre-release suffix.
299#[must_use]
300pub fn is_version(text: &str) -> bool {
301    let core = text.split_once('-').map_or(text, |(core, _)| core);
302    let parts: Vec<&str> = core.split('.').collect();
303    parts.len() == 3
304        && parts
305            .iter()
306            .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
307}
308
309/// The newest release among `tags`: a `v<major>.<minor>.<patch>` tag with
310/// no pre-release suffix, by version precedence.
311#[must_use]
312pub fn newest_release<'a>(tags: impl IntoIterator<Item = &'a str>) -> Option<String> {
313    let mut newest: Option<&str> = None;
314    for tag in tags {
315        let Some(version) = tag.strip_prefix('v') else {
316            continue;
317        };
318        if !is_version(version) || version.contains('-') {
319            continue;
320        }
321        if newest.is_none_or(|current| version_is_newer(version, &current[1..])) {
322            newest = Some(tag);
323        }
324    }
325    newest.map(str::to_owned)
326}
327
328/// Where a list of GitHub check runs stands.
329#[must_use]
330pub fn github_checks(body: &Value) -> Checks {
331    let Some(runs) = body["check_runs"].as_array() else {
332        return Checks::Unknown;
333    };
334    if runs.is_empty() {
335        return Checks::Pending;
336    }
337    let failed = runs.iter().any(|run| {
338        matches!(
339            run["conclusion"].as_str(),
340            Some(
341                "failure"
342                    | "cancelled"
343                    | "timed_out"
344                    | "action_required"
345                    | "startup_failure"
346                    | "stale"
347            )
348        )
349    });
350    if failed {
351        return Checks::Failing;
352    }
353    if runs
354        .iter()
355        .any(|run| run["status"].as_str() != Some("completed"))
356    {
357        return Checks::Pending;
358    }
359    Checks::Passing
360}
361
362/// Where a GitLab pipeline status stands.
363#[must_use]
364pub fn gitlab_checks(status: Option<&str>) -> Checks {
365    match status {
366        Some("success") => Checks::Passing,
367        Some("failed" | "canceled" | "canceling") => Checks::Failing,
368        Some(_) | None => Checks::Pending,
369    }
370}
371
372/// Whether a run or a pipeline finished, and how.
373#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
374pub struct Run {
375    /// The forge's id for it.
376    pub id: u64,
377    /// `queued`, `in_progress`, or `completed` on GitHub; the pipeline
378    /// status on GitLab.
379    pub status: String,
380    /// How it concluded, once it did.
381    pub conclusion: Option<String>,
382    /// The branch or tag it ran on.
383    pub head_branch: Option<String>,
384}
385
386impl Run {
387    /// Whether it finished.
388    #[must_use]
389    pub const fn concluded(&self) -> bool {
390        self.conclusion.is_some()
391    }
392
393    /// Whether it finished with success.
394    #[must_use]
395    pub fn succeeded(&self) -> bool {
396        self.conclusion.as_deref() == Some("success")
397    }
398}
399
400/// The run of `workflow` on commit `sha`, selected by the commit and the
401/// push event, never by recency: a workflow that also reports on requests
402/// makes the newest run the wrong one.
403#[must_use]
404pub fn github_run_on(body: &Value, workflow: &str, sha: &str) -> Option<Run> {
405    let suffix = format!("/{workflow}");
406    body["workflow_runs"]
407        .as_array()?
408        .iter()
409        .filter(|run| run["head_sha"].as_str() == Some(sha))
410        .filter(|run| run["event"].as_str() == Some("push"))
411        .filter(|run| {
412            run["path"]
413                .as_str()
414                .is_some_and(|path| path.ends_with(&suffix) || path == workflow)
415        })
416        .map(|run| Run {
417            id: run["id"].as_u64().unwrap_or_default(),
418            status: run["status"].as_str().unwrap_or("unknown").to_owned(),
419            conclusion: (run["status"].as_str() == Some("completed"))
420                .then(|| run["conclusion"].as_str().map(str::to_owned))
421                .flatten(),
422            head_branch: run["head_branch"].as_str().map(str::to_owned),
423        })
424        .min_by_key(|run| run.id)
425}
426
427/// The pipeline on commit `sha` that ran on `reference`.
428#[must_use]
429pub fn gitlab_pipeline_on(body: &Value, reference: impl Fn(&str) -> bool) -> Option<Run> {
430    body.as_array()?
431        .iter()
432        .filter(|pipeline| pipeline["ref"].as_str().is_some_and(&reference))
433        .map(|pipeline| {
434            let status = pipeline["status"].as_str().unwrap_or("unknown").to_owned();
435            let conclusion = matches!(
436                status.as_str(),
437                "success" | "failed" | "canceled" | "skipped"
438            )
439            .then(|| status.clone());
440            Run {
441                id: pipeline["id"].as_u64().unwrap_or_default(),
442                status,
443                conclusion,
444                head_branch: pipeline["ref"].as_str().map(str::to_owned),
445            }
446        })
447        .min_by_key(|run| run.id)
448}
449
450/// What a release page carries.
451#[derive(Debug, Clone, Default, PartialEq, Eq)]
452pub struct Page {
453    /// Whether the page exists.
454    pub exists: bool,
455    /// Every artifact name on it.
456    pub assets: Vec<String>,
457    /// The artifacts no attestation covers.
458    pub unattested: Vec<String>,
459    /// Whether any attestation answer could not be read.
460    pub attestation_unknown: bool,
461}
462
463impl Page {
464    /// Whether the page carries its artifacts, each attested.
465    #[must_use]
466    pub const fn verified(&self) -> Answer {
467        if !self.exists || self.assets.is_empty() || !self.unattested.is_empty() {
468            return Answer::No;
469        }
470        if self.attestation_unknown {
471            return Answer::Unknown;
472        }
473        Answer::Yes
474    }
475}
476
477/// The newest merged release request, and what followed it.
478#[derive(Debug, Clone)]
479pub struct Merged {
480    /// Its number.
481    pub number: u64,
482    /// The version its title proposed.
483    pub version: String,
484    /// Whether the tag for that version exists at the forge.
485    pub tagged: Answer,
486    /// Whether the version is published.
487    pub published: Answer,
488}
489
490/// Every symptom the observed state shows. Pure: the reads gather the
491/// facts, and this rule alone decides what they mean.
492///
493/// SATISFIES release-state:a-symptom-names-its-recovery
494///
495/// A merged request whose version has no tag, or no published version,
496/// published nothing. A published release whose pair builds artifacts and
497/// whose page carries none lost its artifact build. An artifact no
498/// attestation covers was uploaded outside the run that signs.
499#[must_use]
500pub fn symptoms(
501    merged: Option<&Merged>,
502    last: &LastRelease,
503    page: Option<&Page>,
504    artifacts: bool,
505) -> Vec<Symptom> {
506    let mut found = Vec::new();
507    if let Some(merged) = merged {
508        let missing = match (merged.tagged, merged.published) {
509            (Answer::No, _) => Some(format!("no tag v{} exists", merged.version)),
510            (_, Answer::No) => Some(format!("{} is not published", merged.version)),
511            _ => None,
512        };
513        if let Some(missing) = missing {
514            found.push(Symptom {
515                code: MERGED_NOTHING_PUBLISHED,
516                evidence: format!(
517                    "release request #{} proposing {} merged, and {missing}",
518                    merged.number, merged.version
519                ),
520                recovery: RECOVERY_NOTHING_PUBLISHED,
521            });
522        }
523    }
524    if let (true, Some(tag), Answer::Yes, Some(page)) =
525        (artifacts, last.tag.as_deref(), last.published, page)
526    {
527        if page.assets.is_empty() {
528            found.push(Symptom {
529                code: PUBLISHED_NO_ARTIFACTS,
530                evidence: format!("{tag} is published and its release page carries no artifact"),
531                recovery: RECOVERY_ARTIFACT_BUILD,
532            });
533        } else if !page.unattested.is_empty() {
534            found.push(Symptom {
535                code: ARTIFACT_WITHOUT_ATTESTATION,
536                evidence: format!(
537                    "{tag} carries {} with no attestation",
538                    page.unattested.join(", ")
539                ),
540                recovery: RECOVERY_ARTIFACT_BUILD,
541            });
542        }
543    }
544    found
545}
546
547/// SATISFIES release-state:a-symptom-names-its-recovery
548///
549/// Each release tag newer than the newest complete release of its own
550/// line, with no complete page itself. The trunk and each release line
551/// keep their own cutoff: a `v<line>.*` tag belongs to that line, and
552/// every other release tag to the trunk. Each group is walked newest
553/// first and stops at its first complete release, so a tag older than a
554/// complete release of its line drops out, and an accepted gap needs no
555/// record. `complete` answers whether a tag's page is complete; an
556/// unknown answer stops the walk of its line, because the unread page
557/// could be the complete release that retires every older gap.
558pub fn orphan_tags(
559    tags: &[&str],
560    lines: &[&str],
561    mut complete: impl FnMut(&str) -> Answer,
562) -> Vec<Symptom> {
563    let line_of = |version: &str| {
564        lines
565            .iter()
566            .find(|line| version.starts_with(&format!("{line}.")))
567            .copied()
568    };
569    let mut groups: std::collections::BTreeMap<Option<&str>, Vec<&str>> =
570        std::collections::BTreeMap::new();
571    for tag in tags {
572        let Some(version) = tag.strip_prefix('v') else {
573            continue;
574        };
575        if !is_version(version) || version.contains('-') {
576            continue;
577        }
578        groups.entry(line_of(version)).or_default().push(tag);
579    }
580    let mut found = Vec::new();
581    for (line, mut group) in groups {
582        group.sort_by(|left, right| {
583            if version_is_newer(&left[1..], &right[1..]) {
584                std::cmp::Ordering::Less
585            } else if version_is_newer(&right[1..], &left[1..]) {
586                std::cmp::Ordering::Greater
587            } else {
588                std::cmp::Ordering::Equal
589            }
590        });
591        let place = line.map_or_else(|| "the trunk".to_owned(), |line| format!("the line {line}"));
592        for tag in group {
593            // A complete page is the cutoff, and an unread page could be
594            // one, so either stops the walk of the line.
595            if complete(tag) != Answer::No {
596                break;
597            }
598            found.push(Symptom {
599                code: TAG_WITHOUT_RELEASE,
600                evidence: format!(
601                    "{tag} exists on {place} with no complete release page, and no complete release of that line is newer"
602                ),
603                recovery: RECOVERY_TAG_WITHOUT_RELEASE,
604            });
605        }
606    }
607    found
608}
609
610/// One read-only forge API answer.
611#[derive(Debug)]
612pub enum Api {
613    /// The call succeeded and parsed.
614    Ok(Value),
615    /// The forge answered 404.
616    Missing,
617    /// The call failed for another reason, with the CLI's own words.
618    Failed(String),
619}
620
621/// The forge CLI, resolved once.
622#[derive(Debug, Clone)]
623pub struct ForgeCli {
624    cli: PathBuf,
625    cwd: PathBuf,
626}
627
628impl ForgeCli {
629    /// A CLI already resolved, run from `cwd`.
630    #[must_use]
631    pub const fn new(cli: PathBuf, cwd: PathBuf) -> Self {
632        Self { cli, cwd }
633    }
634
635    /// The CLI for `forge`, run from `target`.
636    ///
637    /// # Errors
638    ///
639    /// The resolution's refusal where no usable binary resolves.
640    pub fn resolve(forge: Forge, target: &Utf8Path) -> Result<Self, crate::error::RkError> {
641        Ok(Self {
642            cli: crate::setup::context::resolve_cli(forge)?,
643            cwd: target.as_std_path().to_path_buf(),
644        })
645    }
646
647    /// One read-only `api` call.
648    #[must_use]
649    pub fn api(&self, path: &str) -> Api {
650        let answered = std::process::Command::new(&self.cli)
651            .args(["api", path])
652            .current_dir(&self.cwd)
653            .env("GH_PAGER", "")
654            .env("GLAB_PAGER", "")
655            .env("NO_COLOR", "1")
656            .output();
657        let output = match answered {
658            Ok(output) => output,
659            Err(source) => return Api::Failed(format!("the forge CLI did not run: {source}")),
660        };
661        if output.status.success() {
662            return serde_json::from_slice::<Value>(&output.stdout).map_or_else(
663                |_| Api::Failed("the forge answer did not parse as JSON".to_owned()),
664                Api::Ok,
665            );
666        }
667        // `gh` renders "HTTP 404", `glab` "404 Not Found"; a bare
668        // substring would read an outage message mentioning 404 as one.
669        let stderr = String::from_utf8_lossy(&output.stderr);
670        if stderr.contains("HTTP 404") || stderr.contains("404 Not Found") {
671            return Api::Missing;
672        }
673        Api::Failed(
674            stderr
675                .lines()
676                .rev()
677                .find(|line| !line.trim().is_empty())
678                .unwrap_or("no output")
679                .to_owned(),
680        )
681    }
682}
683
684/// Whether the registry serves `package`, at `version` where one is
685/// named: a 200 answers yes, a 404 answers no, and anything else is
686/// unknown.
687#[must_use]
688pub fn registry_serves(registry: Registry, package: &str, version: Option<&str>) -> Answer {
689    let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
690    let answered = std::process::Command::new(curl)
691        .args([
692            "-sS",
693            "-o",
694            "/dev/null",
695            "-w",
696            "%{http_code}",
697            "--max-time",
698            "20",
699        ])
700        .args(["-A", concat!("release-kit/", env!("CARGO_PKG_VERSION"))])
701        .arg(registry.url(package, version))
702        .output();
703    match answered {
704        Ok(output) => match String::from_utf8_lossy(&output.stdout).trim() {
705            "200" => Answer::Yes,
706            "404" => Answer::No,
707            _ => Answer::Unknown,
708        },
709        Err(_) => Answer::Unknown,
710    }
711}
712
713/// The package name the registry knows, read from the target's own
714/// version file: `[package].name` in `Cargo.toml`, `[project].name` in
715/// `pyproject.toml`.
716#[must_use]
717pub fn package_name(target: &Path, driver: &str) -> Option<String> {
718    let (file, table) = match driver {
719        "rust" => ("Cargo.toml", "package"),
720        "python" => ("pyproject.toml", "project"),
721        _ => return None,
722    };
723    let text = std::fs::read_to_string(target.join(file)).ok()?;
724    let value: toml::Table = toml::from_str(&text).ok()?;
725    value.get(table)?.get("name")?.as_str().map(str::to_owned)
726}
727
728/// The GitLab project id a path names, URL-encoded once.
729#[must_use]
730pub fn gitlab_project(repo: &str) -> String {
731    repo.replace('/', "%2F")
732}
733
734#[cfg(test)]
735mod tests {
736    use serde_json::json;
737
738    use super::{
739        Answer, Checks, LastRelease, Merged, Page, github_checks, github_run_on, gitlab_checks,
740        newest_release, proposed_version, symptoms,
741    };
742
743    #[test]
744    fn a_title_proposes_its_last_version_token() {
745        assert_eq!(
746            proposed_version("chore: release v0.8.7").as_deref(),
747            Some("0.8.7")
748        );
749        assert_eq!(
750            proposed_version("chore(main): release 1.2.3").as_deref(),
751            Some("1.2.3")
752        );
753        assert_eq!(
754            proposed_version("chore(release): v2.0.0-rc.1").as_deref(),
755            Some("2.0.0-rc.1")
756        );
757        assert_eq!(proposed_version("chore: release"), None);
758    }
759
760    #[test]
761    fn the_newest_release_skips_candidates_and_other_tags() {
762        assert_eq!(
763            newest_release(["v0.9.0", "v0.10.0", "v0.11.0-rc.1", "latest", "v1.2"]).as_deref(),
764            Some("v0.10.0")
765        );
766        assert_eq!(newest_release(["nightly"]), None);
767    }
768
769    #[test]
770    fn check_runs_judge_failing_before_pending() {
771        assert_eq!(github_checks(&json!({"check_runs": []})), Checks::Pending);
772        assert_eq!(
773            github_checks(&json!({"check_runs": [
774                {"status": "completed", "conclusion": "success"},
775                {"status": "in_progress", "conclusion": null}
776            ]})),
777            Checks::Pending
778        );
779        assert_eq!(
780            github_checks(&json!({"check_runs": [
781                {"status": "completed", "conclusion": "failure"},
782                {"status": "in_progress", "conclusion": null}
783            ]})),
784            Checks::Failing
785        );
786        assert_eq!(
787            github_checks(&json!({"check_runs": [
788                {"status": "completed", "conclusion": "success"},
789                {"status": "completed", "conclusion": "skipped"}
790            ]})),
791            Checks::Passing
792        );
793        assert_eq!(github_checks(&json!({})), Checks::Unknown);
794        assert_eq!(gitlab_checks(Some("running")), Checks::Pending);
795        assert_eq!(gitlab_checks(Some("failed")), Checks::Failing);
796        assert_eq!(gitlab_checks(Some("success")), Checks::Passing);
797    }
798
799    /// The run is picked by its commit and its push event: a newer run on
800    /// a request, the same workflow, never answers for the commit.
801    #[test]
802    fn a_run_is_selected_by_its_commit_never_by_recency() {
803        let body = json!({"workflow_runs": [
804            {"id": 30, "head_sha": "bbb", "event": "pull_request", "status": "in_progress",
805             "conclusion": null, "path": ".github/workflows/release-plz.yml", "head_branch": "feat/x"},
806            {"id": 20, "head_sha": "aaa", "event": "push", "status": "completed",
807             "conclusion": "success", "path": ".github/workflows/release-plz.yml", "head_branch": "master"},
808            {"id": 21, "head_sha": "aaa", "event": "push", "status": "queued",
809             "conclusion": null, "path": ".github/workflows/release.yml", "head_branch": "v1.0.0"}
810        ]});
811        let publish = github_run_on(&body, "release-plz.yml", "aaa").expect("the publish run");
812        assert_eq!(publish.id, 20);
813        assert!(publish.succeeded());
814        let artifact = github_run_on(&body, "release.yml", "aaa").expect("the artifact run");
815        assert_eq!(artifact.id, 21);
816        assert!(!artifact.concluded());
817        assert_eq!(github_run_on(&body, "release-plz.yml", "bbb"), None);
818    }
819
820    fn last(tag: &str, published: Answer) -> LastRelease {
821        LastRelease {
822            tag: Some(tag.to_owned()),
823            published,
824            artifacts_verified: Answer::Unknown,
825        }
826    }
827
828    #[test]
829    fn each_symptom_has_its_rule() {
830        let merged = Merged {
831            number: 12,
832            version: "1.2.3".into(),
833            tagged: Answer::No,
834            published: Answer::Unknown,
835        };
836        let found = symptoms(Some(&merged), &last("v1.2.2", Answer::Yes), None, false);
837        assert_eq!(found.len(), 1);
838        assert_eq!(found[0].code, super::MERGED_NOTHING_PUBLISHED);
839        assert_eq!(
840            found[0].recovery,
841            "The release request merged and nothing published"
842        );
843        let unpublished = Merged {
844            tagged: Answer::Yes,
845            published: Answer::No,
846            ..merged.clone()
847        };
848        assert_eq!(
849            symptoms(Some(&unpublished), &last("v1.2.3", Answer::No), None, false)[0].code,
850            super::MERGED_NOTHING_PUBLISHED
851        );
852        let shipped = Merged {
853            tagged: Answer::Yes,
854            published: Answer::Yes,
855            ..merged
856        };
857        let empty = Page {
858            exists: true,
859            ..Page::default()
860        };
861        let found = symptoms(
862            Some(&shipped),
863            &last("v1.2.3", Answer::Yes),
864            Some(&empty),
865            true,
866        );
867        assert_eq!(found.len(), 1);
868        assert_eq!(found[0].code, super::PUBLISHED_NO_ARTIFACTS);
869        assert_eq!(found[0].recovery, "The artifact build failed");
870        let unattested = Page {
871            exists: true,
872            assets: vec!["widget.tar.gz".into()],
873            unattested: vec!["widget.tar.gz".into()],
874            attestation_unknown: false,
875        };
876        let found = symptoms(None, &last("v1.2.3", Answer::Yes), Some(&unattested), true);
877        assert_eq!(found[0].code, super::ARTIFACT_WITHOUT_ATTESTATION);
878        assert!(found[0].evidence.contains("widget.tar.gz"));
879        assert!(
880            symptoms(None, &last("v1.2.3", Answer::Yes), Some(&empty), false).is_empty(),
881            "a pair that builds no artifact has no artifact symptom"
882        );
883    }
884
885    #[test]
886    fn the_answer_words_are_the_wire_form() {
887        for (answer, word) in [
888            (Answer::Yes, "yes"),
889            (Answer::No, "no"),
890            (Answer::Unknown, "unknown"),
891            (Answer::NotApplicable, "not-applicable"),
892        ] {
893            assert_eq!(answer.as_str(), word);
894            assert_eq!(
895                serde_json::to_string(&answer).expect("serializes"),
896                format!("\"{word}\"")
897            );
898        }
899        assert_eq!(Answer::of(true), Answer::Yes);
900    }
901}