diff --git a/AGENTS.md b/AGENTS.md index 49561a50..60794148 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 +│ ├── disable.rs # `disable` CLI command — sets queueStatus to disabled (default) or paused on matched definitions │ ├── 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..f0ad6a55 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. + +- `disable [PATH]` - Set `queueStatus` to `disabled` (default) or `paused` on every ADO build definition that matches a local fixture under `PATH`. Refuses to touch any ADO definition that is not the target of a local fixture match — that safety property falls naturally out of the same yaml-path + name match used by `configure`. Skips definitions that are already at the requested status; fail-soft per fixture; exits non-zero if any patch failed or if zero local fixtures matched ADO definitions. + - `--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). + - `--paused` - Use `queueStatus: paused` instead of `disabled`. Paused definitions still queue scheduled runs but the queue is held; disabled definitions reject all queue requests. + - `--dry-run` - Print the planned `from → to` transitions without calling the ADO API. diff --git a/src/ado/mod.rs b/src/ado/mod.rs index d7793ddb..81bcf5d8 100644 --- a/src/ado/mod.rs +++ b/src/ado/mod.rs @@ -238,6 +238,12 @@ pub struct MatchedDefinition { pub name: String, pub match_method: MatchMethod, pub yaml_path: String, + /// `enabled`, `disabled`, `paused`, or `None` when the matcher + /// couldn't read the field (explicit-ID matches, older API + /// responses). Populated from `DefinitionSummary::queue_status` + /// when available, so command-level decision logic can skip + /// already-at-target definitions without an extra HTTP round-trip. + pub queue_status: Option, } /// List all build definitions in the project, handling pagination. @@ -425,6 +431,7 @@ pub async fn match_definitions( name: def.name.clone(), match_method: MatchMethod::YamlPath, yaml_path: yaml_path_normalized.to_string(), + queue_status: def.queue_status.clone(), }); continue; } @@ -448,6 +455,7 @@ pub async fn match_definitions( name: def.name.clone(), match_method: MatchMethod::PipelineName, yaml_path: yaml_path_normalized.to_string(), + queue_status: def.queue_status.clone(), }); continue; } @@ -752,6 +760,7 @@ pub async fn resolve_definitions( name, match_method: MatchMethod::Explicit, yaml_path: String::new(), + queue_status: None, }); } return Ok(Some(matched)); diff --git a/src/disable.rs b/src/disable.rs new file mode 100644 index 00000000..e8ebc186 --- /dev/null +++ b/src/disable.rs @@ -0,0 +1,341 @@ +//! The `disable` CLI command. +//! +//! Sets `queueStatus` to `disabled` (default) or `paused` on every ADO +//! build definition matched against a local fixture. Phase 1 of the +//! pipeline-lifecycle CLI family — see `docs/cli.md`. +//! +//! Scope (Phase 1): +//! +//! - Only touches ADO definitions that map to a local fixture. This +//! safety property falls naturally out of [`match_definitions`] — +//! definitions without a local fixture are never in the returned +//! set. +//! - No-op (skip) when the current `queueStatus` already matches the +//! target. + +use anyhow::{Context, Result}; +use log::debug; +use std::path::{Path, PathBuf}; + +use crate::ado::{ + MatchedDefinition, match_definitions, patch_queue_status, resolve_ado_context, resolve_auth, +}; +use crate::detect; + +/// Which `queueStatus` value the operator wants to land on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Target { + Disabled, + Paused, +} + +impl Target { + pub fn as_str(&self) -> &'static str { + match self { + Target::Disabled => "disabled", + Target::Paused => "paused", + } + } +} + +/// Outcome of inspecting one matched definition against the operator's +/// requested target. +/// +/// Pure data — no HTTP, no auth, no IO. Built by [`decide_action`] so +/// the decision logic can be exercised without touching the network. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Already at the requested status; nothing to do. + Skip { id: u64, name: String, reason: String }, + /// `queueStatus` needs to be patched. + Patch { + id: u64, + name: String, + from: String, + to: &'static str, + }, +} + +/// Pure function: decide what to do for one matched definition. +/// +/// - `Skip` when the current status equals the target. +/// - `Patch` otherwise — including the "current status is unknown" +/// case (older API responses, explicit-ID matches), since the safe +/// default is to apply the patch and let ADO reject if appropriate. +pub fn decide_action(matched: &MatchedDefinition, target: Target) -> Action { + let target_str = target.as_str(); + let current = matched.queue_status.as_deref().unwrap_or(""); + + if current == target_str { + return Action::Skip { + id: matched.id, + name: matched.name.clone(), + reason: format!("already {}", target_str), + }; + } + + Action::Patch { + id: matched.id, + name: matched.name.clone(), + from: if current.is_empty() { + "unknown".to_string() + } else { + current.to_string() + }, + to: target_str, + } +} + +/// CLI options for [`run`]. +pub struct DisableOptions<'a> { + pub org: Option<&'a str>, + pub project: Option<&'a str>, + pub pat: Option<&'a str>, + pub path: Option<&'a Path>, + pub paused: bool, + pub dry_run: bool, +} + +/// Run the `disable` command. +pub async fn run(opts: DisableOptions<'_>) -> 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 target = if opts.paused { + Target::Paused + } else { + Target::Disabled + }; + + let auth = resolve_auth(opts.pat).await?; + let ado_ctx = resolve_ado_context(&repo_path, opts.org, opts.project).await?; + + println!( + "ADO context: org={}, project={}", + ado_ctx.org_url, ado_ctx.project + ); + println!("Target queueStatus: {}", target.as_str()); + println!(); + + println!("Scanning for agentic pipelines..."); + let detected = detect::detect_pipelines(&repo_path).await?; + if detected.is_empty() { + anyhow::bail!( + "No local agentic pipeline fixtures were found under {}. \ + Run `ado-aw compile` first (or point `ado-aw disable` at the repo root).", + repo_path.display() + ); + } + println!("Found {} agentic pipeline(s).", detected.len()); + println!(); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("Failed to create HTTP client")?; + + println!("Matching to Azure DevOps pipeline definitions..."); + let matched = match_definitions(&client, &ado_ctx, &auth, &detected).await?; + + if matched.is_empty() { + anyhow::bail!( + "No ADO definitions matched any local fixture. Run `ado-aw list` to \ + diagnose: either the fixtures haven't been registered with `ado-aw \ + enable`, or the local yaml paths and ADO `yamlFilename` values don't \ + line up." + ); + } + + println!("{} definition(s) matched.", matched.len()); + println!(); + + let mut patched = 0usize; + let mut skipped = 0usize; + let mut failure = 0usize; + for m in &matched { + let action = decide_action(m, target); + debug!("definition {}: action={:?}", m.id, action); + + match action { + Action::Skip { id, name, reason } => { + println!("↻ skip: {} (id={}, {})", name, id, reason); + skipped += 1; + } + Action::Patch { id, name, from, to } => { + if opts.dry_run { + println!( + "[dry-run] ▶ would patch: {} (id={}, {} → {})", + name, id, from, to + ); + patched += 1; + continue; + } + match patch_queue_status(&client, &ado_ctx, &auth, id, to).await { + Ok(()) => { + println!("▶ patched: {} (id={}, {} → {})", name, id, from, to); + patched += 1; + } + Err(e) => { + eprintln!("✗ failed: {} (id={}): {:#}", name, id, e); + failure += 1; + } + } + } + } + } + + println!(); + println!( + "Done: {} patched, {} skipped, {} failed.", + patched, skipped, failure + ); + if failure > 0 { + anyhow::bail!("{} definition(s) failed", failure); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ado::MatchMethod; + + fn matched_with_status(id: u64, name: &str, status: Option<&str>) -> MatchedDefinition { + MatchedDefinition { + id, + name: name.to_string(), + match_method: MatchMethod::YamlPath, + yaml_path: format!("/tests/{}.lock.yml", name.replace(' ', "-")), + queue_status: status.map(str::to_string), + } + } + + #[test] + fn target_as_str_disabled_and_paused() { + assert_eq!(Target::Disabled.as_str(), "disabled"); + assert_eq!(Target::Paused.as_str(), "paused"); + } + + // ============ decide_action matrix ============ + + #[test] + fn enabled_to_disabled_patches() { + let m = matched_with_status(1, "noop", Some("enabled")); + let action = decide_action(&m, Target::Disabled); + assert_eq!( + action, + Action::Patch { + id: 1, + name: "noop".to_string(), + from: "enabled".to_string(), + to: "disabled" + } + ); + } + + #[test] + fn enabled_to_paused_patches() { + let m = matched_with_status(2, "noop", Some("enabled")); + let action = decide_action(&m, Target::Paused); + assert_eq!( + action, + Action::Patch { + id: 2, + name: "noop".to_string(), + from: "enabled".to_string(), + to: "paused" + } + ); + } + + #[test] + fn disabled_to_disabled_skips() { + let m = matched_with_status(3, "noop", Some("disabled")); + let action = decide_action(&m, Target::Disabled); + assert_eq!( + action, + Action::Skip { + id: 3, + name: "noop".to_string(), + reason: "already disabled".to_string() + } + ); + } + + #[test] + fn paused_to_paused_skips() { + let m = matched_with_status(4, "noop", Some("paused")); + let action = decide_action(&m, Target::Paused); + assert_eq!( + action, + Action::Skip { + id: 4, + name: "noop".to_string(), + reason: "already paused".to_string() + } + ); + } + + #[test] + fn disabled_to_paused_patches() { + let m = matched_with_status(5, "noop", Some("disabled")); + let action = decide_action(&m, Target::Paused); + assert_eq!( + action, + Action::Patch { + id: 5, + name: "noop".to_string(), + from: "disabled".to_string(), + to: "paused" + } + ); + } + + #[test] + fn paused_to_disabled_patches() { + let m = matched_with_status(6, "noop", Some("paused")); + let action = decide_action(&m, Target::Disabled); + assert_eq!( + action, + Action::Patch { + id: 6, + name: "noop".to_string(), + from: "paused".to_string(), + to: "disabled" + } + ); + } + + #[test] + fn unknown_status_patches_with_from_unknown() { + // Explicit-ID matches and older API responses have queue_status = None. + // The safe default is to apply the patch and let ADO reject if needed. + let m = matched_with_status(7, "noop", None); + let action = decide_action(&m, Target::Disabled); + assert_eq!( + action, + Action::Patch { + id: 7, + name: "noop".to_string(), + from: "unknown".to_string(), + to: "disabled" + } + ); + } + + #[test] + fn empty_status_string_treated_as_unknown() { + let m = matched_with_status(8, "noop", Some("")); + let action = decide_action(&m, Target::Disabled); + match action { + Action::Patch { from, .. } => assert_eq!(from, "unknown"), + other => panic!("expected Patch, got {:?}", other), + } + } +} diff --git a/src/main.rs b/src/main.rs index 9d19f316..a18d3387 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ pub mod ado; mod compile; mod configure; mod detect; +mod disable; mod ecosystem_domains; mod enable; mod engine; @@ -174,6 +175,31 @@ enum Commands { #[arg(long, requires = "also_set_token")] token: Option, }, + /// Disable (or pause) every ADO build definition that matches a local fixture. + Disable { + /// 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, + /// Set queueStatus to `paused` instead of `disabled`. Paused + /// definitions still queue scheduled runs but the queue is held; + /// disabled definitions reject all queue requests. + #[arg(long)] + paused: bool, + /// Preview the planned actions without calling the ADO API. + #[arg(long)] + dry_run: bool, + }, } #[derive(Parser, Debug)] @@ -524,6 +550,7 @@ async fn main() -> Result<()> { Some(Commands::Init { .. }) => "init", Some(Commands::Configure { .. }) => "configure", Some(Commands::Enable { .. }) => "enable", + Some(Commands::Disable { .. }) => "disable", None => "ado-aw", }; @@ -656,6 +683,24 @@ async fn main() -> Result<()> { }) .await?; } + Commands::Disable { + path, + org, + project, + pat, + paused, + dry_run, + } => { + disable::run(disable::DisableOptions { + org: org.as_deref(), + project: project.as_deref(), + pat: pat.as_deref(), + path: path.as_deref(), + paused, + dry_run, + }) + .await?; + } } Ok(()) } diff --git a/tests/disable_integration.rs b/tests/disable_integration.rs new file mode 100644 index 00000000..46b89e8e --- /dev/null +++ b/tests/disable_integration.rs @@ -0,0 +1,47 @@ +//! Integration tests for `ado-aw disable`. +//! +//! These tests exercise the compiled binary at the CLI surface level — +//! `--help` output and clap-level validation — without driving real +//! HTTP traffic. The pure decision logic (`decide_action`, the full +//! enabled/disabled/paused transition matrix) is covered by +//! `#[cfg(test)] mod tests` inside `src/disable.rs`. + +use std::path::PathBuf; + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")) +} + +#[test] +fn disable_help_describes_command() { + let output = std::process::Command::new(binary()) + .args(["disable", "--help"]) + .output() + .expect("Failed to run ado-aw disable --help"); + assert!(output.status.success(), "--help should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Disable (or pause) every ADO build definition"), + "Help text should describe the disable command, got:\n{stdout}" + ); + for flag in ["--org", "--project", "--pat", "--paused", "--dry-run"] { + assert!( + stdout.contains(flag), + "Expected --help to advertise {flag}, got:\n{stdout}" + ); + } +} + +#[test] +fn disable_is_listed_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("disable"), + "Top-level --help should mention the disable subcommand, got:\n{stdout}" + ); +}