use std::io::Write;
use std::thread::sleep;
use std::time::Duration;
use anyhow::{anyhow, Result};
use clap::{Args, Subcommand};
use serde_json::{json, Value};
use crate::client;
use crate::commands::issue::resolve_repo;
use crate::output::{render_record, render_table, FormatChoice};
#[derive(Debug, Subcommand)]
pub enum RunCmd {
List(ListArgs),
View(ViewArgs),
Logs(LogsArgs),
Cancel(IdArgs),
}
#[derive(Debug, Args)]
pub struct ListArgs {
pub repo: String,
#[arg(long, default_value_t = false)]
pub json: bool,
#[arg(long, default_value_t = false)]
pub tsv: bool,
}
#[derive(Debug, Args)]
pub struct ViewArgs {
pub id: String,
#[arg(long, default_value_t = false)]
pub json: bool,
#[arg(long, default_value_t = false)]
pub tsv: bool,
}
#[derive(Debug, Args)]
pub struct LogsArgs {
pub id: String,
#[arg(long, default_value_t = false)]
pub follow: bool,
}
#[derive(Debug, Args)]
pub struct IdArgs {
pub id: String,
}
pub fn run(cmd: RunCmd) -> Result<()> {
match cmd {
RunCmd::List(a) => list(a),
RunCmd::View(a) => view(a),
RunCmd::Logs(a) => logs(a),
RunCmd::Cancel(a) => cancel(a),
}
}
fn list(args: ListArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let (repo_id, _o, _n) = resolve_repo(&cli, &args.repo)?;
let data = cli.execute(LIST_RUNS, json!({ "id": repo_id }))?;
let runs = data["actionRuns"].as_array().cloned().unwrap_or_default();
let rows = run_rows(&runs);
let fmt = FormatChoice {
json: args.json,
tsv: args.tsv,
}
.resolve();
let out = render_table(&["id", "status", "workflow", "job", "started"], &rows, fmt);
std::io::stdout().lock().write_all(out.as_bytes())?;
Ok(())
}
fn view(args: ViewArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let data = cli.execute(VIEW_RUN, json!({ "id": args.id }))?;
let r = &data["actionRun"];
if r.is_null() {
return Err(anyhow!("no run with id {}", args.id));
}
let pairs = run_record_pairs(r, &args.id);
let fmt = FormatChoice {
json: args.json,
tsv: args.tsv,
}
.resolve();
std::io::stdout()
.lock()
.write_all(render_record(&pairs, fmt).as_bytes())?;
Ok(())
}
fn logs(args: LogsArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let mut printed: usize = 0;
loop {
let data = cli.execute(LOG_RUN, json!({ "id": args.id }))?;
let buf = data["actionRun"]["log"].as_str().unwrap_or("");
let status = data["actionRun"]["status"]
.as_str()
.unwrap_or("")
.to_string();
if let Some(chunk) = next_log_chunk(buf, printed) {
std::io::stdout().lock().write_all(chunk.as_bytes())?;
printed = buf.len();
}
if !args.follow {
break;
}
if is_terminal_log_status(&status) {
break;
}
sleep(Duration::from_millis(750));
}
Ok(())
}
fn cancel(args: IdArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
cli.execute(CANCEL_RUN, json!({ "id": args.id }))?;
writeln!(std::io::stdout().lock(), "Cancelled run {}", args.id)?;
Ok(())
}
fn is_terminal_log_status(status: &str) -> bool {
matches!(
status,
"SUCCESS" | "SUCCEEDED" | "FAILURE" | "FAILED" | "CANCELLED" | "CANCELED" | "SKIPPED"
)
}
fn run_rows(runs: &[Value]) -> Vec<Vec<String>> {
runs.iter()
.map(|r| {
vec![
r["id"].as_str().unwrap_or("").to_string(),
r["status"].as_str().unwrap_or("").to_string(),
r["workflowName"].as_str().unwrap_or("").to_string(),
r["jobName"].as_str().unwrap_or("").to_string(),
r["startedAt"].as_str().unwrap_or("").to_string(),
]
})
.collect()
}
fn run_record_pairs(run: &Value, id: &str) -> Vec<(&'static str, String)> {
vec![
("Run", id.to_string()),
("Status", run["status"].as_str().unwrap_or("").to_string()),
(
"Workflow",
run["workflowName"].as_str().unwrap_or("").to_string(),
),
("Job", run["jobName"].as_str().unwrap_or("").to_string()),
(
"Commit",
run["commitOid"].as_str().unwrap_or("").to_string(),
),
(
"Triggered by",
run["triggeredByUsername"]
.as_str()
.unwrap_or("")
.to_string(),
),
("Queued", run["queuedAt"].as_str().unwrap_or("").to_string()),
(
"Started",
run["startedAt"].as_str().unwrap_or("").to_string(),
),
(
"Completed",
run["completedAt"].as_str().unwrap_or("").to_string(),
),
]
}
fn next_log_chunk(buf: &str, printed: usize) -> Option<&str> {
(buf.len() > printed).then(|| &buf[printed..])
}
const LIST_RUNS: &str = "query($id: UUID!) { \
actionRuns(repositoryId: $id) { \
id status workflowName jobName startedAt completedAt \
} \
}";
const VIEW_RUN: &str = "query($id: UUID!) { \
actionRun(id: $id) { \
id status workflowName jobName commitOid triggeredByUsername \
queuedAt startedAt completedAt \
} \
}";
const LOG_RUN: &str = "query($id: UUID!) { actionRun(id: $id) { status log } }";
const CANCEL_RUN: &str = "mutation($id: UUID!) { cancelActionRun(id: $id) { id status } }";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminal_log_statuses_match_api_and_legacy_values() {
for status in [
"SUCCESS",
"SUCCEEDED",
"FAILURE",
"FAILED",
"CANCELLED",
"CANCELED",
"SKIPPED",
] {
assert!(
is_terminal_log_status(status),
"{status} should stop following logs"
);
}
for status in ["QUEUED", "RUNNING", "IN_PROGRESS", ""] {
assert!(
!is_terminal_log_status(status),
"{status:?} should keep following logs"
);
}
}
#[test]
fn run_rows_and_record_pairs_format_values_with_defaults() {
let runs = vec![
json!({
"id": "run-1",
"status": "RUNNING",
"workflowName": "ci",
"jobName": "test",
"startedAt": "2026-06-18T01:00:00Z"
}),
json!({}),
];
assert_eq!(
run_rows(&runs),
vec![
vec![
"run-1".to_string(),
"RUNNING".to_string(),
"ci".to_string(),
"test".to_string(),
"2026-06-18T01:00:00Z".to_string(),
],
vec![
String::new(),
String::new(),
String::new(),
String::new(),
String::new(),
],
]
);
let run = json!({
"status": "SUCCESS",
"workflowName": "release",
"jobName": "publish",
"commitOid": "abc123",
"triggeredByUsername": "bri",
"queuedAt": "2026-06-18T00:59:00Z",
"startedAt": "2026-06-18T01:00:00Z",
"completedAt": "2026-06-18T01:05:00Z"
});
assert_eq!(
run_record_pairs(&run, "run-2"),
vec![
("Run", "run-2".to_string()),
("Status", "SUCCESS".to_string()),
("Workflow", "release".to_string()),
("Job", "publish".to_string()),
("Commit", "abc123".to_string()),
("Triggered by", "bri".to_string()),
("Queued", "2026-06-18T00:59:00Z".to_string()),
("Started", "2026-06-18T01:00:00Z".to_string()),
("Completed", "2026-06-18T01:05:00Z".to_string()),
]
);
assert_eq!(
run_record_pairs(&json!({}), "run-3")[0],
("Run", "run-3".to_string())
);
}
#[test]
fn next_log_chunk_returns_only_unprinted_suffix() {
assert_eq!(next_log_chunk("hello world", 6), Some("world"));
assert_eq!(next_log_chunk("hello", 5), None);
assert_eq!(next_log_chunk("", 0), None);
}
}