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