//! 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());
}
}
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
14.2 KiB · text
History
bdd2439