use crate::client::Client;
use crate::config;
use crate::output;
use clap::{Args, Subcommand};
use futures::StreamExt;
use serde::Deserialize;
use tokio::io::AsyncBufReadExt;
#[derive(Args)]
pub struct CiArgs {
#[command(subcommand)]
pub command: CiCommand,
}
#[derive(Subcommand)]
pub enum CiCommand {
List {
repo: Option<String>,
#[arg(long)]
status: Option<String>,
#[arg(long, default_value = "20")]
limit: u32,
},
View {
id: String,
#[arg(long)]
repo: Option<String>,
},
Run {
repo: Option<String>,
#[arg(long)]
sha: Option<String>,
#[arg(long)]
branch: Option<String>,
},
#[command(name = "job-view")]
JobView {
id: String,
#[arg(long)]
no_follow: bool,
#[arg(long)]
repo: Option<String>,
},
Cancel {
id: String,
#[arg(long)]
repo: Option<String>,
},
Secrets {
repo: Option<String>,
},
#[command(name = "set-secret")]
SetSecret {
#[arg(long)]
name: String,
#[arg(long)]
value: String,
#[arg(long)]
env: Option<String>,
#[arg(long)]
repo: Option<String>,
},
#[command(name = "delete-secret")]
DeleteSecret {
#[arg(long)]
name: String,
#[arg(long)]
repo: Option<String>,
},
}
#[derive(Debug, Deserialize)]
struct CiRun {
id: Option<String>,
short_id: Option<String>,
status: Option<String>,
commit_sha: Option<String>,
branch: Option<String>,
inserted_at: Option<String>,
duration_seconds: Option<f64>,
validation_status: Option<String>,
validation_messages: Option<Vec<String>>,
jobs: Option<Vec<CiJob>>,
}
#[derive(Debug, Deserialize)]
struct CiJob {
id: Option<String>,
short_id: Option<String>,
name: Option<String>,
status: Option<String>,
step_index: Option<u32>,
started_at: Option<String>,
completed_at: Option<String>,
duration_seconds: Option<f64>,
runner: Option<serde_json::Value>,
exit_code: Option<i32>,
}
pub async fn run(args: CiArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
CiCommand::List {
repo,
status,
limit,
} => list(repo.as_deref(), status.as_deref(), limit).await,
CiCommand::View { id, repo } => view(&id, repo.as_deref()).await,
CiCommand::Run { repo, sha, branch } => {
trigger(repo.as_deref(), sha.as_deref(), branch.as_deref()).await
}
CiCommand::JobView {
id,
no_follow,
repo,
} => job_view(&id, no_follow, repo.as_deref()).await,
CiCommand::Cancel { id, repo } => cancel(&id, repo.as_deref()).await,
CiCommand::Secrets { repo } => list_secrets(repo.as_deref()).await,
CiCommand::SetSecret {
name,
value,
env,
repo,
} => set_secret(repo.as_deref(), &name, &value, env.as_deref()).await,
CiCommand::DeleteSecret { name, repo } => delete_secret(repo.as_deref(), &name).await,
}
}
async fn list(
repo: Option<&str>,
status: Option<&str>,
limit: u32,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut query = vec![("per_page", limit.to_string())];
if let Some(s) = status {
query.push(("status", s.to_string()));
}
let query_refs: Vec<(&str, &str)> = query.iter().map(|(k, v)| (*k, v.as_str())).collect();
let resp: serde_json::Value = client
.get_with_query(&format!("/{org}/{name}/ci/runs"), &query_refs)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let runs: Vec<CiRun> = if let Some(arr) = resp.get("ci_runs") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else if let Some(arr) = resp.get("data") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
serde_json::from_value(resp).unwrap_or_default()
};
output::header(&format!("CI runs ({org}/{name})"));
let rows: Vec<Vec<String>> = runs
.iter()
.map(|r| {
let sha = r
.commit_sha
.as_deref()
.unwrap_or("?")
.chars()
.take(8)
.collect::<String>();
let duration = r
.duration_seconds
.map(|d| format!("{:.0}s", d))
.unwrap_or_default();
vec![
r.short_id
.as_deref()
.unwrap_or(r.id.as_deref().unwrap_or("?"))
.to_string(),
output::colorize_status(r.status.as_deref().unwrap_or("?")),
r.branch.clone().unwrap_or_default(),
sha,
duration,
r.inserted_at
.as_deref()
.map(output::format_time)
.unwrap_or_default(),
]
})
.collect();
output::print_table(
&["ID", "STATUS", "BRANCH", "SHA", "DURATION", "STARTED"],
&rows,
);
Ok(())
}
async fn view(id: &str, repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/ci/runs/{id}")).await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let run: CiRun = if let Some(obj) = resp.get("ci_run") {
serde_json::from_value(obj.clone())?
} else if let Some(obj) = resp.get("data") {
serde_json::from_value(obj.clone())?
} else {
serde_json::from_value(resp)?
};
let display_id = run
.short_id
.as_deref()
.unwrap_or(run.id.as_deref().unwrap_or(id));
output::header(&format!("CI Run {display_id}"));
output::detail(
"Status",
&output::colorize_status(run.status.as_deref().unwrap_or("?")),
);
if let Some(ref status) = run.validation_status {
output::detail("Validation", status);
}
if let Some(ref messages) = run.validation_messages {
if !messages.is_empty() {
for msg in messages {
output::detail(" -", msg);
}
}
}
if let Some(ref branch) = run.branch {
output::detail("Branch", branch);
}
if let Some(ref sha) = run.commit_sha {
output::detail("Commit", sha);
}
if let Some(d) = run.duration_seconds {
output::detail("Duration", &format!("{:.1}s", d));
}
if let Some(ref ts) = run.inserted_at {
output::detail("Started", &output::format_time(ts));
}
if let Some(ref jobs) = run.jobs {
if !jobs.is_empty() {
output::header("Jobs");
let rows: Vec<Vec<String>> = jobs
.iter()
.map(|j| {
let job_id = j
.short_id
.as_deref()
.unwrap_or(j.id.as_deref().unwrap_or("?"))
.to_string();
vec![
job_id,
j.name.clone().unwrap_or_default(),
output::colorize_status(j.status.as_deref().unwrap_or("?")),
j.duration_seconds
.map(|d| format!("{:.1}s", d))
.unwrap_or_default(),
]
})
.collect();
output::print_table(&["ID", "NAME", "STATUS", "DURATION"], &rows);
}
}
Ok(())
}
async fn job_view(
id: &str,
no_follow: bool,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/ci/jobs/{id}")).await?;
let job: CiJob = if let Some(obj) = resp.get("job") {
serde_json::from_value(obj.clone())?
} else if let Some(obj) = resp.get("data") {
serde_json::from_value(obj.clone())?
} else {
serde_json::from_value(resp)?
};
let display_id = job
.short_id
.as_deref()
.unwrap_or(job.id.as_deref().unwrap_or(id));
output::header(&format!("CI Job {display_id}"));
if let Some(ref name) = job.name {
output::detail("Name", name);
}
output::detail(
"Status",
&output::colorize_status(job.status.as_deref().unwrap_or("?")),
);
if let Some(idx) = job.step_index {
output::detail("Step", &idx.to_string());
}
if let Some(d) = job.duration_seconds {
output::detail("Duration", &format!("{:.1}s", d));
}
if let Some(ref runner) = job.runner {
let name = runner
.get("name")
.and_then(|v| v.as_str())
.or_else(|| runner.as_str())
.unwrap_or("?");
output::detail("Runner", name);
}
if let Some(code) = job.exit_code {
output::detail("Exit Code", &code.to_string());
}
if let Some(ref ts) = job.started_at {
output::detail("Started", &output::format_time(ts));
}
if let Some(ref ts) = job.completed_at {
output::detail("Completed", &output::format_time(ts));
}
let log_path = format!("/{org}/{name}/ci/jobs/{id}/logs");
let follow_query = if no_follow { "?follow=false" } else { "" };
let full_path = format!("{log_path}{follow_query}");
eprintln!();
if let Err(e) = stream_job_logs(&client, &full_path).await {
eprintln!("Could not fetch logs: {e}");
eprintln!("(Job metadata above was retrieved successfully.)");
std::process::exit(1);
}
Ok(())
}
async fn stream_job_logs(client: &Client, path: &str) -> Result<(), Box<dyn std::error::Error>> {
let resp = client.get_sse_stream(path).await?;
let stream = resp
.bytes_stream()
.map(|r| r.map_err(std::io::Error::other));
let stream_reader = tokio_util::io::StreamReader::new(stream);
let mut reader = tokio::io::BufReader::new(stream_reader);
let mut line = String::new();
let mut event_type = String::new();
loop {
line.clear();
let bytes_read = reader.read_line(&mut line).await?;
if bytes_read == 0 {
break;
}
let trimmed = line.trim();
if let Some(evt) = trimmed.strip_prefix("event: ") {
event_type = evt.to_string();
} else if let Some(data) = trimmed.strip_prefix("data: ") {
match event_type.as_str() {
"log_line" => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
let content = parsed["content"].as_str().unwrap_or("");
let stream_type = parsed["stream"].as_str().unwrap_or("stdout");
if stream_type == "stderr" {
eprintln!("\x1b[2m{}\x1b[0m", content);
} else {
println!("{}", content);
}
}
}
"done" => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
let status = parsed["status"].as_str().unwrap_or("?");
let exit_code = parsed["exit_code"].as_i64().unwrap_or(-1);
eprintln!("\n--- Job {} (exit code: {}) ---", status, exit_code);
}
break;
}
_ => {}
}
} else if trimmed.starts_with(':') {
}
}
Ok(())
}
async fn cancel(id: &str, repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/ci/runs/{id}/cancel"),
&serde_json::json!({}),
)
.await?;
let status = resp
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("cancelled");
let short_id = resp.get("short_id").and_then(|v| v.as_str()).unwrap_or(id);
output::success(&format!("Cancelled CI run {short_id} (status: {status})"));
Ok(())
}
async fn trigger(
repo: Option<&str>,
sha: Option<&str>,
branch: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut body = serde_json::Map::new();
let branch_name = match branch {
Some(b) => b.to_string(),
None => {
let output = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()?;
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
};
body.insert(
"branch".into(),
serde_json::Value::String(branch_name.clone()),
);
let display_sha;
if let Some(s) = sha {
display_sha = s.to_string();
body.insert(
"commit_sha".into(),
serde_json::Value::String(display_sha.clone()),
);
} else {
display_sha = "(server-resolved)".to_string();
}
let resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/ci/runs"),
&serde_json::Value::Object(body),
)
.await?;
let run_id = resp
.pointer("/ci_run/short_id")
.or_else(|| resp.pointer("/ci_run/id"))
.or_else(|| resp.pointer("/data/short_id"))
.or_else(|| resp.pointer("/data/id"))
.or_else(|| resp.get("id"))
.and_then(|v| v.as_str())
.unwrap_or("?");
output::success(&format!("Triggered CI run {}", run_id));
output::detail("Branch", &branch_name);
output::detail("Commit", &display_sha[..8.min(display_sha.len())]);
Ok(())
}
async fn list_secrets(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/ci/secrets")).await?;
let secrets: Vec<serde_json::Value> = resp
.get("secrets")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
output::header(&format!("CI Secrets ({org}/{name})"));
let rows: Vec<Vec<String>> = secrets
.iter()
.map(|s| {
vec![
s.get("name")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string(),
s.get("environment")
.and_then(|v| v.as_str())
.unwrap_or("*")
.to_string(),
s.get("updated_at")
.and_then(|v| v.as_str())
.map(output::format_time)
.unwrap_or_default(),
]
})
.collect();
output::print_table(&["NAME", "ENVIRONMENT", "UPDATED"], &rows);
Ok(())
}
async fn set_secret(
repo: Option<&str>,
secret_name: &str,
value: &str,
env: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::json!({"name": secret_name, "value": value});
if let Some(e) = env {
payload["environment"] = serde_json::json!(e);
}
let _resp: serde_json::Value = client
.post(&format!("/{org}/{name}/ci/secrets"), &payload)
.await?;
output::success(&format!("Set secret '{secret_name}'"));
Ok(())
}
async fn delete_secret(
repo: Option<&str>,
secret_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
client
.delete_empty(&format!("/{org}/{name}/ci/secrets/{secret_name}"))
.await?;
output::success(&format!("Deleted secret '{secret_name}'"));
Ok(())
}