@@ -2,7 +2,9 @@
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 {
@@ -25,8 +27,11 @@
},
/// View a CI run
View {
/// Run short ID or UUID
/// Run ID
id: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Trigger a new CI run
Run {
@@ -39,6 +44,26 @@
#[arg(long)]
branch: Option<String>,
},
/// View a CI job and stream its build logs
#[command(name = "job-view")]
JobView {
/// Job short ID or UUID
id: String,
/// Don't follow running jobs, just dump existing logs
#[arg(long)]
no_follow: bool,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Cancel a running CI run
Cancel {
/// Run short ID or UUID
id: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// List CI secrets (names only, values hidden)
Secrets {
/// Repository (org/repo)
@@ -75,21 +100,29 @@
#[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)]
#[allow(dead_code)]
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<String>,
exit_code: Option<i32>,
}
pub async fn run(args: CiArgs) -> Result<(), Box<dyn std::error::Error>> {
@@ -99,12 +132,25 @@
status,
limit,
} => list(repo.as_deref(), status.as_deref(), limit).await,
CiCommand::View { id, repo } => view(&id, repo.as_deref()).await,
CiCommand::View { id } => view(&id).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,
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
}
}
}
@@ -154,7 +200,10 @@
.unwrap_or_default();
vec![
r.short_id
.as_deref()
.unwrap_or(r.id.as_deref().unwrap_or("?"))
r.id.as_deref().unwrap_or("?").chars().take(8).collect(),
.to_string(),
output::colorize_status(r.status.as_deref().unwrap_or("?")),
r.branch.clone().unwrap_or_default(),
sha,
@@ -175,10 +224,13 @@
Ok(())
}
async fn view(id: &str) -> Result<(), Box<dyn std::error::Error>> {
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}"))
let resp: serde_json::Value = client.get(&format!("/ci/runs/{id}")).await?;
.await?;
let run: CiRun = if let Some(obj) = resp.get("ci_run") {
serde_json::from_value(obj.clone())?
@@ -188,12 +240,26 @@
serde_json::from_value(resp)?
};
output::header(&format!("CI Run {}", run.id.as_deref().unwrap_or(id)));
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);
}
@@ -213,7 +279,13 @@
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
@@ -222,13 +294,152 @@
]
})
.collect();
output::print_table(&["NAME", "STATUS", "DURATION"], &rows);
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)?;
// Fetch job metadata
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 {
output::detail("Runner", runner);
}
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));
}
// Stream logs
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!(); // blank line before logs
stream_job_logs(&client, &full_path).await?;
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" {
// dim style for 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(':') {
// SSE comment (keepalive), ignore
}
}
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)?;
client
.post_empty(&format!("/{org}/{name}/ci/runs/{id}/cancel"))
.await?;
output::success(&format!("Cancelled CI run {id}"));
Ok(())
}
async fn trigger(
repo: Option<&str>,
sha: Option<&str>,
@@ -277,7 +488,9 @@
.await?;
let run_id = resp
.pointer("/ci_run/id")
.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())
@@ -285,7 +498,7 @@
output::success(&format!(
"Triggered CI run {}",
&run_id[..run_id.len().min(10)]
&run_id[..8.min(run_id.len())]
));
output::detail("Branch", &branch_name);
output::detail("Commit", &commit_sha[..8.min(commit_sha.len())]);
@@ -297,35 +510,62 @@
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")
let secrets: Vec<serde_json::Value> = resp.get("secrets").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
.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(|t| output::format_time(t)).unwrap_or_default(),
]
}).collect();
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>> {
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?;
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>> {
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?;
client
.delete_empty(&format!("/{org}/{name}/ci/secrets/{secret_name}"))
.await?;
output::success(&format!("Deleted secret '{secret_name}'"));
Ok(())
}