thirdshift 0.13.0

Turn a GitHub issue into a ready-for-review pull request, unattended
//! Format machine-wide Command observations.

mod inbox;
pub mod inspect;
pub(crate) mod time;
pub(crate) mod waterfall;
pub(crate) use inbox::{Archive, ArchivePage, archive};
pub(crate) use inbox::{Inbox, Row, inbox};

use anyhow::Result;
use chrono::Local;
use serde::Serialize;

use super::command_record::CommandRecord;
use super::reading::{Command, Group, IdlePass, InboxItem, Observation, Snapshot};
use crate::config::UserConfig;
use crate::issue::IssueUrl;

#[derive(Serialize)]
struct Status<'a> {
    version: u32,
    hostname: String,
    commands: Vec<InboxItem<'a>>,
    idle_passes: &'a [IdlePass],
}

fn print_command(command: &Command, indent: &str) {
    let facts = &command.record;
    let stage = stage_text(command);
    let mut subject = match &facts.issue {
        Some(issue) => format!(
            "{}#{} {}",
            facts.repository,
            issue.number,
            issue.title.as_deref().unwrap_or("(title unavailable)")
        ),
        None => format!("{} on {}", facts.name, facts.repository),
    };
    if let Some(pull_request) = &facts.pull_request {
        subject.push_str(&format!(" · {pull_request}"));
    }
    println!(
        "{indent}{} {subject} · {} · {} · {} · {} · {stage}",
        facts.kind,
        command.state,
        facts.harness,
        facts.model.as_deref().unwrap_or("default Model"),
        facts.effort.as_deref().unwrap_or("default Effort")
    );
    print_tickets(facts, &format!("{indent}  "));
    for dispatched in &command.dispatched {
        print_command(dispatched, &format!("{indent}  "));
    }
    for child in &command.children {
        print_command(child, &format!("{indent}  "));
    }
}

pub(super) fn print_history(command: &super::reading::HistoricalCommand) {
    let issue = command
        .issue
        .as_ref()
        .map(|issue| format!("#{}", issue.number))
        .unwrap_or_default();
    println!(
        "{}{issue} {} · {} → {} · {}",
        command.repository,
        command.kind,
        command.started_at,
        command.ended_at.as_deref().unwrap_or("unknown end"),
        command.outcome.as_deref().unwrap_or("unknown outcome")
    );
}

pub fn show(json: bool, all: bool) -> Result<()> {
    let config = UserConfig::load()?;
    let waiting = config.effective_waiting_line_interval();
    if waiting != config.waiting_line_interval {
        eprintln!(
            "thirdshift: warning: logs.waiting_line_minutes must be less than inbox.silent_minutes; using half the silent time ({} sec) for waiting lines",
            waiting.as_secs()
        );
    }
    let snapshot =
        Snapshot::load_with_silence(&config.logs_dir, Local::now(), config.silent_interval)?;
    let status = Status {
        version: 1,
        hostname: crate::host::name().unwrap_or_else(|| "unknown".into()),
        commands: snapshot.inbox(all, config.inbox_days),
        idle_passes: &snapshot.idle_passes,
    };
    if json {
        println!("{}", serde_json::to_string_pretty(&status)?);
    } else {
        println!("{}", status.hostname);
        for group in Group::ORDER {
            let items: Vec<_> = status
                .commands
                .iter()
                .filter(|item| item.group == group)
                .collect();
            let references: Vec<_> = status
                .commands
                .iter()
                .filter(|item| {
                    group == Group::Landing && item.landing && item.group == Group::NeedsYou
                })
                .collect();
            if items.is_empty() && references.is_empty() {
                continue;
            }
            println!("{}:", group.title());
            for item in items {
                match item.command {
                    Observation::Record(command) => print_command(command, ""),
                    Observation::History(command) => print_history(command),
                }
                if item.count > 1 {
                    let cause = item.cause().unwrap_or_default();
                    println!("  {} Commands with this cause: {cause}", item.count);
                }
            }
            for item in references {
                if let Observation::Record(command) = item.command {
                    let facts = &command.record;
                    let name = facts.issue.as_ref().map_or_else(
                        || facts.name.clone(),
                        |issue| format!("{}#{}", facts.repository, issue.number),
                    );
                    println!(
                        "{name} · {} · see Needs you",
                        item.needs_you_kind.expect("Needs you has a kind").title()
                    );
                }
            }
        }
        for pass in status.idle_passes {
            println!("{} idle on {}: {}", pass.kind, pass.repository, pass.reason);
        }
    }
    Ok(())
}

pub(super) fn stage_text(command: &Command) -> String {
    if let Some(outcome) = &command.record.outcome {
        return outcome.clone();
    }
    let mut text = command.stage.as_ref().map_or_else(
        || {
            if command.state == "died" {
                "died before first stage".into()
            } else {
                "starting".into()
            }
        },
        |stage| {
            if command.state == "died" {
                format!("died at {}", stage.name)
            } else {
                format!("{} · {}", stage.name, age(stage.age_seconds))
            }
        },
    );
    if let Some(health) = command
        .health
        .as_ref()
        .and_then(super::reading::Health::text)
    {
        text.push_str(&format!(" · {health}"));
    }
    text
}

pub(super) fn print_tickets(facts: &CommandRecord, indent: &str) {
    for ticket in &facts.tickets {
        println!("{indent}{}", ticket_text(ticket, &facts.repository));
    }
}

fn ticket_text(ticket: &super::command_record::TicketRecord, repository: &str) -> String {
    use super::command_record::TicketState;
    let state = match ticket.state {
        TicketState::Running => "running".into(),
        TicketState::Merged => "merged".into(),
        TicketState::Failed => "failed".into(),
        TicketState::NotStarted => "not started".into(),
        TicketState::Waiting => format!(
            "waiting on {}",
            ticket
                .blockers
                .iter()
                .map(|issue| {
                    if IssueUrl::parse(&issue.url)
                        .is_ok_and(|blocker| blocker.repo_slug().eq_ignore_ascii_case(repository))
                    {
                        format!("#{}", issue.number)
                    } else {
                        issue.url.clone()
                    }
                })
                .collect::<Vec<_>>()
                .join(", ")
        ),
    };
    format!("Ticket #{} · {state}", ticket.issue.number)
}

fn age(seconds: i64) -> String {
    if seconds < 60 {
        format!("{seconds} sec")
    } else if seconds < 3600 {
        format!("{} min", seconds / 60)
    } else {
        format!("{} hr {} min", seconds / 3600, seconds % 3600 / 60)
    }
}