ref:17fe32ecea44b44586e1a589e8c5ca034e2c8cf9

feat(cli): add short_id display, ci job-view with SSE streaming, ci cancel

- Update CiRun/CiJob structs with short_id, validation_status, validation_messages, step_index, started_at, completed_at fields - ci list: display short_id instead of truncated UUID - ci view: use org/repo API path, show short_id and validation info - Add get_sse_stream() to Client for SSE endpoint consumption - Add ci job-view: fetch job metadata + stream build logs via SSE with stderr dim styling and done event handling - Add ci cancel: POST to cancel a running CI run - Fix all clippy warnings across ci, commit, issue, pr, requirement, and ssh_key commands (redundant closures, io_other_error) - Add tokio-util and reqwest stream feature for SSE support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SHA: 17fe32ecea44b44586e1a589e8c5ca034e2c8cf9
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-03-21 18:18
Parents: 86bd78c
9 files changed +323 -32
Type
Cargo.lock +29 −0
@@ -80,5 +80,6 @@
"tabled",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"url",
]
@@ -1175,12 +1176,14 @@
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
]
@@ -1576,6 +1579,19 @@
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1811,6 +1827,19 @@
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
Cargo.toml +2 −1
@@ -11,7 +11,8 @@
[dependencies]
clap = { version = "4", features = ["derive"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls", "multipart"], default-features = false }
reqwest = { version = "0.12", features = ["json", "rustls-tls", "multipart", "stream"], default-features = false }
tokio-util = { version = "0.7", features = ["io"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
src/client.rs +20 −0
@@ -155,6 +155,26 @@
}
}
/// GET that returns a raw response for SSE streaming (text/event-stream).
pub async fn get_sse_stream(&self, path: &str) -> Result<reqwest::Response, ApiError> {
let resp = self
.http
.get(self.url(path))
.header("Accept", "text/event-stream")
.send()
.await?;
let status = resp.status();
if status.is_success() {
Ok(resp)
} else {
let text = resp.text().await.unwrap_or_default();
Err(ApiError::Api {
status: status.as_u16(),
message: text,
})
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
src/commands/ci.rs +264 −24
@@ -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(())
}
src/commands/commit.rs +1 −1
@@ -96,7 +96,7 @@
let time = c
.time
.as_deref()
.map(|t| output::format_time(t))
.map(output::format_time)
.unwrap_or_default();
vec![sha, msg, author, time]
src/commands/issue.rs +2 −2
@@ -420,7 +420,7 @@
for comment in &comments {
let author = comment.pointer("/author/email").and_then(|v| v.as_str()).unwrap_or("unknown");
let time = comment.get("inserted_at").and_then(|v| v.as_str()).map(|t| output::format_time(t)).unwrap_or_default();
let time = comment.get("inserted_at").and_then(|v| v.as_str()).map(output::format_time).unwrap_or_default();
let body = comment.get("body").and_then(|v| v.as_str()).unwrap_or("");
println!("\n {} — {}", author, time);
println!(" {body}");
@@ -437,7 +437,7 @@
let rows: Vec<Vec<String>> = milestones.iter().map(|m| {
vec![
m.get("title").and_then(|v| v.as_str()).unwrap_or("?").to_string(),
m.get("state").and_then(|v| v.as_str()).map(|s| output::colorize_status(s)).unwrap_or_default(),
m.get("state").and_then(|v| v.as_str()).map(output::colorize_status).unwrap_or_default(),
format!("{}/{}", m.get("closed_issues").and_then(|v| v.as_u64()).unwrap_or(0), m.get("open_issues").and_then(|v| v.as_u64()).unwrap_or(0) + m.get("closed_issues").and_then(|v| v.as_u64()).unwrap_or(0)),
format!("{}%", m.get("progress_percent").and_then(|v| v.as_f64()).unwrap_or(0.0) as u32),
m.get("due_date").and_then(|v| v.as_str()).unwrap_or("none").to_string(),
src/commands/pr.rs +2 −2
@@ -399,10 +399,10 @@
output::header(&format!("Reviews on PR #{number}"));
let rows: Vec<Vec<String>> = reviews.iter().map(|r| {
vec![
r.get("state").and_then(|v| v.as_str()).map(|s| output::colorize_status(s)).unwrap_or_default(),
r.get("state").and_then(|v| v.as_str()).map(output::colorize_status).unwrap_or_default(),
r.pointer("/author/email").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(),
r.get("body").and_then(|v| v.as_str()).unwrap_or("").lines().next().unwrap_or("").to_string(),
r.get("inserted_at").and_then(|v| v.as_str()).map(|t| output::format_time(t)).unwrap_or_default(),
r.get("inserted_at").and_then(|v| v.as_str()).map(output::format_time).unwrap_or_default(),
]
}).collect();
output::print_table(&["STATE", "AUTHOR", "BODY", "DATE"], &rows);
src/commands/requirement.rs +1 −0
@@ -274,6 +274,7 @@
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn create(
repo: Option<&str>,
requirement_id: &str,
src/commands/ssh_key.rs +2 −2
@@ -63,11 +63,11 @@
k.fingerprint.clone().unwrap_or_default(),
k.last_used_at
.as_deref()
.map(|t| output::format_time(t))
.map(output::format_time)
.unwrap_or("never".to_string()),
k.inserted_at
.as_deref()
.map(|t| output::format_time(t))
.map(output::format_time)
.unwrap_or_default(),
]
})