From 745820dc36511788e8287c72292c653e103ee2de Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 17 May 2026 13:30:33 +0100 Subject: [PATCH 1/3] feat(cli): add ado-aw list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements PR 5 of the Phase 1 CLI overhaul. Renders every ADO build definition that matches a local fixture (under PATH) along with its queueStatus, ADO folder, and latest-run summary. Pass `--all` to also include definitions with no matching local fixture. Output defaults to a human-readable table; `--json` emits a stable JSON array suitable for scripting. Implements the `get_latest_build` stub in `src/ado/mod.rs` that PR 1 (#580) introduced. Extends `DefinitionSummary` with a `path` field (ADO folder) that `list_definitions` already requests via `includeAllProperties=true`. CLI surface: ado-aw list [PATH] --org --project --pat --all --json Design: The decision logic is split into three pure functions — `build_rows`, `render_text`, `render_json` — covered by 12 unit tests including text/JSON snapshots, `--all` filtering, sort order, and `LastRun::from_json` field extraction (with fallback from `_links.web.href` to top-level `url`). Latest-build fetches are sequential for now; a bounded fanout via `tokio::task::JoinSet` is a straightforward improvement once we have projects with 50+ matched pipelines, but premature for the current scale. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 1 + docs/cli.md | 7 + src/ado/mod.rs | 51 +++- src/enable.rs | 1 + src/list.rs | 524 ++++++++++++++++++++++++++++++++++++++ src/main.rs | 43 ++++ tests/list_integration.rs | 41 +++ 7 files changed, 663 insertions(+), 5 deletions(-) create mode 100644 src/list.rs create mode 100644 tests/list_integration.rs diff --git a/AGENTS.md b/AGENTS.md index 49561a50..1550e55b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,7 @@ Every compiled pipeline runs as three sequential jobs: │ ├── mcp.rs # SafeOutputs MCP server (stdio + HTTP) │ ├── 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) │ ├── 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 e4144da3..5d090a52 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -55,3 +55,10 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg - `--token ` - The token value for `--also-set-token`. Falls back to `$GITHUB_TOKEN`, then to an interactive prompt. Requires `--also-set-token`. **Source-repo scope (Phase 1):** `enable` requires the local git remote to be an Azure DevOps Git remote (the source repo is what gets registered as the definition's repository). GitHub-hosted source repos are gated on a follow-up. + +- `list [PATH]` - Render every ADO build definition that matches a local fixture (under `PATH`) along with its `queueStatus`, ADO folder, and latest-run summary. Pass `--all` to also include definitions with no matching local fixture. Output defaults to a human-readable table; `--json` emits a stable JSON array suitable for scripting. + - `--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). + - `--all` - Include ADO definitions that do not match any local fixture. + - `--json` - Emit machine-readable JSON. diff --git a/src/ado/mod.rs b/src/ado/mod.rs index d7793ddb..5d4f028e 100644 --- a/src/ado/mod.rs +++ b/src/ado/mod.rs @@ -205,6 +205,10 @@ pub struct DefinitionSummary { /// [`list_definitions`]). Older/cached responses may omit it. #[serde(rename = "queueStatus")] pub queue_status: Option, + /// ADO folder path (e.g. `\smoke`, `\`). Populated when + /// `includeAllProperties=true`. May be absent on older API versions. + #[serde(default)] + pub path: Option, } #[derive(Debug, Deserialize)] @@ -1065,12 +1069,47 @@ pub async fn get_build( /// Calls `GET /_apis/build/builds?definitions={id}&$top=1&api-version=7.1` /// and returns the first result (or `None` if the definition has never run). pub async fn get_latest_build( - _client: &reqwest::Client, - _ctx: &AdoContext, - _auth: &AdoAuth, - _definition_id: u64, + client: &reqwest::Client, + ctx: &AdoContext, + auth: &AdoAuth, + definition_id: u64, ) -> Result> { - anyhow::bail!("not yet implemented: filled in by PR 5 (ado-aw list) or PR 7 (ado-aw status)") + let url = format!( + "{}/{}/_apis/build/builds?definitions={}&$top=1&api-version=7.1", + ctx.org_url.trim_end_matches('/'), + ctx.project, + definition_id, + ); + + debug!("GET latest build for definition {}: {}", definition_id, url); + + let resp = auth + .apply(client.get(&url)) + .send() + .await + .with_context(|| format!("Failed to fetch latest build for definition {}", definition_id))?; + + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!( + "ADO API returned {} when fetching latest build for definition {}: {}", + status, + definition_id, + body + ); + } + + let body: serde_json::Value = resp + .json() + .await + .with_context(|| format!("Failed to parse builds response for {}", definition_id))?; + + Ok(body + .get("value") + .and_then(|v| v.as_array()) + .and_then(|a| a.first()) + .cloned()) } #[cfg(test)] @@ -1185,6 +1224,7 @@ mod tests { name: name.to_string(), process: None, queue_status: None, + path: None, } } @@ -1196,6 +1236,7 @@ mod tests { yaml_filename: Some(yaml_filename.to_string()), }), queue_status: None, + path: None, } } diff --git a/src/enable.rs b/src/enable.rs index b4e27cf1..726971d7 100644 --- a/src/enable.rs +++ b/src/enable.rs @@ -452,6 +452,7 @@ mod tests { yaml_filename: Some(y.to_string()), }), queue_status: status.map(str::to_string), + path: None, } } diff --git a/src/list.rs b/src/list.rs new file mode 100644 index 00000000..cac2d09a --- /dev/null +++ b/src/list.rs @@ -0,0 +1,524 @@ +//! The `list` CLI command. +//! +//! Renders the current state of every ADO build definition that +//! matches a local fixture (or *all* definitions with `--all`). +//! Phase 1 of the pipeline-lifecycle CLI family — see `docs/cli.md`. +//! +//! Output: +//! +//! - Default: human-readable text table. +//! - `--json`: JSON array, stable shape suitable for programmatic +//! consumption. + +use anyhow::{Context, Result}; +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, +}; +use crate::detect; + +/// One row of the rendered output. +/// +/// `last_run` is intentionally untyped here — ADO build records have +/// many optional fields and the JSON renderer passes them through +/// verbatim, while the text renderer only inspects `result`. Pure +/// data so we can snapshot-test both renderers. +#[derive(Debug, Clone, PartialEq)] +pub struct ListRow { + pub id: u64, + pub name: String, + pub folder: Option, + pub queue_status: Option, + pub yaml_filename: Option, + pub matched: bool, + pub last_run: Option, +} + +/// Latest-build summary, projected to the fields the text renderer +/// uses. JSON output passes the full `serde_json::Value` through so +/// callers don't lose access to fields we don't currently surface. +#[derive(Debug, Clone, PartialEq)] +pub struct LastRun { + pub id: u64, + pub result: Option, + pub status: Option, + pub finish_time: Option, + pub url: Option, + /// Raw value for JSON pass-through. + pub raw: serde_json::Value, +} + +impl LastRun { + /// Project an ADO `build` JSON value into a [`LastRun`]. Returns + /// `None` when the JSON has no usable `id` field. + pub fn from_json(value: serde_json::Value) -> Option { + let id = value.get("id").and_then(|v| v.as_u64())?; + Some(Self { + id, + result: value + .get("result") + .and_then(|v| v.as_str()) + .map(str::to_string), + status: value + .get("status") + .and_then(|v| v.as_str()) + .map(str::to_string), + finish_time: value + .get("finishTime") + .and_then(|v| v.as_str()) + .map(str::to_string), + url: value + .get("_links") + .and_then(|l| l.get("web")) + .and_then(|w| w.get("href")) + .and_then(|v| v.as_str()) + .map(str::to_string) + .or_else(|| { + value + .get("url") + .and_then(|v| v.as_str()) + .map(str::to_string) + }), + raw: value, + }) + } +} + +/// CLI options for [`run`]. +pub struct ListOptions<'a> { + pub org: Option<&'a str>, + pub project: Option<&'a str>, + pub pat: Option<&'a str>, + pub path: Option<&'a Path>, + pub all: bool, + pub json: bool, +} + +/// Pure function: assemble [`ListRow`]s from raw inputs. +/// +/// - `definitions`: project-wide listing from `list_definitions`. +/// - `matched`: subset that maps to a local fixture (yaml-path or +/// pipeline-name). +/// - `last_runs`: latest-build JSON keyed by definition id. Missing +/// entries become `last_run: None`. +/// - `include_unmatched`: when `false`, definitions that aren't in +/// `matched` are filtered out (default for `list`). +/// +/// Row ordering: matched rows first (sorted by name), then unmatched +/// rows (also sorted by name) when included. +pub fn build_rows( + definitions: &[DefinitionSummary], + matched: &[MatchedDefinition], + last_runs: &std::collections::HashMap, + include_unmatched: bool, +) -> Vec { + let matched_ids: HashSet = matched.iter().map(|m| m.id).collect(); + let yaml_by_id: std::collections::HashMap = matched + .iter() + .filter(|m| !m.yaml_path.is_empty()) + .map(|m| (m.id, m.yaml_path.clone())) + .collect(); + + let mut rows: Vec = definitions + .iter() + .filter(|d| include_unmatched || matched_ids.contains(&d.id)) + .map(|d| { + let yaml_filename = yaml_by_id.get(&d.id).cloned().or_else(|| { + d.process + .as_ref() + .and_then(|p| p.yaml_filename.clone()) + }); + let last_run = last_runs.get(&d.id).cloned().and_then(LastRun::from_json); + ListRow { + id: d.id, + name: d.name.clone(), + folder: d.path.clone(), + queue_status: d.queue_status.clone(), + yaml_filename, + matched: matched_ids.contains(&d.id), + last_run, + } + }) + .collect(); + + rows.sort_by(|a, b| { + b.matched + .cmp(&a.matched) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + rows +} + +/// Pure function: render a list of rows as a text table. +pub fn render_text(rows: &[ListRow]) -> String { + if rows.is_empty() { + return "(no definitions)\n".to_string(); + } + let headers = ["NAME", "ID", "FOLDER", "STATUS", "LAST RUN", "SOURCE"]; + let mut widths = headers.map(|h| h.chars().count()); + let str_rows: Vec<[String; 6]> = rows + .iter() + .map(|r| { + [ + r.name.clone(), + r.id.to_string(), + r.folder.clone().unwrap_or_default(), + r.queue_status.clone().unwrap_or_else(|| "?".to_string()), + r.last_run + .as_ref() + .map(|lr| { + lr.result + .clone() + .or_else(|| lr.status.clone()) + .unwrap_or_else(|| "in progress".to_string()) + }) + .unwrap_or_else(|| "never".to_string()), + r.yaml_filename + .clone() + .map(|y| y.trim_start_matches('/').to_string()) + .unwrap_or_default(), + ] + }) + .collect(); + for cells in &str_rows { + for (i, cell) in cells.iter().enumerate() { + widths[i] = widths[i].max(cell.chars().count()); + } + } + let mut out = String::new(); + write_row(&mut out, &headers.map(str::to_string), &widths); + for cells in &str_rows { + write_row(&mut out, cells, &widths); + } + out +} + +fn write_row(out: &mut String, cells: &[String; 6], widths: &[usize; 6]) { + for (i, cell) in cells.iter().enumerate() { + if i > 0 { + out.push_str(" "); + } + let pad = widths[i].saturating_sub(cell.chars().count()); + out.push_str(cell); + if i < cells.len() - 1 { + for _ in 0..pad { + out.push(' '); + } + } + } + out.push('\n'); +} + +/// Pure function: render the rows as JSON. +pub fn render_json(rows: &[ListRow]) -> Result { + let array: Vec = rows + .iter() + .map(|r| { + serde_json::json!({ + "name": r.name, + "id": r.id, + "folder": r.folder, + "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, + })), + }) + }) + .collect(); + serde_json::to_string_pretty(&array).context("Failed to serialize list rows as JSON") +} + +/// Run the `list` command. +pub async fn run(opts: ListOptions<'_>) -> 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(&client, &ado_ctx, &auth, &detected) + .await + .unwrap_or_default(); + + // Decide which IDs need a last-build fetch. + let target_ids: HashSet = if opts.all { + definitions.iter().map(|d| d.id).collect() + } else { + matched.iter().map(|m| m.id).collect() + }; + + // Sequential fetch (small N; bounded fanout via JoinSet is a + // straightforward future improvement once we have a project with + // 50+ matched pipelines). + 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, opts.all); + + if opts.json { + println!("{}", render_json(&rows)?); + } else { + print!("{}", render_text(&rows)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ado::{MatchMethod, ProcessInfo}; + use std::collections::HashMap; + + fn def(id: u64, name: &str, folder: Option<&str>, yaml: Option<&str>, status: Option<&str>) -> DefinitionSummary { + DefinitionSummary { + id, + name: name.to_string(), + process: yaml.map(|y| ProcessInfo { + yaml_filename: Some(y.to_string()), + }), + queue_status: status.map(str::to_string), + path: folder.map(str::to_string), + } + } + + fn matched(id: u64, name: &str, yaml: &str) -> MatchedDefinition { + MatchedDefinition { + id, + name: name.to_string(), + match_method: MatchMethod::YamlPath, + yaml_path: yaml.to_string(), + } + } + + // ============ LastRun::from_json ============ + + #[test] + fn last_run_extracts_fields() { + let v = serde_json::json!({ + "id": 1234, + "result": "succeeded", + "status": "completed", + "finishTime": "2026-05-17T08:00:00Z", + "_links": { "web": { "href": "https://dev.azure.com/.../1234" } } + }); + let lr = LastRun::from_json(v).unwrap(); + assert_eq!(lr.id, 1234); + assert_eq!(lr.result.as_deref(), Some("succeeded")); + assert_eq!(lr.status.as_deref(), Some("completed")); + assert_eq!(lr.finish_time.as_deref(), Some("2026-05-17T08:00:00Z")); + assert_eq!(lr.url.as_deref(), Some("https://dev.azure.com/.../1234")); + } + + #[test] + fn last_run_falls_back_to_top_level_url() { + let v = serde_json::json!({ + "id": 7, + "url": "https://dev.azure.com/.../7" + }); + let lr = LastRun::from_json(v).unwrap(); + assert_eq!(lr.url.as_deref(), Some("https://dev.azure.com/.../7")); + } + + #[test] + fn last_run_returns_none_when_id_missing() { + let v = serde_json::json!({ "result": "succeeded" }); + assert!(LastRun::from_json(v).is_none()); + } + + // ============ build_rows ============ + + #[test] + fn build_rows_default_filters_unmatched() { + let defs = vec![ + def(1, "matched", Some("\\smoke"), Some("/a.yml"), Some("enabled")), + def(2, "unmatched", Some("\\other"), Some("/b.yml"), Some("enabled")), + ]; + let m = vec![matched(1, "matched", "/a.yml")]; + let rows = build_rows(&defs, &m, &HashMap::new(), false); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, 1); + assert!(rows[0].matched); + } + + #[test] + fn build_rows_all_flag_includes_unmatched() { + let defs = vec![ + def(1, "matched", Some("\\smoke"), Some("/a.yml"), Some("enabled")), + def(2, "unmatched", Some("\\other"), Some("/b.yml"), Some("disabled")), + ]; + let m = vec![matched(1, "matched", "/a.yml")]; + let rows = build_rows(&defs, &m, &HashMap::new(), true); + assert_eq!(rows.len(), 2); + // Matched rows sort first. + assert!(rows[0].matched); + assert!(!rows[1].matched); + } + + #[test] + fn build_rows_sorts_within_group_by_name_case_insensitive() { + let defs = vec![ + def(1, "Zebra", None, None, None), + def(2, "alpha", None, None, None), + def(3, "Beta", None, None, None), + ]; + let m = vec![matched(1, "Zebra", "/z.yml"), matched(2, "alpha", "/a.yml"), matched(3, "Beta", "/b.yml")]; + let rows = build_rows(&defs, &m, &HashMap::new(), false); + assert_eq!(rows.iter().map(|r| r.name.as_str()).collect::>(), vec!["alpha", "Beta", "Zebra"]); + } + + #[test] + fn build_rows_attaches_last_run() { + let defs = vec![def(1, "x", None, Some("/x.yml"), Some("enabled"))]; + let m = vec![matched(1, "x", "/x.yml")]; + let mut runs = HashMap::new(); + runs.insert( + 1u64, + serde_json::json!({ "id": 99, "result": "succeeded" }), + ); + let rows = build_rows(&defs, &m, &runs, false); + assert_eq!(rows[0].last_run.as_ref().unwrap().id, 99); + assert_eq!( + rows[0] + .last_run + .as_ref() + .unwrap() + .result + .as_deref(), + Some("succeeded") + ); + } + + // ============ render_text ============ + + #[test] + fn render_text_includes_headers_and_data() { + let rows = vec![ListRow { + id: 123, + name: "Daily noop".to_string(), + folder: Some("\\smoke".to_string()), + queue_status: Some("enabled".to_string()), + yaml_filename: Some("/tests/noop.lock.yml".to_string()), + matched: true, + last_run: Some(LastRun { + id: 999, + result: Some("succeeded".to_string()), + status: None, + finish_time: None, + url: None, + raw: serde_json::Value::Null, + }), + }]; + let out = render_text(&rows); + assert!(out.contains("NAME")); + assert!(out.contains("ID")); + assert!(out.contains("FOLDER")); + assert!(out.contains("STATUS")); + assert!(out.contains("Daily noop")); + assert!(out.contains("123")); + assert!(out.contains("\\smoke")); + assert!(out.contains("enabled")); + assert!(out.contains("succeeded")); + // Yaml source rendered without the leading slash. + assert!(out.contains("tests/noop.lock.yml")); + } + + #[test] + fn render_text_uses_never_when_no_last_run() { + let rows = vec![ListRow { + id: 1, + name: "x".to_string(), + folder: None, + queue_status: Some("enabled".to_string()), + yaml_filename: None, + matched: true, + last_run: None, + }]; + let out = render_text(&rows); + assert!(out.contains("never")); + } + + #[test] + fn render_text_empty_returns_placeholder() { + assert_eq!(render_text(&[]), "(no definitions)\n"); + } + + // ============ render_json ============ + + #[test] + fn render_json_emits_expected_shape() { + let rows = vec![ListRow { + id: 123, + name: "Daily noop".to_string(), + folder: Some("\\smoke".to_string()), + queue_status: Some("enabled".to_string()), + yaml_filename: Some("/tests/noop.lock.yml".to_string()), + matched: true, + last_run: 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_json(&rows).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(parsed[0]["name"], "Daily noop"); + assert_eq!(parsed[0]["id"], 123); + assert_eq!(parsed[0]["folder"], "\\smoke"); + 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"); + } + + #[test] + fn render_json_lastrun_is_null_when_missing() { + let rows = vec![ListRow { + id: 1, + name: "x".to_string(), + folder: None, + queue_status: None, + yaml_filename: None, + matched: true, + last_run: None, + }]; + let out = render_json(&rows).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert!(parsed[0]["lastRun"].is_null()); + } +} diff --git a/src/main.rs b/src/main.rs index 9d19f316..85c5d68d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod execute; mod fuzzy_schedule; mod hash; mod init; +mod list; mod logging; mod mcp; mod ndjson; @@ -174,6 +175,29 @@ enum Commands { #[arg(long, requires = "also_set_token")] token: Option, }, + /// List ADO build definitions (with their latest-run state) that match local fixtures. + List { + /// 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, + /// Include ADO definitions that do not match any local fixture. + #[arg(long)] + all: bool, + /// Emit machine-readable JSON instead of the text table. + #[arg(long)] + json: bool, + }, } #[derive(Parser, Debug)] @@ -524,6 +548,7 @@ async fn main() -> Result<()> { Some(Commands::Init { .. }) => "init", Some(Commands::Configure { .. }) => "configure", Some(Commands::Enable { .. }) => "enable", + Some(Commands::List { .. }) => "list", None => "ado-aw", }; @@ -656,6 +681,24 @@ async fn main() -> Result<()> { }) .await?; } + Commands::List { + path, + org, + project, + pat, + all, + json, + } => { + list::run(list::ListOptions { + org: org.as_deref(), + project: project.as_deref(), + pat: pat.as_deref(), + path: path.as_deref(), + all, + json, + }) + .await?; + } } Ok(()) } diff --git a/tests/list_integration.rs b/tests/list_integration.rs new file mode 100644 index 00000000..90275da8 --- /dev/null +++ b/tests/list_integration.rs @@ -0,0 +1,41 @@ +//! Integration tests for `ado-aw list`. + +use std::path::PathBuf; + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")) +} + +#[test] +fn list_help_describes_command() { + let output = std::process::Command::new(binary()) + .args(["list", "--help"]) + .output() + .expect("Failed to run ado-aw list --help"); + assert!(output.status.success(), "--help should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("List ADO build definitions"), + "Help text should describe the list command, got:\n{stdout}" + ); + for flag in ["--org", "--project", "--pat", "--all", "--json"] { + assert!( + stdout.contains(flag), + "Expected --help to advertise {flag}, got:\n{stdout}" + ); + } +} + +#[test] +fn list_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("list"), + "Top-level --help should mention the list subcommand, got:\n{stdout}" + ); +} From 037796063851634cf2465884fb67b2886ffaacea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 13:20:20 +0000 Subject: [PATCH 2/3] fix(list): preserve raw lastRun json and warn on match failures Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/8d9f7052-79c9-4e0c-bbb0-d8098d17b127 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/list.rs | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/list.rs b/src/list.rs index cac2d09a..4af375f8 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, + 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,13 @@ 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 match_definitions(&client, &ado_ctx, &auth, &detected).await { + Ok(m) => m, + Err(e) => { + eprintln!(" warning: could not match definitions: {:#}", e); + Vec::new() + } + }; // Decide which IDs need a last-build fetch. let target_ids: HashSet = if opts.all { @@ -478,6 +479,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 +500,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 +511,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] From 29425a8e09a2e136b74c7bb9e932a5d20d7f6fec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 13:24:36 +0000 Subject: [PATCH 3/3] chore(list): improve unmatched-definition warning context Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/8d9f7052-79c9-4e0c-bbb0-d8098d17b127 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/list.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/list.rs b/src/list.rs index 4af375f8..39f4d643 100644 --- a/src/list.rs +++ b/src/list.rs @@ -258,7 +258,10 @@ pub async fn run(opts: ListOptions<'_>) -> Result<()> { let matched = match match_definitions(&client, &ado_ctx, &auth, &detected).await { Ok(m) => m, Err(e) => { - eprintln!(" warning: could not match definitions: {:#}", e); + eprintln!( + " warning: failed to match local pipeline files with ADO definitions: {:#}; continuing with unmatched results", + e + ); Vec::new() } };