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