14.2 KiB · text History bdd2439
//! Workflow runs: listing, drilling in, logs, and the two write actions.

use anyhow::{Result, anyhow, bail};
use std::time::Duration;

use crate::api::models::{Job, RunDetailResponse};
use crate::api::{RepoRef, Res};
use crate::cli::CiCommand;
use crate::commands::Ctx;
use crate::output::{
    Table, Tone, ago, bold, bytes, duration, effective_status, heading, note, paint, status_cell,
    status_mark, status_tone, truncate,
};

/// How many consecutive polling failures end a watch. At the default
/// interval that is about a minute of silence, which outlasts a restart
/// without hiding a forge that has genuinely gone away.
const MAX_WATCH_FAILURES: u32 = 12;

pub async fn run(ctx: &Ctx, command: &CiCommand) -> Result<()> {
    let repo = ctx.repo()?;
    match command {
        CiCommand::List { limit } => list(ctx, &repo, *limit).await,
        CiCommand::Show { number, steps } => show(ctx, &repo, *number, *steps).await,
        CiCommand::Logs {
            number,
            job,
            limit,
            raw,
        } => logs(ctx, &repo, *number, job.as_deref(), *limit, *raw).await,
        CiCommand::Rerun { number, job } => rerun(ctx, &repo, *number, job.as_deref()).await,
        CiCommand::Cancel { number } => cancel(ctx, &repo, *number).await,
        CiCommand::Watch { number, interval } => watch(ctx, &repo, *number, *interval).await,
    }
}

async fn list(ctx: &Ctx, repo: &RepoRef, limit: u32) -> Result<()> {
    let res = ctx.api.runs(repo, Some(limit)).await?;
    if ctx.emitted(&res.raw)? {
        return Ok(());
    }
    if res.runs.is_empty() {
        note(&format!("No workflow runs in {repo}."));
        return Ok(());
    }

    let mut table = Table::new(["RUN", "STATUS", "WORKFLOW", "REF", "COMMIT", "TOOK", "WHEN"]);
    for run in &res.runs {
        table.row([
            format!("#{}", run.number),
            status_cell(&run.status, run.conclusion.as_deref()),
            truncate(run.workflow_label(), 28),
            truncate(run.short_ref().unwrap_or("—"), 24),
            run.short_sha().unwrap_or("—").to_string(),
            duration(run.started_at.as_deref(), run.completed_at.as_deref())
                .unwrap_or_else(|| "—".into()),
            ago(run.inserted_at.as_deref().or(run.started_at.as_deref())),
        ]);
    }
    table.print();
    Ok(())
}

async fn show(ctx: &Ctx, repo: &RepoRef, number: Option<i64>, steps: bool) -> Result<()> {
    let number = resolve_number(ctx, repo, number).await?;
    let res = ctx.api.run(repo, number).await?;
    if ctx.emitted(&res.raw)? {
        return Ok(());
    }
    print_run(ctx, repo, &res, steps);
    Ok(())
}

fn print_run(ctx: &Ctx, repo: &RepoRef, res: &Res<RunDetailResponse>, steps: bool) {
    let run = &res.run;
    heading(&format!(
        "#{} {} — {}",
        run.number,
        run.workflow_label(),
        status_cell(&run.status, run.conclusion.as_deref())
    ));
    println!(
        "{} {} · {} · triggered by {} ({})",
        run.short_ref().unwrap_or("—"),
        run.short_sha().unwrap_or(""),
        ago(run.inserted_at.as_deref().or(run.started_at.as_deref())),
        run.triggered_by
            .as_ref()
            .map(|u| u.label().to_string())
            .unwrap_or_else(|| "—".into()),
        run.trigger.as_deref().unwrap_or("unknown event")
    );
    if let Some(took) = duration(run.started_at.as_deref(), run.completed_at.as_deref()) {
        println!("took {took}");
    }

    println!();
    if res.jobs.is_empty() {
        note("No jobs on this run yet.");
    } else {
        let mut table = Table::new(["JOB", "STATUS", "TOOK", "NEEDS"]);
        for job in &res.jobs {
            table.row([
                truncate(&job.label(), 36),
                status_cell(&job.status, job.conclusion.as_deref()),
                duration(job.started_at.as_deref(), job.completed_at.as_deref())
                    .unwrap_or_else(|| "—".into()),
                job.needs.join(", "),
            ]);
        }
        table.print();
    }

    if steps {
        for job in &res.jobs {
            println!("\n{}", bold(&job.label()));
            if job.steps.is_empty() {
                note("  (no steps reported)");
                continue;
            }
            for step in &job.steps {
                let state = effective_status(&step.status, step.conclusion.as_deref());
                println!(
                    "  {} {}{}",
                    paint(status_mark(state), status_tone(state)),
                    step.name.as_deref().unwrap_or("step"),
                    duration(step.started_at.as_deref(), step.completed_at.as_deref())
                        .map(|d| format!("  {}", paint(&d, Tone::Muted)))
                        .unwrap_or_default()
                );
            }
        }
    }

    if !res.artifacts.is_empty() {
        println!("\n{}", bold("Artifacts"));
        for artifact in &res.artifacts {
            println!(
                "  {}  {}  {}",
                artifact.name.as_deref().unwrap_or("artifact"),
                bytes(artifact.size.unwrap_or(0)),
                ago(artifact.inserted_at.as_deref())
            );
        }
    }

    if !res.can_manage {
        note("\n(read-only: this token can't re-run or cancel here)");
    }
    note(&ctx.repo_web_url(repo, &format!("/actions/{}", run.number)));
}

