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

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

use crate::api::models::{Job, Run, RunDetailResponse};
use crate::api::{ApiFailure, RepoRef, Res};
use crate::cli::CiCommand;
use crate::commands::Ctx;
use crate::output::{
    Table, Tone, ago, bold, bytes, duration, effective_status, finished, heading, note, paint,
    status_badge, status_mark, status_tone, trigger_label, 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,
        CiCommand::Workflows => workflows(ctx, &repo).await,
        CiCommand::Dispatch {
            workflow,
            git_ref,
            fields,
            watch: follow,
            interval,
        } => {
            dispatch(
                ctx,
                &repo,
                workflow,
                git_ref.as_deref(),
                fields,
                follow.then_some(*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_badge(run.display_status()),
            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_badge(run.display_status())
    ));
    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()
            .map(trigger_label)
            .unwrap_or_else(|| "unknown event".into()),
        if run.fork { " · from a fork" } else { "" }
    );
    if let Some(took) = duration(run.started_at.as_deref(), run.completed_at.as_deref()) {
        println!("took {took}");
    }
    if run.awaiting_approval {
        println!("\n{}", paint(&approval_notice(run), Tone::Busy));
    }

    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([
                job_cell(job),
                status_badge(job.display_status()),
                duration(job.started_at.as_deref(), job.completed_at.as_deref())
                    .unwrap_or_else(|| "—".into()),
                job.needs.join(", "),
            ]);
        }
        table.print();

        // Why each job is waiting, skipped or cancelled — or why its matrix
        // couldn't expand. Below the table rather than in it: the reasons
        // are sentences, and a column of sentences is a very wide table.
        let reasons: Vec<(String, String)> = res
            .jobs
            .iter()
            .filter_map(|j| j.reason().map(|r| (j.label(), r)))
            .collect();
        if !reasons.is_empty() {
            println!();
            for (label, reason) in reasons {
                println!(
                    "  {} {}",
                    bold(&format!("{label}:")),
                    paint(&reason, Tone::Muted)
                );
            }
        }
    }

    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)));
}

/// A job's name, with its attempt when it's been re-run.
fn job_cell(job: &Job) -> String {
    let label = truncate(&job.label(), 36);
    if job.attempt > 1 {
        format!(
            "{label} {}",
            paint(&format!("(attempt {})", job.attempt), Tone::Muted)
        )
    } else {
        label
    }
}

/// A fork's run waits for someone with write access to approve it, and
/// that happens on the pull request — so say so, and say how.
fn approval_notice(run: &Run) -> String {
    match run.pull_request_number {
        Some(pr) => format!(
            "Waiting for approval: this run comes from a fork. Its jobs start once someone \
             with write access approves it on pull request #{pr} (`gg pull approve-runs {pr}`)."
        ),
        None => "Waiting for approval: this run comes from a fork. Its jobs start once someone \
                 with write access approves it."
            .to_string(),
    }
}

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)?;

    // A dynamic matrix's placeholder is deleted when its legs replace it,
    // so the job picked a moment ago can be gone by the time its logs are
    // asked for. That's news about the run, not an error in the request.
    let res = match ctx.api.logs(repo, job.id, limit).await {
        Ok(res) => res,
        Err(e) if ApiFailure::of(&e).is_some_and(|f| f.status.as_u16() == 404) => {
            if job.placeholder {
                bail!(
                    "{} no longer exists — a matrix job is replaced by its legs once it expands; \
                     `gg ci show {number}` lists them",
                    job.label()
                );
            }
            return Err(e);
        }
        Err(e) => return Err(e),
    };
    if ctx.emitted(&res.raw)? {
        return Ok(());
    }

    if !raw {
        heading(&format!(
            "#{} {} · {} — {}",
            number,
            detail.run.workflow_label(),
            job.label(),
            status_badge(res.job.display_status())
        ));
    }

    // A job that hasn't started still lists its steps, each with nothing
    // in it — which says less than the reason it hasn't started.
    if res.steps.iter().all(|s| s.lines.is_empty()) {
        // A job that hasn't run says why, when the server says.
        match res.job.reason() {
            Some(reason) => note(&format!("No logs yet — {reason}.")),
            None => 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_badge(res.run.display_status())
    );
    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_badge(res.run.display_status())
    );
    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;
    let mut told_approval = false;

    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 = run.display_status();

        // Job marks are re-read from the run on every poll, never kept by
        // job id: a dynamic matrix's placeholder is replaced by its legs
        // (new ids), and the next poll simply shows the legs.
        let line = format!(
            "#{} {} · {}",
            run.number,
            status_badge(state),
            res.jobs
                .iter()
                .map(|j| {
                    let s = j.display_status();
                    format!("{} {}", paint(status_mark(s), status_tone(s)), j.label())
                })
                .collect::<Vec<_>>()
                .join("  ")
        );
        if line != last_line {
            println!("{line}");
            last_line = line;
        }

        // A fork's run waiting for approval stays watched, as the app's
        // RunWatch does: the approval is what it's waiting for. Said once,
        // so nobody mistakes the silence for a stuck runner.
        if run.awaiting_approval && !told_approval {
            note(&approval_notice(run));
            told_approval = true;
        }

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

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

