Skip to main content

release_kit/commands/
release.rs

1//! `rk release status`: where a landed target's release stands.
2//!
3//! The record answers the style, the integration authority, the checkout
4//! mode, and the trunk, offline. The forge answers the release request,
5//! the last release, and the runs, and a field the runtime cannot read
6//! reads `unknown`: the verb never asks for a credential so that a field
7//! can read. The registry answers whether a version is published. The
8//! judgments live in [`crate::release_state`]; this handler reads, joins,
9//! and renders.
10//!
11//! SATISFIES release-state:the-report-joins-the-record-and-the-forge
12
13use std::time::{Duration, Instant};
14
15use camino::Utf8Path;
16use serde::Serialize;
17use serde_json::Value;
18
19use crate::cli::release::{ReleaseAction, ReleaseArgs};
20use crate::commands::lines::{self, LineRow};
21use crate::detect::Forge;
22use crate::diagnostic::{Diagnostic, Reason};
23use crate::error::RkError;
24use crate::landing::manifest::{self, CheckoutMode, Integration, Manifest, Style};
25use crate::output::Output;
26use crate::profile::ReleaseMode;
27use crate::release_engine;
28use crate::release_state::{
29    self, Answer, Api, ForgeCli, LastRelease, Merged, Page, Pair, Request, Run, Symptom,
30};
31
32/// The tags one forge page holds.
33const TAG_PAGE: usize = 100;
34
35/// The most tag pages a read follows before it reports the list unread.
36const TAG_PAGES: usize = 50;
37
38/// The report's shape version.
39const SCHEMA: &str = "rk.release-status/1";
40
41/// How long `--wait` sleeps between two reads, unless the environment
42/// names another interval in milliseconds.
43const WAIT_INTERVAL_MS: u64 = 15_000;
44
45/// The variable that overrides the wait interval.
46const WAIT_INTERVAL_VAR: &str = "RK_RELEASE_WAIT_INTERVAL_MS";
47
48/// Whether the forge could be read, and why not.
49#[derive(Debug, Serialize)]
50struct ForgeState {
51    /// The recorded forge.
52    name: Option<String>,
53    /// The recorded project path.
54    repo: Option<String>,
55    /// `yes`, `unknown`, or `not-applicable`.
56    read: Answer,
57    /// Why the forge was not read, where it was not.
58    reason: Option<String>,
59}
60
61/// What `--wait` found on the commit.
62#[derive(Debug, Serialize)]
63struct Wait {
64    /// The commit waited on.
65    sha: String,
66    /// The run that tags and publishes.
67    publish: Option<Run>,
68    /// The run that builds the artifacts, where the pair builds any.
69    artifact: Option<Run>,
70    /// The release tag that names the commit, once one does.
71    tag: Option<String>,
72    /// Whether the timeout ended the wait before both runs concluded.
73    timed_out: bool,
74}
75
76/// The machine form of `rk release status`.
77#[derive(Debug, Serialize)]
78struct Report {
79    /// The shape version of this document.
80    schema: &'static str,
81    /// Whether a landing record exists; every other field needs one.
82    landed: bool,
83    /// The recorded release style.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    style: Option<Style>,
86    /// The recorded integration authority.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    integration: Option<Integration>,
89    /// The recorded checkout mode.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    checkout_mode: Option<CheckoutMode>,
92    /// The recorded trunk.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    trunk: Option<String>,
95    /// Whether the forge could be read.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    forge: Option<ForgeState>,
98    /// The open release request.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    request: Option<Request>,
101    /// The newest release.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    last_release: Option<LastRelease>,
104    /// Every release line, as `rk lines list` reports it.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    lines: Option<Vec<LineRow>>,
107    /// Every symptom the state shows.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    symptoms: Option<Vec<Symptom>>,
110    /// What `--wait` found, where it ran.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    wait: Option<Wait>,
113}
114
115/// Route one `rk release` invocation.
116///
117/// # Errors
118///
119/// Each verb's own failures; every failure is typed.
120pub fn run(args: &ReleaseArgs) -> Result<(), RkError> {
121    match &args.action {
122        ReleaseAction::Status {
123            target,
124            wait,
125            timeout,
126            json,
127        } => status(target, wait.as_deref(), *timeout, Output::new(*json)),
128        ReleaseAction::Request { target, json } => {
129            render_done(&release_engine::request::run(target)?, Output::new(*json))
130        }
131        ReleaseAction::Build { target, json } => {
132            render_done(&release_engine::build::run(target)?, Output::new(*json))
133        }
134        ReleaseAction::Publish { target, json } => {
135            render_done(&release_engine::publish::run(target)?, Output::new(*json))
136        }
137    }
138}
139
140/// Render one engine action's report: the human lines and the next steps,
141/// or the JSON document under `--json`.
142fn render_done<R: Serialize>(done: &release_engine::Done<R>, out: Output) -> Result<(), RkError> {
143    for line in &done.lines {
144        out.result_line(line);
145    }
146    if !done.next.is_empty() {
147        out.next(&done.next);
148    }
149    out.emit(&done.report)
150}
151
152/// Everything the forge answered, gathered once.
153struct Observed {
154    request: Request,
155    last: LastRelease,
156    symptoms: Vec<Symptom>,
157    wait: Option<Wait>,
158}
159
160#[allow(
161    clippy::too_many_lines,
162    reason = "one pass reads the record, the lines, and the forge, and renders the one report"
163)]
164fn status(target: &Utf8Path, wait: Option<&str>, timeout: u64, out: Output) -> Result<(), RkError> {
165    if !target.is_dir() {
166        return Err(RkError::missing(
167            Diagnostic::new(
168                Reason::TargetNotFound,
169                format!("target {target} is not a directory"),
170            )
171            .expected("an existing repository to report on"),
172        ));
173    }
174    let Some(manifest) = manifest::load(target)? else {
175        out.result_line(format!("no landing at {target}"));
176        out.next(&[format!(
177            "rk status --target {target} classifies the target; the rk-setup skill lands the convention"
178        )]);
179        return out.emit(&Report {
180            schema: SCHEMA,
181            landed: false,
182            style: None,
183            integration: None,
184            checkout_mode: None,
185            trunk: None,
186            forge: None,
187            request: None,
188            last_release: None,
189            lines: None,
190            symptoms: None,
191            wait: None,
192        });
193    };
194    let lines = lines::rows(target)?;
195    let local = local_release_tags(target);
196    let pair = pair_of(target, &manifest);
197    let (forge, observed) = match &pair {
198        Err(reason) => (
199            ForgeState {
200                name: manifest.profile.forge.clone(),
201                repo: (!manifest.parameters.repo.is_empty())
202                    .then(|| manifest.parameters.repo.clone()),
203                read: Answer::NotApplicable,
204                reason: Some(reason.clone()),
205            },
206            Observed {
207                request: Request::none(),
208                last: LastRelease {
209                    tag: newest_local(&local),
210                    published: Answer::NotApplicable,
211                    artifacts_verified: Answer::NotApplicable,
212                },
213                symptoms: Vec::new(),
214                wait: None,
215            },
216        ),
217        Ok(pair) => match ForgeCli::resolve(pair.forge, target) {
218            Err(error) => (
219                ForgeState {
220                    name: Some(pair.forge.as_str().to_owned()),
221                    repo: Some(pair.repo.clone()),
222                    read: Answer::Unknown,
223                    reason: Some(error.to_string()),
224                },
225                Observed {
226                    request: Request::unknown(),
227                    last: LastRelease {
228                        tag: newest_local(&local),
229                        published: Answer::Unknown,
230                        artifacts_verified: if pair.artifacts {
231                            Answer::Unknown
232                        } else {
233                            Answer::NotApplicable
234                        },
235                    },
236                    symptoms: Vec::new(),
237                    wait: None,
238                },
239            ),
240            Ok(cli) => {
241                let mut reader = Reader {
242                    cli: &cli,
243                    pair,
244                    read: Answer::Yes,
245                    failures: std::cell::RefCell::new(Vec::new()),
246                };
247                let line_names: Vec<&str> = lines.iter().map(|row| row.line.as_str()).collect();
248                let observed = reader.observe(&local, wait, timeout, &line_names);
249                (
250                    ForgeState {
251                        name: Some(pair.forge.as_str().to_owned()),
252                        repo: Some(pair.repo.clone()),
253                        read: reader.read,
254                        reason: reader.reason(),
255                    },
256                    observed,
257                )
258            }
259        },
260    };
261    if wait.is_some() && observed.wait.is_none() {
262        return Err(RkError::refusal(
263            Diagnostic::new(
264                Reason::ObservationIncomplete,
265                format!(
266                    "--wait needs the forge's runs, and this runtime cannot read them: {}",
267                    forge
268                        .reason
269                        .as_deref()
270                        .unwrap_or("the forge did not answer")
271                ),
272            )
273            .expected("a forge CLI that can read this project's runs")
274            .action("rerun where the forge CLI is authenticated for this project")
275            .target_state("unchanged"),
276        ));
277    }
278    render(out, &manifest, &forge, &observed, &lines);
279    let failure = observed.wait.as_ref().and_then(wait_failure);
280    out.emit(&Report {
281        schema: SCHEMA,
282        landed: true,
283        style: manifest.profile.release.style,
284        integration: Some(manifest.git.integration),
285        checkout_mode: Some(manifest.git.checkout_mode),
286        trunk: Some(manifest.git.trunk),
287        forge: Some(forge),
288        request: Some(observed.request),
289        last_release: Some(observed.last),
290        lines: Some(lines),
291        symptoms: Some(observed.symptoms),
292        wait: observed.wait,
293    })?;
294    failure.map_or(Ok(()), Err)
295}
296
297/// The pair the forge reads need, or why the forge is not read at all.
298fn pair_of(target: &Utf8Path, manifest: &Manifest) -> Result<Pair, String> {
299    if manifest.profile.release.mode != ReleaseMode::Automatic {
300        return Err("the record selects no automated release".to_owned());
301    }
302    let driver = manifest
303        .profile
304        .release
305        .driver
306        .clone()
307        .ok_or_else(|| "the record names no release driver".to_owned())?;
308    let forge = manifest
309        .profile
310        .forge
311        .as_deref()
312        .and_then(Forge::parse)
313        .ok_or_else(|| "the record names no forge this binary reads".to_owned())?;
314    if manifest.parameters.repo.is_empty() {
315        return Err("the record names no forge repository".to_owned());
316    }
317    let artifacts = match (forge, driver.as_str()) {
318        (Forge::Github, "rust") => manifest.file("dist-workspace.toml").is_some(),
319        (Forge::Github | Forge::Gitlab, "bash" | "python") => true,
320        _ => false,
321    };
322    Ok(Pair {
323        package: crate::release_engine::adapter::package_name(target.as_std_path(), &driver),
324        registry_answer: manifest.profile.release.registry,
325        driver,
326        forge,
327        repo: manifest.parameters.repo.clone(),
328        trunk: manifest.git.trunk.clone(),
329        line_prefix: manifest
330            .profile
331            .release
332            .line_prefix
333            .clone()
334            .unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
335        artifacts,
336    })
337}
338
339/// Every release tag the clone holds, `v<version>` with a candidate
340/// included, with the commit it names, read offline. An annotated tag
341/// names the commit it points to, as the forge's tag list does. A clone
342/// whose tags cannot be read holds none here, so nothing is compared.
343fn local_release_tags(target: &Utf8Path) -> Vec<(String, String)> {
344    let mut command = std::process::Command::new(crate::probes::git_bin());
345    for var in crate::maintenance::GIT_HOOK_VARS {
346        command.env_remove(var);
347    }
348    let Ok(output) = command
349        .arg("-C")
350        .arg(target.as_std_path())
351        .args([
352            "for-each-ref",
353            "--format=%(refname:strip=2) %(objectname) %(*objectname)",
354            "refs/tags/v*",
355        ])
356        .output()
357    else {
358        return Vec::new();
359    };
360    if !output.status.success() {
361        return Vec::new();
362    }
363    String::from_utf8_lossy(&output.stdout)
364        .lines()
365        .filter_map(|line| {
366            let mut fields = line.split_whitespace();
367            let tag = fields.next()?;
368            let object = fields.next()?;
369            let commit = fields.next().unwrap_or(object);
370            tag.strip_prefix('v')
371                .is_some_and(release_state::is_version)
372                .then(|| (tag.to_owned(), commit.to_owned()))
373        })
374        .collect()
375}
376
377/// Each tag paired with its commit, borrowed.
378fn borrowed(tags: &[(String, String)]) -> Vec<(&str, &str)> {
379    tags.iter()
380        .map(|(tag, commit)| (tag.as_str(), commit.as_str()))
381        .collect()
382}
383
384/// The newest release among the clone's tags.
385fn newest_local(local: &[(String, String)]) -> Option<String> {
386    release_state::newest_release(local.iter().map(|(tag, _)| tag.as_str()))
387}
388
389/// The run that failed a wait, as the error the verb exits with after it
390/// rendered the report; a timeout refuses, because nothing was judged.
391fn wait_failure(wait: &Wait) -> Option<RkError> {
392    if wait.timed_out {
393        return Some(RkError::refusal(
394            Diagnostic::new(
395                Reason::ObservationIncomplete,
396                format!(
397                    "the runs on {} did not conclude before the timeout",
398                    wait.sha
399                ),
400            )
401            .expected("the publish run, and the artifact run where the pair builds one, concluded")
402            .action("rerun with a longer --timeout, or read the runs on the forge")
403            .target_state("unchanged"),
404        ));
405    }
406    let failed = [&wait.publish, &wait.artifact]
407        .into_iter()
408        .flatten()
409        .find(|run| run.concluded() && !run.succeeded())?;
410    Some(RkError::check_failed(
411        Diagnostic::new(
412            Reason::StateDrift,
413            format!(
414                "run {} on {} concluded {}",
415                failed.id,
416                wait.sha,
417                failed.conclusion.as_deref().unwrap_or("without success")
418            ),
419        )
420        .expected("every run on the release commit concluded success")
421        .action("rk method recovery names the path for the symptom this report shows"),
422    ))
423}
424
425/// The forge reads for one pair, with what they could not read.
426struct Reader<'a> {
427    cli: &'a ForgeCli,
428    pair: &'a Pair,
429    read: Answer,
430    failures: std::cell::RefCell<Vec<String>>,
431}
432
433impl Reader<'_> {
434    fn reason(&self) -> Option<String> {
435        self.failures.borrow().first().cloned()
436    }
437
438    /// One call; a failure is remembered so the report says why a field
439    /// reads unknown.
440    fn api(&self, path: &str) -> Option<Value> {
441        match self.cli.api(path) {
442            Api::Ok(body) => Some(body),
443            Api::Missing => None,
444            Api::Failed(reason) => {
445                self.failures.borrow_mut().push(reason);
446                None
447            }
448        }
449    }
450
451    /// One call that tells a definite not-found apart from a failure.
452    fn api_answer(&self, path: &str) -> Api {
453        let answer = self.cli.api(path);
454        if let Api::Failed(reason) = &answer {
455            self.failures.borrow_mut().push(reason.clone());
456        }
457        answer
458    }
459
460    fn project(&self) -> String {
461        release_state::gitlab_project(&self.pair.repo)
462    }
463
464    fn observe(
465        &mut self,
466        local: &[(String, String)],
467        wait: Option<&str>,
468        timeout: u64,
469        lines: &[&str],
470    ) -> Observed {
471        let request = self.open_request();
472        let mut wait = wait.map(|sha| self.wait(sha, timeout));
473        // Read after the wait: a trunk run tags its own commit while it
474        // runs, so a list read before it cannot hold the tag it made.
475        let tags = self.tags();
476        if let Some(wait) = wait.as_mut().filter(|wait| wait.tag.is_none()) {
477            wait.tag = tags.as_ref().and_then(|tags| {
478                tags.iter()
479                    .find(|(name, commit)| *commit == wait.sha && name.starts_with('v'))
480                    .map(|(name, _)| name.clone())
481            });
482        }
483        // An unread tag list is no empty one: without it, the absence
484        // of a release is not observed either.
485        let unread = tags.is_none();
486        // A wait reports the release its commit made and no other: a
487        // commit that made none never borrows an older release's state.
488        let tag = wait.as_ref().map_or_else(
489            || {
490                tags.as_ref().map_or_else(
491                    || newest_local(local),
492                    |tags| {
493                        release_state::newest_release(tags.iter().map(|(name, _)| name.as_str()))
494                    },
495                )
496            },
497            |wait| wait.tag.clone(),
498        );
499        // A tag the clone supplies because the forge refused its list is
500        // no observed release: its fields stay unknown, as they do where
501        // the forge CLI is missing.
502        let from_clone = unread && wait.is_none();
503        let page = if from_clone {
504            None
505        } else {
506            tag.as_deref().and_then(|tag| self.page(tag))
507        };
508        let last = LastRelease {
509            published: if from_clone {
510                Answer::Unknown
511            } else {
512                tag.as_deref().map_or(
513                    if unread {
514                        Answer::Unknown
515                    } else {
516                        Answer::NotApplicable
517                    },
518                    |tag| self.published(tag, page.as_ref()),
519                )
520            },
521            artifacts_verified: if !self.pair.artifacts {
522                Answer::NotApplicable
523            } else if from_clone {
524                Answer::Unknown
525            } else if tag.is_none() {
526                if unread { Answer::Unknown } else { Answer::No }
527            } else {
528                page.as_ref().map_or(Answer::Unknown, Page::verified)
529            },
530            tag,
531        };
532        let merged = self.merged().map(|(number, version)| {
533            let name = format!("v{version}");
534            let tagged = tags.as_ref().map_or(Answer::Unknown, |tags| {
535                Answer::of(tags.iter().any(|(tag, _)| *tag == name))
536            });
537            let published = if last.tag.as_deref() == Some(name.as_str()) {
538                last.published
539            } else if tagged == Answer::No && self.pair.registry().is_none() {
540                Answer::No
541            } else {
542                self.published(&name, None)
543            };
544            Merged {
545                number,
546                version,
547                tagged,
548                published,
549            }
550        });
551        let mut symptoms =
552            release_state::symptoms(merged.as_ref(), &last, page.as_ref(), self.pair.artifacts);
553        if let Some(tags) = tags.as_ref() {
554            let names: Vec<&str> = tags.iter().map(|(name, _)| name.as_str()).collect();
555            symptoms.extend(release_state::orphan_tags(&names, lines, |tag| {
556                // The last release's page is already read; every other
557                // page is read once, newest first, until a complete one.
558                let read = if last.tag.as_deref() == Some(tag) {
559                    page.clone()
560                } else {
561                    self.page(tag)
562                };
563                read.map_or(Answer::Unknown, |page| self.complete(&page))
564            }));
565            symptoms.extend(release_state::local_tag_drift(
566                &borrowed(local),
567                &borrowed(tags),
568            ));
569        }
570        if !self.failures.borrow().is_empty() {
571            self.read = Answer::Unknown;
572        }
573        Observed {
574            request,
575            last,
576            symptoms,
577            wait,
578        }
579    }
580
581    /// Whether a release page is complete: one with artifacts for a pair
582    /// that builds them, one that exists for any other.
583    const fn complete(&self, page: &Page) -> Answer {
584        Answer::of(page.exists && (!self.pair.artifacts || !page.assets.is_empty()))
585    }
586
587    /// Whether the version a tag names is published: the registry's
588    /// answer where the pair has one, the release page's otherwise.
589    fn published(&self, tag: &str, page: Option<&Page>) -> Answer {
590        // The registry knows the version in the driver's own spelling: a
591        // python candidate tagged v1.4.3-rc.2 is 1.4.3rc2 on PyPI.
592        let version = crate::release_engine::adapter::Version::from_tag(tag).map_or_else(
593            || tag.strip_prefix('v').unwrap_or(tag).to_owned(),
594            |version| version.spelled(&self.pair.driver),
595        );
596        match (self.pair.registry(), self.pair.package.as_deref()) {
597            (Some(registry), Some(package)) => {
598                release_state::registry_serves(registry, package, Some(&version))
599            }
600            (Some(_), None) => Answer::Unknown,
601            (None, _) => page
602                .cloned()
603                .or_else(|| self.page(tag))
604                .map_or(Answer::Unknown, |page| Answer::of(page.exists)),
605        }
606    }
607
608    fn open_request(&self) -> Request {
609        let Some(prefix) = self.pair.request_prefix() else {
610            return Request::none();
611        };
612        match self.pair.forge {
613            Forge::Github => self.github_request(prefix),
614            Forge::Gitlab => self.gitlab_request(prefix),
615        }
616    }
617
618    fn github_request(&self, prefix: &str) -> Request {
619        let repo = &self.pair.repo;
620        let Some(body) = self.api(&format!(
621            "repos/{repo}/pulls?state=open&base={}&per_page=100",
622            self.pair.trunk
623        )) else {
624            return Request::unknown();
625        };
626        let Some(request) = body.as_array().and_then(|requests| {
627            requests.iter().find(|request| {
628                request["head"]["ref"]
629                    .as_str()
630                    .is_some_and(|head| head.starts_with(prefix))
631            })
632        }) else {
633            return Request::none();
634        };
635        let sha = request["head"]["sha"].as_str().unwrap_or_default();
636        let checks = self
637            .api(&format!(
638                "repos/{repo}/commits/{sha}/check-runs?per_page=100"
639            ))
640            .map_or(release_state::Checks::Unknown, |body| {
641                release_state::github_checks(&body)
642            });
643        let behind_trunk = self
644            .api(&format!("repos/{repo}/compare/{}...{sha}", self.pair.trunk))
645            .and_then(|body| body["behind_by"].as_u64())
646            .map_or(Answer::Unknown, |behind| Answer::of(behind > 0));
647        Request {
648            state: "open",
649            number: request["number"].as_u64(),
650            proposed_version: request["title"]
651                .as_str()
652                .and_then(release_state::proposed_version),
653            armed: Answer::of(!request["auto_merge"].is_null()),
654            checks,
655            behind_trunk,
656        }
657    }
658
659    fn gitlab_request(&self, prefix: &str) -> Request {
660        let project = self.project();
661        let Some(body) = self.api(&format!(
662            "projects/{project}/merge_requests?state=opened&target_branch={}&per_page=100",
663            self.pair.trunk
664        )) else {
665            return Request::unknown();
666        };
667        let Some(request) = body.as_array().and_then(|requests| {
668            requests.iter().find(|request| {
669                request["source_branch"]
670                    .as_str()
671                    .is_some_and(|head| head.starts_with(prefix))
672            })
673        }) else {
674            return Request::none();
675        };
676        let iid = request["iid"].as_u64();
677        let detail = iid.and_then(|iid| {
678            self.api(&format!(
679                "projects/{project}/merge_requests/{iid}?include_diverged_commits_count=true"
680            ))
681        });
682        let armed = request["merge_when_pipeline_succeeds"]
683            .as_bool()
684            .or_else(|| request["auto_merge_enabled"].as_bool())
685            .map_or(Answer::Unknown, Answer::of);
686        Request {
687            state: "open",
688            number: iid,
689            proposed_version: request["title"]
690                .as_str()
691                .and_then(release_state::proposed_version),
692            armed,
693            checks: detail
694                .as_ref()
695                .map_or(release_state::Checks::Unknown, |detail| {
696                    release_state::gitlab_checks(detail["head_pipeline"]["status"].as_str())
697                }),
698            behind_trunk: detail
699                .as_ref()
700                .and_then(|detail| detail["diverged_commits_count"].as_u64())
701                .map_or(Answer::Unknown, |behind| Answer::of(behind > 0)),
702        }
703    }
704
705    /// The newest merged release request: its number and the version its
706    /// title proposed.
707    fn merged(&self) -> Option<(u64, String)> {
708        let prefix = self.pair.request_prefix()?;
709        let (path, head, merged_at, number) = match self.pair.forge {
710            Forge::Github => (
711                format!(
712                    "repos/{}/pulls?state=closed&base={}&sort=updated&direction=desc&per_page=30",
713                    self.pair.repo, self.pair.trunk
714                ),
715                "/head/ref",
716                "/merged_at",
717                "/number",
718            ),
719            Forge::Gitlab => (
720                format!(
721                    "projects/{}/merge_requests?state=merged&target_branch={}&order_by=updated_at&sort=desc&per_page=30",
722                    self.project(),
723                    self.pair.trunk
724                ),
725                "/source_branch",
726                "/merged_at",
727                "/iid",
728            ),
729        };
730        let body = self.api(&path)?;
731        body.as_array()?
732            .iter()
733            .filter(|request| {
734                request
735                    .pointer(head)
736                    .and_then(Value::as_str)
737                    .is_some_and(|head| head.starts_with(prefix))
738            })
739            .filter_map(|request| {
740                let at = request.pointer(merged_at)?.as_str()?.to_owned();
741                let version = release_state::proposed_version(request["title"].as_str()?)?;
742                Some((at, request.pointer(number)?.as_u64()?, version))
743            })
744            .max_by(|left, right| left.0.cmp(&right.0))
745            .map(|(_, number, version)| (number, version))
746    }
747
748    /// Every tag the forge holds, with the commit it names, read page by
749    /// page to the end. A page that cannot be read, or an inventory longer
750    /// than the bound, leaves the list unread, never silently truncated.
751    fn tags(&self) -> Option<Vec<(String, String)>> {
752        let (path, commit) = match self.pair.forge {
753            Forge::Github => (
754                format!("repos/{}/tags?per_page={TAG_PAGE}", self.pair.repo),
755                "/commit/sha",
756            ),
757            Forge::Gitlab => (
758                format!(
759                    "projects/{}/repository/tags?per_page={TAG_PAGE}",
760                    self.project()
761                ),
762                "/commit/id",
763            ),
764        };
765        let mut tags = Vec::new();
766        for page in 1..=TAG_PAGES {
767            let body = if page == 1 {
768                self.api(&path)?
769            } else {
770                self.api(&format!("{path}&page={page}"))?
771            };
772            let entries = body.as_array()?;
773            tags.extend(entries.iter().filter_map(|tag| {
774                Some((
775                    tag["name"].as_str()?.to_owned(),
776                    tag.pointer(commit)?.as_str()?.to_owned(),
777                ))
778            }));
779            if entries.len() < TAG_PAGE {
780                return Some(tags);
781            }
782        }
783        None
784    }
785
786    /// The release page for a tag, with each artifact's attestation.
787    fn page(&self, tag: &str) -> Option<Page> {
788        match self.pair.forge {
789            Forge::Github => {
790                let repo = &self.pair.repo;
791                let body = match self.api_answer(&format!("repos/{repo}/releases/tags/{tag}")) {
792                    Api::Ok(body) => body,
793                    Api::Missing => return Some(Page::default()),
794                    Api::Failed(_) => return None,
795                };
796                let mut page = Page {
797                    exists: true,
798                    ..Page::default()
799                };
800                for asset in body["assets"].as_array().into_iter().flatten() {
801                    let Some(name) = asset["name"].as_str() else {
802                        continue;
803                    };
804                    // The engine's checksum file lists the attested
805                    // artifacts and is no artifact itself, so it neither
806                    // counts as one nor needs an attestation.
807                    if release_state::is_support(name) {
808                        continue;
809                    }
810                    page.assets.push(name.to_owned());
811                    let Some(digest) = asset["digest"].as_str() else {
812                        page.attestation_unknown = true;
813                        continue;
814                    };
815                    match self.api_answer(&format!("repos/{repo}/attestations/{digest}")) {
816                        Api::Ok(body)
817                            if body["attestations"]
818                                .as_array()
819                                .is_some_and(|list| !list.is_empty()) => {}
820                        Api::Ok(_) | Api::Missing => page.unattested.push(name.to_owned()),
821                        Api::Failed(_) => page.attestation_unknown = true,
822                    }
823                }
824                Some(page)
825            }
826            Forge::Gitlab => {
827                let body =
828                    match self.api_answer(&format!("projects/{}/releases/{tag}", self.project())) {
829                        Api::Ok(body) => body,
830                        Api::Missing => return Some(Page::default()),
831                        Api::Failed(_) => return None,
832                    };
833                let names: Vec<String> = body["assets"]["links"]
834                    .as_array()
835                    .into_iter()
836                    .flatten()
837                    .filter_map(|link| link["name"].as_str().map(str::to_owned))
838                    .collect();
839                let bundles: Vec<&String> = names
840                    .iter()
841                    .filter(|name| name.ends_with(release_state::BUNDLE_SUFFIX))
842                    .collect();
843                // The checksum file and the bundles are support material,
844                // and no artifact the page must carry.
845                let assets: Vec<String> = names
846                    .iter()
847                    .filter(|name| !release_state::is_support(name))
848                    .cloned()
849                    .collect();
850                // The distributions the provenance job signs: the bash
851                // tarball, and the python sdist and wheel, each named in the
852                // lowercase the builds write.
853                let unattested = assets
854                    .iter()
855                    .filter(|name| {
856                        [".tar.gz", ".whl"]
857                            .iter()
858                            .any(|suffix| name.to_ascii_lowercase().ends_with(suffix))
859                    })
860                    .filter(|name| {
861                        !bundles.iter().any(|bundle| {
862                            **bundle == format!("{name}{}", release_state::BUNDLE_SUFFIX)
863                        })
864                    })
865                    .cloned()
866                    .collect();
867                Some(Page {
868                    exists: true,
869                    assets,
870                    unattested,
871                    attestation_unknown: false,
872                })
873            }
874        }
875    }
876
877    /// Read the runs on `sha` until the publish run, and the artifact run
878    /// where the pair builds one, concluded or the timeout passed.
879    fn wait(&self, sha: &str, timeout: u64) -> Wait {
880        let interval = std::env::var(WAIT_INTERVAL_VAR)
881            .ok()
882            .and_then(|value| value.parse().ok())
883            .unwrap_or(WAIT_INTERVAL_MS);
884        let deadline = Instant::now() + Duration::from_secs(timeout);
885        loop {
886            let (publish, artifact) = self.runs(sha);
887            let concluded = publish.as_ref().is_some_and(Run::concluded)
888                && (!self.pair.artifacts || artifact.as_ref().is_some_and(Run::concluded));
889            let failed = [&publish, &artifact]
890                .into_iter()
891                .flatten()
892                .any(|run| run.concluded() && !run.succeeded());
893            if concluded || failed || Instant::now() >= deadline {
894                let tag = artifact
895                    .as_ref()
896                    .and_then(|run| run.head_branch.clone())
897                    .filter(|branch| branch.starts_with('v'));
898                return Wait {
899                    sha: sha.to_owned(),
900                    timed_out: !concluded && !failed,
901                    publish,
902                    artifact,
903                    tag,
904                };
905            }
906            std::thread::sleep(Duration::from_millis(interval));
907        }
908    }
909
910    /// The publish run and the artifact run on `sha`.
911    fn runs(&self, sha: &str) -> (Option<Run>, Option<Run>) {
912        match self.pair.forge {
913            Forge::Github => {
914                let Some(body) = self.api(&format!(
915                    "repos/{}/actions/runs?head_sha={sha}&per_page=100",
916                    self.pair.repo
917                )) else {
918                    return (None, None);
919                };
920                let publish = self
921                    .pair
922                    .publish_workflow()
923                    .and_then(|workflow| release_state::github_run_on(&body, workflow, sha));
924                let artifact = self
925                    .pair
926                    .artifact_workflow()
927                    .and_then(|workflow| release_state::github_run_on(&body, workflow, sha));
928                (publish, artifact)
929            }
930            Forge::Gitlab => {
931                let Some(body) = self.api(&format!(
932                    "projects/{}/pipelines?sha={sha}&per_page=100",
933                    self.project()
934                )) else {
935                    return (None, None);
936                };
937                // The rk engine builds and publishes in the branch-push
938                // pipeline of the commit it released, on the trunk or on a
939                // release line. A rust pair publishes from the trunk and
940                // builds in the pipeline of the tag it pushed.
941                let engine =
942                    self.pair.request_prefix() == Some(crate::release_engine::REQUEST_PREFIX);
943                if engine {
944                    let run = release_state::gitlab_branch_push_on(&body, sha, |reference| {
945                        self.pair.is_base(reference)
946                    });
947                    let artifact = self.pair.artifacts.then(|| run.clone()).flatten();
948                    return (run, artifact);
949                }
950                let trunk = self.pair.trunk.clone();
951                let publish =
952                    release_state::gitlab_pipeline_on(&body, |reference| reference == trunk);
953                let artifact = self.pair.artifacts.then(|| {
954                    release_state::gitlab_pipeline_on(&body, |reference| reference.starts_with('v'))
955                });
956                (publish, artifact.flatten())
957            }
958        }
959    }
960}
961
962/// The human report, in the order the JSON states it.
963fn render(
964    out: Output,
965    manifest: &Manifest,
966    forge: &ForgeState,
967    observed: &Observed,
968    lines: &[LineRow],
969) {
970    out.result_line(format!(
971        "release: {} style, {} integration, {} checkout, trunk {}",
972        manifest
973            .profile
974            .release
975            .style
976            .map_or("no", |style| style.as_str()),
977        manifest.git.integration.as_str(),
978        manifest.git.checkout_mode.as_str(),
979        manifest.git.trunk
980    ));
981    out.result_line(format!(
982        "forge: {} {} ({}{})",
983        forge.name.as_deref().unwrap_or("none"),
984        forge.repo.as_deref().unwrap_or(""),
985        forge.read.as_str(),
986        forge
987            .reason
988            .as_deref()
989            .map_or_else(String::new, |reason| format!(": {reason}"))
990    ));
991    let request = &observed.request;
992    match request.state {
993        "open" => out.result_line(format!(
994            "request: #{} proposing {}, armed {}, checks {}, behind the trunk {}",
995            request.number.unwrap_or_default(),
996            request
997                .proposed_version
998                .as_deref()
999                .unwrap_or("an unread version"),
1000            request.armed.as_str(),
1001            request.checks.as_str(),
1002            request.behind_trunk.as_str()
1003        )),
1004        state => out.result_line(format!("request: {state}")),
1005    }
1006    let last = &observed.last;
1007    out.result_line(format!(
1008        "last release: {}, published {}, artifacts verified {}",
1009        last.tag.as_deref().unwrap_or("none"),
1010        last.published.as_str(),
1011        last.artifacts_verified.as_str()
1012    ));
1013    for row in lines {
1014        out.result_line(format!(
1015            "line {}: newest release {}",
1016            row.branch,
1017            row.newest_release.as_deref().unwrap_or("none")
1018        ));
1019    }
1020    if let Some(wait) = &observed.wait {
1021        let describe = |run: &Option<Run>| {
1022            run.as_ref().map_or_else(
1023                || "not found".to_owned(),
1024                |run| {
1025                    format!(
1026                        "{} {}",
1027                        run.id,
1028                        run.conclusion.as_deref().unwrap_or(run.status.as_str())
1029                    )
1030                },
1031            )
1032        };
1033        out.result_line(format!(
1034            "wait {}: publish run {}, artifact run {}{}",
1035            wait.sha,
1036            describe(&wait.publish),
1037            describe(&wait.artifact),
1038            if wait.timed_out { ", timed out" } else { "" }
1039        ));
1040    }
1041    for symptom in &observed.symptoms {
1042        out.result_line(format!(
1043            "SYMPTOM {}: {} (rk method recovery: {})",
1044            symptom.code, symptom.evidence, symptom.recovery
1045        ));
1046    }
1047    let mut next = Vec::new();
1048    if !observed.symptoms.is_empty() {
1049        next.push("rk method recovery owns the path for each symptom".to_owned());
1050    }
1051    next.push("rk guide release orders the release".to_owned());
1052    out.next(&next);
1053}
1054
1055#[cfg(test)]
1056mod tests {
1057    use super::{ForgeState, Report, Wait};
1058    use crate::commands::lines::LineRow;
1059    use crate::landing::manifest::{CheckoutMode, Integration, Style};
1060    use crate::release_state::{
1061        Answer, Checks, LastRelease, MERGED_NOTHING_PUBLISHED, Request, Run, Symptom,
1062    };
1063
1064    /// The complete `rk.release-status/1` shape, held by snapshot in both
1065    /// the landed and the absent forms.
1066    #[test]
1067    fn the_release_status_schema_snapshot_holds() {
1068        let landed = Report {
1069            schema: super::SCHEMA,
1070            landed: true,
1071            style: Some(Style::Trunk),
1072            integration: Some(Integration::Local),
1073            checkout_mode: Some(CheckoutMode::LinkedWorktree),
1074            trunk: Some("master".into()),
1075            forge: Some(ForgeState {
1076                name: Some("github".into()),
1077                repo: Some("acme/widget".into()),
1078                read: Answer::Yes,
1079                reason: None,
1080            }),
1081            request: Some(Request {
1082                state: "open",
1083                number: Some(12),
1084                proposed_version: Some("1.2.3".into()),
1085                armed: Answer::Yes,
1086                checks: Checks::Passing,
1087                behind_trunk: Answer::No,
1088            }),
1089            last_release: Some(LastRelease {
1090                tag: Some("v1.2.2".into()),
1091                published: Answer::Yes,
1092                artifacts_verified: Answer::NotApplicable,
1093            }),
1094            lines: Some(vec![LineRow {
1095                line: "1.4".into(),
1096                branch: "release/1.4".into(),
1097                presence: "remote",
1098                newest_release: Some("v1.4.5".into()),
1099                newest_candidate: None,
1100                tag_covered: None,
1101                seat: None,
1102            }]),
1103            symptoms: Some(vec![Symptom {
1104                code: MERGED_NOTHING_PUBLISHED,
1105                evidence: "release request #11 proposing 1.2.2 merged, and no tag v1.2.2 exists"
1106                    .into(),
1107                recovery: "The release request merged and nothing published",
1108            }]),
1109            wait: Some(Wait {
1110                sha: "aaa".into(),
1111                publish: Some(Run {
1112                    id: 20,
1113                    status: "completed".into(),
1114                    conclusion: Some("success".into()),
1115                    head_branch: Some("master".into()),
1116                }),
1117                artifact: None,
1118                tag: Some("v1.2.2".into()),
1119                timed_out: false,
1120            }),
1121        };
1122        assert_eq!(
1123            serde_json::to_string(&landed).expect("a report serializes"),
1124            r#"{"schema":"rk.release-status/1","landed":true,"style":"trunk","integration":"local","checkout_mode":"linked-worktree","trunk":"master","forge":{"name":"github","repo":"acme/widget","read":"yes","reason":null},"request":{"state":"open","number":12,"proposed_version":"1.2.3","armed":"yes","checks":"passing","behind_trunk":"no"},"last_release":{"tag":"v1.2.2","published":"yes","artifacts_verified":"not-applicable"},"lines":[{"line":"1.4","branch":"release/1.4","presence":"remote","newest_release":"v1.4.5"}],"symptoms":[{"code":"request-merged-nothing-published","evidence":"release request #11 proposing 1.2.2 merged, and no tag v1.2.2 exists","recovery":"The release request merged and nothing published"}],"wait":{"sha":"aaa","publish":{"id":20,"status":"completed","conclusion":"success","head_branch":"master"},"artifact":null,"tag":"v1.2.2","timed_out":false}}"#
1125        );
1126        let absent = Report {
1127            landed: false,
1128            style: None,
1129            integration: None,
1130            checkout_mode: None,
1131            trunk: None,
1132            forge: None,
1133            request: None,
1134            last_release: None,
1135            lines: None,
1136            symptoms: None,
1137            wait: None,
1138            ..landed
1139        };
1140        assert_eq!(
1141            serde_json::to_string(&absent).expect("a report serializes"),
1142            r#"{"schema":"rk.release-status/1","landed":false}"#
1143        );
1144    }
1145}