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