//! 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(());
}
// A whole re-run is a new run with its own number; a job re-runs in
// place, and the status shown is its run's.
match job {
Some(job) => println!(
"{} job {job} of run #{number} — run now {}",
paint("Re-queued", Tone::Good),
status_badge(res.run.display_status())
),
None => println!(
"{} run #{} (re-run of #{number}) — {}",
paint("Started", Tone::Good),
res.run.number,
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());
}
}
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
23.1 KiB · text
History
f19604a