async fn logs(
    ctx: &Ctx,
    repo: &RepoRef,
    number: Option<i64>,
    job: Option<&str>,
    limit: Option<u32>,
    raw: bool,
) -> Result<()> {
    let number = resolve_number(ctx, repo, number).await?;
    let detail = ctx.api.run(repo, number).await?;
    let job = pick_job(&detail.jobs, job)?;

    let res = ctx.api.logs(repo, job.id, limit).await?;
    if ctx.emitted(&res.raw)? {
        return Ok(());
    }

    if !raw {
        heading(&format!(
            "#{} {} · {} — {}",
            number,
            detail.run.workflow_label(),
            job.label(),
            status_cell(&job.status, job.conclusion.as_deref())
        ));
    }

    if res.steps.is_empty() {
        note("No logs — the job may not have started, or they may have expired.");
        return Ok(());
    }

    for step in &res.steps {
        if !raw {
            let state = effective_status(&step.step.status, step.step.conclusion.as_deref());
            println!(
                "\n{} {}",
                paint(status_mark(state), status_tone(state)),
                bold(step.step.name.as_deref().unwrap_or("step"))
            );
        }
        for line in &step.lines {
            println!("{}", line.body);
        }
    }

    if res.truncated {
        note("\n(truncated — raise --limit to see more)");
    }
    Ok(())
}

/// Which job the user meant.
///
/// A name or workflow id if they said one; otherwise the only job, or the
/// single failed one. Anything ambiguous is an error listing the choices —
/// guessing which job's logs you wanted is worse than asking.
fn pick_job<'a>(jobs: &'a [Job], wanted: Option<&str>) -> Result<&'a Job> {
    if jobs.is_empty() {
        bail!("this run has no jobs yet");
    }

    if let Some(wanted) = wanted {
        let matches: Vec<&Job> = jobs
            .iter()
            .filter(|j| {
                j.job_id
                    .as_deref()
                    .is_some_and(|id| id.eq_ignore_ascii_case(wanted))
                    || j.name
                        .as_deref()
                        .is_some_and(|n| n.eq_ignore_ascii_case(wanted))
                    || j.id.to_string() == wanted
            })
            .collect();
        return match matches.as_slice() {
            [job] => Ok(job),
            [] => Err(anyhow!(
                "no job called {wanted:?} — this run has: {}",
                job_names(jobs)
            )),
            _ => Err(anyhow!(
                "{wanted:?} matches more than one job; use its numeric id ({})",
                matches
                    .iter()
                    .map(|j| j.id.to_string())
                    .collect::<Vec<_>>()
                    .join(", ")
            )),
        };
    }

    if jobs.len() == 1 {
        return Ok(&jobs[0]);
    }

    let failed: Vec<&Job> = jobs
        .iter()
        .filter(|j| {
            matches!(
                effective_status(&j.status, j.conclusion.as_deref()),
                "failure" | "timed_out"
            )
        })
        .collect();
    match failed.as_slice() {
        [job] => Ok(job),
        _ => Err(anyhow!(
            "this run has several jobs — pick one with --job: {}",
            job_names(jobs)
        )),
    }
}

fn job_names(jobs: &[Job]) -> String {
    jobs.iter()
        .map(|j| j.job_id.clone().unwrap_or_else(|| j.label()))
        .collect::<Vec<_>>()
        .join(", ")
}

async fn rerun(ctx: &Ctx, repo: &RepoRef, number: i64, job: Option<&str>) -> Result<()> {
    let res = match job {
        Some(job) => ctx.api.rerun_job(repo, number, job).await?,
        None => ctx.api.rerun(repo, number).await?,
    };
    if ctx.emitted(&res.raw)? {
        return Ok(());
    }

    let what = match job {
        Some(job) => format!("job {job} of run #{number}"),
        None => format!("run #{number}"),
    };
    println!(
        "{} {what} — now {}",
        paint("Re-queued", Tone::Good),
        status_cell(&res.run.status, res.run.conclusion.as_deref())
    );
    Ok(())
}

