use std::time::Duration;
use anyhow::Result;
use owo_colors::OwoColorize;
use tokio::time::sleep;
use crate::api::client::ApiClient;
use crate::api::models::InfraEvent;
use crate::render::theme;
pub async fn run(
client: &ApiClient,
project_reference: Option<&str>,
service_reference: Option<&str>,
) -> Result<()> {
let project = match project_reference {
Some(reference) => Some(client.find_project(reference).await?),
None => None,
};
let service = match (&project, service_reference) {
(Some(project), Some(reference)) => {
Some(client.find_service(&project.id, reference).await?)
}
_ => None,
};
let scope = match (&project, &service) {
(Some(project), Some(service)) => format!("{} · {}", project.name, service.name),
(Some(project), None) => project.name.clone(),
_ => "todo".to_string(),
};
println!(
" {} escuchando {} · Ctrl-C para salir",
theme::ARROW.style(theme::accent()),
scope.style(theme::strong()),
);
let project_id = project.map(|one| one.id);
let service_id = service.map(|one| one.id);
let mut consecutive_failures = 0_u32;
loop {
let outcome = client
.stream_events(|event| {
if !in_scope(&event, project_id.as_deref(), service_id.as_deref()) {
return;
}
if let Some(line) = describe(&event) {
println!("{line}");
}
})
.await;
match outcome {
Ok(()) => consecutive_failures = 0,
Err(failure) => {
consecutive_failures += 1;
if consecutive_failures == NOISY_AFTER {
eprintln!(
" {} sin conexión: {failure}. Reintentando…",
theme::CROSS.style(theme::warning())
);
}
if consecutive_failures >= GIVE_UP_AFTER {
return Err(failure);
}
}
}
sleep(backoff_for(consecutive_failures)).await;
}
}
const NOISY_AFTER: u32 = 3;
const GIVE_UP_AFTER: u32 = 20;
fn backoff_for(failures: u32) -> Duration {
if failures == 0 {
return Duration::from_millis(250);
}
Duration::from_secs(u64::from(failures).pow(2).min(15))
}
fn in_scope(event: &InfraEvent, project_id: Option<&str>, service_id: Option<&str>) -> bool {
if project_id.is_some_and(|wanted| event.project_id != wanted) {
return false;
}
service_id.is_none_or(|wanted| event.service_environment_id.as_deref() == Some(wanted))
}
fn describe(event: &InfraEvent) -> Option<String> {
match event.kind.as_str() {
"deployment-status" => Some(describe_deployment(event)),
"variable-changed" => Some(format!(
" {} {} {}",
theme::PENDING.style(theme::muted()),
event
.key
.as_deref()
.unwrap_or("una variable")
.style(theme::strong()),
match event.action.as_deref() {
Some("removed") => "eliminada",
Some("sealed") => "sellada",
_ => "actualizada",
},
)),
_ => None,
}
}
fn describe_deployment(event: &InfraEvent) -> String {
let name = event.service_name.as_deref().unwrap_or("un servicio");
let status = event.status.as_deref().unwrap_or("");
let (mark, word) = mark_for(status);
let symbol = mark.style(theme::state_style(status)).to_string();
let subject = name.style(theme::strong()).to_string();
match reason_for(event) {
Some(because) => format!(" {symbol} {subject} {word} · {because}"),
None => format!(" {symbol} {subject} {word}"),
}
}
fn mark_for(status: &str) -> (&'static str, &str) {
match status {
"queued" => (theme::ARROW, "desplegando"),
"building" => (theme::PENDING, "construyendo"),
"deploying" => (theme::PENDING, "arrancando"),
"active" => (theme::CHECK, "en marcha"),
"failed" | "crashed" => (theme::CROSS, "ha fallado"),
"stopped" => (theme::SKIP, "se ha dormido"),
"cancelled" | "superseded" => (theme::SKIP, "reemplazado"),
other => (theme::PENDING, other),
}
}
fn reason_for(event: &InfraEvent) -> Option<String> {
if event.status.as_deref() != Some("queued") {
return None;
}
match event.trigger.as_deref() {
Some("git-push") => Some(match (&event.commit_subject, &event.commit_sha) {
(Some(subject), _) => format!("nuevo commit: {subject}"),
(None, Some(sha)) => format!("nuevo commit {}", &sha[..sha.len().min(7)]),
_ => "la rama se movió".to_string(),
}),
Some("variable-change") => Some("cambiaron las variables".to_string()),
Some("rollback") => Some("vuelta atrás".to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn event(kind: &str) -> InfraEvent {
InfraEvent {
kind: kind.to_string(),
project_id: "prj_1".to_string(),
service_environment_id: Some("sve_1".to_string()),
deployment_id: Some("dep_1".to_string()),
status: None,
state: None,
trigger: None,
number: None,
service_name: Some("api".to_string()),
commit_sha: None,
commit_subject: None,
key: None,
action: None,
}
}
#[test]
fn it_should_keep_an_event_from_the_project_being_watched() {
assert!(in_scope(&event("deployment-status"), Some("prj_1"), None));
}
#[test]
fn it_should_drop_an_event_from_another_project() {
assert!(!in_scope(&event("deployment-status"), Some("prj_2"), None));
}
#[test]
fn it_should_drop_an_event_from_another_service() {
assert!(!in_scope(
&event("deployment-status"),
Some("prj_1"),
Some("sve_2")
));
}
#[test]
fn it_should_keep_everything_when_nothing_was_asked_for() {
assert!(in_scope(&event("deployment-status"), None, None));
}
#[test]
fn it_should_say_which_commit_started_a_deployment() {
let mut one = event("deployment-status");
one.status = Some("queued".to_string());
one.trigger = Some("git-push".to_string());
one.commit_subject = Some("fix the health check".to_string());
assert_eq!(
reason_for(&one).unwrap(),
"nuevo commit: fix the health check"
);
}
#[test]
fn it_should_fall_back_to_the_short_sha_when_there_is_no_subject() {
let mut one = event("deployment-status");
one.status = Some("queued".to_string());
one.trigger = Some("git-push".to_string());
one.commit_sha = Some("abc1234567890".to_string());
assert_eq!(reason_for(&one).unwrap(), "nuevo commit abc1234");
}
#[test]
fn it_should_give_no_reason_for_a_deployment_somebody_started_by_hand() {
let mut one = event("deployment-status");
one.status = Some("queued".to_string());
one.trigger = Some("manual".to_string());
assert!(reason_for(&one).is_none());
}
#[test]
fn it_should_name_the_variable_that_changed() {
let mut one = event("variable-changed");
one.key = Some("DATABASE_URL".to_string());
one.action = Some("set".to_string());
assert!(describe(&one).unwrap().contains("DATABASE_URL"));
}
#[test]
fn it_should_print_nothing_for_a_kind_it_does_not_draw() {
assert!(describe(&event("usage")).is_none());
}
#[test]
fn it_should_come_back_quickly_after_a_clean_close() {
assert_eq!(backoff_for(0), Duration::from_millis(250));
}
#[test]
fn it_should_wait_longer_as_failures_repeat() {
assert!(backoff_for(3) > backoff_for(1));
}
#[test]
fn it_should_stop_backing_off_at_fifteen_seconds() {
assert_eq!(backoff_for(50), Duration::from_secs(15));
}
}