/// The workflows that can be run by hand, with their inputs.
async fn workflows(ctx: &Ctx, repo: &RepoRef) -> Result<()> {
    let res = ctx.api.workflows(repo).await?;
    if ctx.emitted(&res.raw)? {
        return Ok(());
    }
    let branch = res
        .default_branch
        .as_deref()
        .unwrap_or("the default branch");
    if res.workflows.is_empty() {
        note(&format!(
            "No workflow on {branch} in {repo} has an `on: workflow_dispatch` trigger."
        ));
        return Ok(());
    }

    for (i, workflow) in res.workflows.iter().enumerate() {
        if i > 0 {
            println!();
        }
        println!(
            "{}  {}",
            bold(workflow.label()),
            paint(&workflow.path, Tone::Muted)
        );
        if workflow.inputs.is_empty() {
            note("  no inputs");
            continue;
        }
        let mut table = Table::new(["  INPUT", "TYPE", "DEFAULT", "DESCRIPTION"]);
        for input in &workflow.inputs {
            let kind = if input.options.is_empty() {
                input.kind.clone()
            } else {
                format!("{} ({})", input.kind, input.options.join("|"))
            };
            table.row([
                format!("  {}{}", input.name, if input.required { " *" } else { "" }),
                kind,
                input
                    .default
                    .as_deref()
                    .filter(|d| !d.is_empty())
                    .unwrap_or("—")
                    .to_string(),
                truncate(input.description.as_deref().unwrap_or(""), 60),
            ]);
        }
        table.print();
    }

    println!();
    note(&format!(
        "Inputs marked * are required. Runs on {branch} unless --ref says otherwise."
    ));
    if res.can_dispatch {
        note("Run one with `gg ci dispatch <workflow> [--ref REF] [-f NAME=VALUE …]`.");
    } else {
        note("(read-only: running a workflow needs write access here)");
    }
    Ok(())
}

/// `workflow_dispatch`, and optionally a watch of the run it started.
async fn dispatch(
    ctx: &Ctx,
    repo: &RepoRef,
    workflow: &str,
    git_ref: Option<&str>,
    fields: &[(String, String)],
    watch_interval: Option<u64>,
) -> Result<()> {
    let mut inputs = BTreeMap::new();
    for (name, value) in fields {
        if inputs.insert(name.clone(), value.clone()).is_some() {
            bail!("input {name} given more than once");
        }
    }

    let res = ctx
        .api
        .dispatch(repo, workflow, git_ref, &inputs)
        .await
        .map_err(|e| match ApiFailure::of(&e) {
            Some(f) if !f.errors.is_empty() => e.context(format!(
                "{workflow}: see `gg ci workflows` for what it declares"
            )),
            _ => e,
        })?;
    if ctx.emitted(&res.raw)? {
        return Ok(());
    }

    let run = &res.run;
    println!(
        "{} run #{} {} on {}",
        paint("Started", Tone::Good),
        run.number,
        run.workflow_label(),
        run.short_ref().or(git_ref).unwrap_or("the default branch")
    );
    note(&ctx.repo_web_url(repo, &format!("/actions/{}", run.number)));

    match watch_interval {
        Some(interval) => watch(ctx, repo, Some(run.number), interval).await,
        None => Ok(()),
    }
}

/// 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![],
            status_reason: None,
            attempt: 1,
            superseded: false,
            placeholder: false,
            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 superseded_jobs_read_as_superseded_not_cancelled() {
        let mut j = job(1, "build", "cancelled", None);
        assert_eq!(j.display_status(), "cancelled");
        j.superseded = true;
        assert_eq!(j.display_status(), "superseded");
        assert!(finished(j.display_status()));
        assert_eq!(status_tone(j.display_status()), Tone::Muted);
    }

    #[test]
    fn a_placeholder_explains_itself() {
        let mut j = job(1, "build", "blocked", None);
        j.placeholder = true;
        j.needs = vec!["setup".into()];
        assert_eq!(
            j.reason().as_deref(),
            Some("Matrix expands when setup finish")
        );
        j.status_reason = Some("Waiting for approval".into());
        assert_eq!(j.reason().as_deref(), Some("Waiting for approval"));
    }

    #[test]
    fn a_run_waiting_for_approval_is_not_finished() {
        let run: Run = serde_json::from_value(serde_json::json!({
            "number": 7,
            "status": "queued",
            "fork": true,
            "awaiting_approval": true,
            "pull_request_number": 3
        }))
        .unwrap();
        assert_eq!(run.display_status(), "awaiting_approval");
        assert!(!finished(run.display_status()));
        assert!(approval_notice(&run).contains("#3"));
    }

    #[test]
    fn older_servers_still_parse() {
        let j: Job =
            serde_json::from_value(serde_json::json!({"id": 1, "status": "waiting"})).unwrap();
        assert_eq!(j.attempt, 1);
        assert!(!j.superseded && !j.placeholder);
        assert!(!finished(j.display_status()));
    }

    #[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());
    }
}