async fn cancel(ctx: &Ctx, repo: &RepoRef, number: i64) -> Result<()> {
    let res = ctx.api.cancel_run(repo, number).await?;
    if ctx.emitted(&res.raw)? {
        return Ok(());
    }
    println!(
        "{} run #{number} — now {}",
        paint("Cancelled", Tone::Busy),
        status_cell(&res.run.status, res.run.conclusion.as_deref())
    );
    Ok(())
}

/// Poll a run until it settles, printing each transition.
///
/// Exits non-zero on a failed run so `gg ci watch && deploy` means
/// what it looks like it means.
async fn watch(ctx: &Ctx, repo: &RepoRef, number: Option<i64>, interval: u64) -> Result<()> {
    let number = resolve_number(ctx, repo, number).await?;
    let interval = Duration::from_secs(interval.max(1));
    let mut last_line = String::new();
    let mut failures = 0;

    loop {
        // A watch outlives things the forge does to itself. Deploying it
        // mid-run answers with 502s for a few seconds, and giving up
        // then is the opposite of what watching is for — so transient
        // errors are reported once and retried, and only a run of them
        // ends the watch.
        let res = match ctx.api.run(repo, number).await {
            Ok(res) => {
                if failures > 0 {
                    note("reconnected");
                    failures = 0;
                }
                res
            }
            Err(e) => {
                failures += 1;
                if failures >= MAX_WATCH_FAILURES {
                    return Err(e.context(format!(
                        "giving up after {failures} consecutive failures watching run #{number}"
                    )));
                }
                if failures == 1 {
                    note(&format!("{e:#} — retrying"));
                }
                tokio::time::sleep(interval).await;
                continue;
            }
        };

        let run = &res.run;
        let state = effective_status(&run.status, run.conclusion.as_deref());

        let line = format!(
            "#{} {} · {}",
            run.number,
            status_cell(&run.status, run.conclusion.as_deref()),
            res.jobs
                .iter()
                .map(|j| {
                    let s = effective_status(&j.status, j.conclusion.as_deref());
                    format!("{} {}", paint(status_mark(s), status_tone(s)), j.label())
                })
                .collect::<Vec<_>>()
                .join("  ")
        );
        if line != last_line {
            println!("{line}");
            last_line = line;
        }

        if !matches!(state, "queued" | "running" | "pending" | "blocked") {
            return match state {
                "success" => Ok(()),
                "skipped" | "cancelled" => {
                    note(&format!("run #{number} {state}"));
                    Ok(())
                }
                other => Err(anyhow!("run #{number} {other}")),
            };
        }

        tokio::time::sleep(interval).await;
    }
}

/// The run the user meant: the one they named, or the most recent.
async fn resolve_number(ctx: &Ctx, repo: &RepoRef, number: Option<i64>) -> Result<i64> {
    if let Some(number) = number {
        return Ok(number);
    }
    let res = ctx.api.runs(repo, Some(1)).await?;
    res.runs
        .first()
        .map(|r| r.number)
        .ok_or_else(|| anyhow!("no workflow runs in {repo} yet"))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn job(id: i64, job_id: &str, status: &str, conclusion: Option<&str>) -> Job {
        Job {
            id,
            job_id: Some(job_id.to_string()),
            name: Some(job_id.to_uppercase()),
            status: status.to_string(),
            conclusion: conclusion.map(str::to_string),
            needs: vec![],
            started_at: None,
            completed_at: None,
            steps: vec![],
        }
    }

    #[test]
    fn a_lone_job_needs_no_flag() {
        let jobs = vec![job(1, "build", "completed", Some("success"))];
        assert_eq!(pick_job(&jobs, None).unwrap().id, 1);
    }

    #[test]
    fn the_failed_job_is_the_interesting_one() {
        let jobs = vec![
            job(1, "build", "completed", Some("success")),
            job(2, "test", "completed", Some("failure")),
        ];
        assert_eq!(pick_job(&jobs, None).unwrap().id, 2);
    }

    #[test]
    fn several_healthy_jobs_are_ambiguous() {
        let jobs = vec![
            job(1, "build", "completed", Some("success")),
            job(2, "test", "completed", Some("success")),
        ];
        assert!(pick_job(&jobs, None).is_err());
    }

    #[test]
    fn matches_by_id_or_name() {
        let jobs = vec![
            job(1, "build", "completed", Some("success")),
            job(2, "test", "completed", Some("success")),
        ];
        assert_eq!(pick_job(&jobs, Some("test")).unwrap().id, 2);
        assert_eq!(pick_job(&jobs, Some("BUILD")).unwrap().id, 1);
        assert_eq!(pick_job(&jobs, Some("2")).unwrap().id, 2);
        assert!(pick_job(&jobs, Some("deploy")).is_err());
    }
}