diff --git a/AGENTS.md b/AGENTS.md index 1550e55b..686a1bb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,7 @@ Every compiled pipeline runs as three sequential jobs: │ ├── configure.rs # `configure` CLI command — orchestration shim atop `src/ado/` │ ├── enable.rs # `enable` CLI command — registers ADO build definitions for compiled pipelines and ensures they are enabled │ ├── list.rs # `list` CLI command — renders matched ADO definitions with their latest-run state (text or JSON) +│ ├── status.rs # `status` CLI command — denser per-pipeline status block (thin renderer over `list`'s data path) │ ├── ado/ # Shared Azure DevOps REST helpers (auth, list/match/PATCH/POST) │ │ └── mod.rs # Used by `configure` and the lifecycle commands (enable, disable, remove, list, run, status, secrets) │ ├── detect.rs # Agentic pipeline detection (helper for `configure`) diff --git a/docs/cli.md b/docs/cli.md index 5d090a52..07c8b5f5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -62,3 +62,9 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg - `--pat ` / `AZURE_DEVOPS_EXT_PAT` env var - PAT for ADO API authentication (Azure CLI fallback if omitted). - `--all` - Include ADO definitions that do not match any local fixture. - `--json` - Emit machine-readable JSON. + +- `status [PATH]` - Per-pipeline status: name, id, folder, `queueStatus`, latest-run summary, and a deep link — one block per matched definition. Read-only. `--json` emits the same shape as `list --json` so scripts can use either. + - `--org ` - Override: Azure DevOps organization (URL or bare org name). Inferred from git remote by default. + - `--project ` - Override: Azure DevOps project name (inferred from git remote by default). + - `--pat ` / `AZURE_DEVOPS_EXT_PAT` env var - PAT for ADO API authentication (Azure CLI fallback if omitted). + - `--json` - Emit machine-readable JSON (same shape as `list --json`). diff --git a/src/ado/mod.rs b/src/ado/mod.rs index 5d4f028e..665c48df 100644 --- a/src/ado/mod.rs +++ b/src/ado/mod.rs @@ -370,24 +370,14 @@ pub fn normalize_ado_yaml_path(path: &str) -> String { /// Strategy: /// 1. Try to match by the `yamlFilename` field in the definition's process config /// 2. Fall back to matching by pipeline name containing the agent name -pub async fn match_definitions( - client: &reqwest::Client, - ctx: &AdoContext, - auth: &AdoAuth, +pub fn match_definitions_in( + definitions: &[DefinitionSummary], detected: &[detect::DetectedPipeline], -) -> Result> { - let definitions = list_definitions(client, ctx, auth).await?; - info!( - "Found {} pipeline definitions in {}/{}", - definitions.len(), - ctx.org_url, - ctx.project - ); - +) -> Vec { let mut matched = Vec::new(); // Log all definition yaml paths for debugging - for def in &definitions { + for def in definitions { let yaml_path = def .process .as_ref() @@ -440,7 +430,7 @@ pub async fn match_definitions( .and_then(|s| s.to_str()) .unwrap_or(""); - match fuzzy_match_by_name(agent_name, &definitions) { + match fuzzy_match_by_name(agent_name, definitions) { FuzzyMatchResult::Single(idx) => { let def = &definitions[idx]; eprintln!( @@ -473,7 +463,29 @@ pub async fn match_definitions( ); } - Ok(matched) + matched +} + +/// Match detected pipeline YAML files to ADO pipeline definitions. +/// +/// Strategy: +/// 1. Try to match by the `yamlFilename` field in the definition's process config +/// 2. Fall back to matching by pipeline name containing the agent name +pub async fn match_definitions( + client: &reqwest::Client, + ctx: &AdoContext, + auth: &AdoAuth, + detected: &[detect::DetectedPipeline], +) -> Result> { + let definitions = list_definitions(client, ctx, auth).await?; + info!( + "Found {} pipeline definitions in {}/{}", + definitions.len(), + ctx.org_url, + ctx.project + ); + + Ok(match_definitions_in(&definitions, detected)) } /// Fetch the human-readable name of a pipeline definition by ID. diff --git a/src/list.rs b/src/list.rs index cac2d09a..8de325c8 100644 --- a/src/list.rs +++ b/src/list.rs @@ -15,8 +15,8 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use crate::ado::{ - AdoAuth, AdoContext, DefinitionSummary, MatchedDefinition, get_latest_build, - list_definitions, match_definitions, resolve_ado_context, resolve_auth, + DefinitionSummary, MatchedDefinition, get_latest_build, list_definitions, + match_definitions_in, resolve_ado_context, resolve_auth, }; use crate::detect; @@ -217,6 +217,9 @@ pub fn render_json(rows: &[ListRow]) -> Result { let array: Vec = rows .iter() .map(|r| { + // Keep this as a raw pass-through for scripting stability. + // Text output trims the leading slash for readability; JSON + // intentionally retains ADO/local-matcher path shape. serde_json::json!({ "name": r.name, "id": r.id, @@ -224,13 +227,7 @@ pub fn render_json(rows: &[ListRow]) -> Result { "queueStatus": r.queue_status, "yamlFilename": r.yaml_filename, "matched": r.matched, - "lastRun": r.last_run.as_ref().map(|lr| serde_json::json!({ - "id": lr.id, - "result": lr.result, - "status": lr.status, - "finishTime": lr.finish_time, - "url": lr.url, - })), + "lastRun": r.last_run.as_ref().map(|lr| &lr.raw), }) }) .collect(); @@ -258,9 +255,7 @@ pub async fn run(opts: ListOptions<'_>) -> Result<()> { let definitions = list_definitions(&client, &ado_ctx, &auth).await?; let detected = detect::detect_pipelines(&repo_path).await.unwrap_or_default(); - let matched = match_definitions(&client, &ado_ctx, &auth, &detected) - .await - .unwrap_or_default(); + let matched = match_definitions_in(&definitions, &detected); // Decide which IDs need a last-build fetch. let target_ids: HashSet = if opts.all { @@ -478,6 +473,14 @@ mod tests { #[test] fn render_json_emits_expected_shape() { + let raw = serde_json::json!({ + "id": 999, + "result": "succeeded", + "status": "completed", + "finishTime": "2026-05-17T08:00:00Z", + "requestedFor": { "displayName": "A User" }, + "triggerInfo": { "ci.sourceSha": "abc123" }, + }); let rows = vec![ListRow { id: 123, name: "Daily noop".to_string(), @@ -491,7 +494,7 @@ mod tests { status: Some("completed".to_string()), finish_time: Some("2026-05-17T08:00:00Z".to_string()), url: Some("https://dev.azure.com/.../999".to_string()), - raw: serde_json::Value::Null, + raw: raw.clone(), }), }]; let out = render_json(&rows).unwrap(); @@ -502,8 +505,7 @@ mod tests { assert_eq!(parsed[0]["queueStatus"], "enabled"); assert_eq!(parsed[0]["yamlFilename"], "/tests/noop.lock.yml"); assert_eq!(parsed[0]["matched"], true); - assert_eq!(parsed[0]["lastRun"]["id"], 999); - assert_eq!(parsed[0]["lastRun"]["result"], "succeeded"); + assert_eq!(parsed[0]["lastRun"], raw); } #[test] diff --git a/src/main.rs b/src/main.rs index 85c5d68d..43ce40d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ mod ndjson; pub mod runtimes; pub mod sanitize; mod safeoutputs; +mod status; mod tools; pub mod validate; @@ -198,6 +199,26 @@ enum Commands { #[arg(long)] json: bool, }, + /// Per-pipeline status: queueStatus + latest-run summary, for every matched definition. + Status { + /// Path to the repository root (defaults to current directory). Used + /// to auto-discover compiled pipelines, same as `compile`. + path: Option, + /// Override: Azure DevOps organization (URL like `https://dev.azure.com/myorg`, + /// or just the org name `myorg`). Inferred from git remote by default. + #[arg(long)] + org: Option, + /// Override: Azure DevOps project name (inferred from git remote by default). + #[arg(long)] + project: Option, + /// PAT for ADO API authentication (prefer setting AZURE_DEVOPS_EXT_PAT env var; + /// Azure CLI fallback if omitted). + #[arg(long, env = "AZURE_DEVOPS_EXT_PAT")] + pat: Option, + /// Emit machine-readable JSON (same shape as `list --json`). + #[arg(long)] + json: bool, + }, } #[derive(Parser, Debug)] @@ -549,6 +570,7 @@ async fn main() -> Result<()> { Some(Commands::Configure { .. }) => "configure", Some(Commands::Enable { .. }) => "enable", Some(Commands::List { .. }) => "list", + Some(Commands::Status { .. }) => "status", None => "ado-aw", }; @@ -699,6 +721,22 @@ async fn main() -> Result<()> { }) .await?; } + Commands::Status { + path, + org, + project, + pat, + json, + } => { + status::run(status::StatusOptions { + org: org.as_deref(), + project: project.as_deref(), + pat: pat.as_deref(), + path: path.as_deref(), + json, + }) + .await?; + } } Ok(()) } diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 00000000..01fca907 --- /dev/null +++ b/src/status.rs @@ -0,0 +1,234 @@ +//! The `status` CLI command. +//! +//! Renders the per-pipeline status for every ADO build definition +//! that matches a local fixture: name, id, folder, queueStatus, the +//! latest-run summary, and a deep link. Read-only. +//! +//! `status` is intentionally a thin renderer over the same data path +//! as `list` — same `list_definitions` + `match_definitions` + +//! `get_latest_build` sequence, just a denser per-pipeline block +//! instead of a table. The `--json` shape is byte-for-byte identical +//! to `list --json` so scripts can use either. + +use anyhow::{Context, Result}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use crate::ado::{ + get_latest_build, list_definitions, match_definitions_in, resolve_ado_context, resolve_auth, +}; +use crate::detect; +use crate::list::{ListRow, build_rows, render_json}; + +/// CLI options for [`run`]. +pub struct StatusOptions<'a> { + pub org: Option<&'a str>, + pub project: Option<&'a str>, + pub pat: Option<&'a str>, + pub path: Option<&'a Path>, + pub json: bool, +} + +/// Pure renderer: dense per-pipeline block. +pub fn render_blocks(ado_org_url: &str, ado_project: &str, rows: &[ListRow]) -> String { + if rows.is_empty() { + return "(no matched definitions)\n".to_string(); + } + + let mut out = String::new(); + for r in rows { + out.push_str(&format!("● {}\n", r.name)); + out.push_str(&format!(" id: {}\n", r.id)); + if let Some(folder) = &r.folder { + out.push_str(&format!(" folder: {}\n", folder)); + } + out.push_str(&format!( + " queueStatus: {}\n", + r.queue_status.as_deref().unwrap_or("?") + )); + if let Some(yaml) = &r.yaml_filename { + out.push_str(&format!(" source: {}\n", yaml.trim_start_matches('/'))); + } + match &r.last_run { + Some(lr) => { + let result = lr + .result + .clone() + .or_else(|| lr.status.clone()) + .unwrap_or_else(|| "in progress".to_string()); + out.push_str(&format!(" last run: build {} — {}", lr.id, result)); + if let Some(t) = &lr.finish_time { + out.push_str(&format!(" @ {}", t)); + } + out.push('\n'); + let url = lr.url.clone().unwrap_or_else(|| { + format!( + "{}/{}/_build/results?buildId={}", + ado_org_url.trim_end_matches('/'), + ado_project, + lr.id, + ) + }); + out.push_str(&format!(" url: {}\n", url)); + } + None => { + out.push_str(" last run: never\n"); + } + } + out.push('\n'); + } + out +} + +/// Run the `status` command. +pub async fn run(opts: StatusOptions<'_>) -> Result<()> { + let repo_path: PathBuf = match opts.path { + Some(p) => tokio::fs::canonicalize(p) + .await + .with_context(|| format!("Could not resolve path: {}", p.display()))?, + None => tokio::fs::canonicalize(".") + .await + .context("Could not resolve current directory")?, + }; + + let auth = resolve_auth(opts.pat).await?; + let ado_ctx = resolve_ado_context(&repo_path, opts.org, opts.project).await?; + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("Failed to create HTTP client")?; + + let definitions = list_definitions(&client, &ado_ctx, &auth).await?; + let detected = detect::detect_pipelines(&repo_path).await.unwrap_or_default(); + let matched = match_definitions_in(&definitions, &detected); + + let target_ids: HashSet = matched.iter().map(|m| m.id).collect(); + let mut last_runs = std::collections::HashMap::new(); + for id in &target_ids { + match get_latest_build(&client, &ado_ctx, &auth, *id).await { + Ok(Some(v)) => { + last_runs.insert(*id, v); + } + Ok(None) => {} + Err(e) => { + eprintln!(" warning: failed to fetch latest build for {}: {:#}", id, e); + } + } + } + + let rows = build_rows(&definitions, &matched, &last_runs, false); + + if opts.json { + println!("{}", render_json(&rows)?); + } else { + print!("{}", render_blocks(&ado_ctx.org_url, &ado_ctx.project, &rows)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::list::LastRun; + + fn row_with_run(id: u64, name: &str, status: Option<&str>, last_run: Option) -> ListRow { + ListRow { + id, + name: name.to_string(), + folder: Some("\\smoke".to_string()), + queue_status: status.map(str::to_string), + yaml_filename: Some(format!("/tests/{}.lock.yml", name)), + matched: true, + last_run, + } + } + + #[test] + fn empty_renders_placeholder() { + let out = render_blocks("https://dev.azure.com/o", "p", &[]); + assert_eq!(out, "(no matched definitions)\n"); + } + + #[test] + fn block_shows_succeeded_run_with_url() { + let row = row_with_run( + 123, + "noop", + Some("enabled"), + Some(LastRun { + id: 999, + result: Some("succeeded".to_string()), + status: Some("completed".to_string()), + finish_time: Some("2026-05-17T08:00:00Z".to_string()), + url: Some("https://dev.azure.com/.../999".to_string()), + raw: serde_json::Value::Null, + }), + ); + let out = render_blocks("https://dev.azure.com/o", "p", &[row]); + assert!(out.contains("● noop")); + assert!(out.contains("id: 123")); + assert!(out.contains("folder: \\smoke")); + assert!(out.contains("queueStatus: enabled")); + assert!(out.contains("source: tests/noop.lock.yml")); + assert!(out.contains("last run: build 999 — succeeded")); + assert!(out.contains("2026-05-17T08:00:00Z")); + assert!(out.contains("https://dev.azure.com/.../999")); + } + + #[test] + fn block_synthesizes_url_when_missing() { + let row = row_with_run( + 42, + "x", + Some("disabled"), + Some(LastRun { + id: 7, + result: Some("failed".to_string()), + status: Some("completed".to_string()), + finish_time: None, + url: None, + raw: serde_json::Value::Null, + }), + ); + let out = render_blocks("https://dev.azure.com/myorg/", "myproject", &[row]); + assert!( + out.contains("https://dev.azure.com/myorg/myproject/_build/results?buildId=7"), + "expected synthesized URL in:\n{out}" + ); + } + + #[test] + fn block_shows_never_when_no_last_run() { + let row = row_with_run(1, "x", Some("enabled"), None); + let out = render_blocks("o", "p", &[row]); + assert!(out.contains("last run: never")); + assert!(!out.contains("url:")); + } + + #[test] + fn block_shows_in_progress_when_no_result_yet() { + let row = row_with_run( + 1, + "x", + Some("enabled"), + Some(LastRun { + id: 10, + result: None, + status: Some("inProgress".to_string()), + finish_time: None, + url: None, + raw: serde_json::Value::Null, + }), + ); + let out = render_blocks("o", "p", &[row]); + assert!(out.contains("build 10 — inProgress")); + } + + #[test] + fn block_uses_question_mark_when_queue_status_missing() { + let row = row_with_run(1, "x", None, None); + let out = render_blocks("o", "p", &[row]); + assert!(out.contains("queueStatus: ?")); + } +} diff --git a/tests/status_integration.rs b/tests/status_integration.rs new file mode 100644 index 00000000..2a362bbf --- /dev/null +++ b/tests/status_integration.rs @@ -0,0 +1,41 @@ +//! Integration tests for `ado-aw status`. + +use std::path::PathBuf; + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")) +} + +#[test] +fn status_help_describes_command() { + let output = std::process::Command::new(binary()) + .args(["status", "--help"]) + .output() + .expect("Failed to run ado-aw status --help"); + assert!(output.status.success(), "--help should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Per-pipeline status"), + "Help text should describe the status command, got:\n{stdout}" + ); + for flag in ["--org", "--project", "--pat", "--json"] { + assert!( + stdout.contains(flag), + "Expected --help to advertise {flag}, got:\n{stdout}" + ); + } +} + +#[test] +fn status_is_in_top_level_help() { + let output = std::process::Command::new(binary()) + .arg("--help") + .output() + .expect("Failed to run ado-aw --help"); + assert!(output.status.success(), "--help should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("status"), + "Top-level --help should mention the status subcommand, got:\n{stdout}" + ); +}