Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
│ ├── run.rs # `run` CLI command — queues builds for matched definitions, optional polling to completion (module entry is `dispatch`)
│ ├── 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`)
Expand Down
11 changes: 11 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,14 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg
- `--token <value>` - 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.

- `run [PATH]` - Queue an ADO build for every ADO definition that matches a local fixture (under `PATH`). With `--wait`, poll each queued build until completion and exit with an aggregate result — 0 only if every queued build succeeded.
- `--org <url>` - Override: Azure DevOps organization (URL or bare org name). Inferred from git remote by default.
- `--project <name>` - Override: Azure DevOps project name (inferred from git remote by default).
- `--pat <pat>` / `AZURE_DEVOPS_EXT_PAT` env var - PAT for ADO API authentication (Azure CLI fallback if omitted).
- `--branch <ref>` - Source branch to queue (e.g. `refs/heads/main`). Defaults to the definition's `defaultBranch`.
- `--parameters <k=v[,k=v...]>` - ADO `templateParameters`. Repeatable and/or comma-separated. All values are strings (ADO coerces as the definition requires). Rejects malformed pairs (missing `=`).
- `--wait` - Poll each queued build to completion before exiting.
- `--poll-interval <secs>` - Polling period when `--wait` is set (default 10).
- `--timeout <secs>` - Hard cap on the polling loop when `--wait` is set (default 1800).
- `--dry-run` - Print the planned `templateParameters` body without calling the ADO API.
100 changes: 88 additions & 12 deletions src/ado/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1038,26 +1038,102 @@ pub async fn create_definition(
/// build's `id`. `branch` defaults to the definition's `defaultBranch` when
/// `None`. `parameters` are passed through as ADO `templateParameters`.
pub async fn queue_build(
_client: &reqwest::Client,
_ctx: &AdoContext,
_auth: &AdoAuth,
_definition_id: u64,
_branch: Option<&str>,
_parameters: &serde_json::Map<String, serde_json::Value>,
client: &reqwest::Client,
ctx: &AdoContext,
auth: &AdoAuth,
definition_id: u64,
branch: Option<&str>,
parameters: &serde_json::Map<String, serde_json::Value>,
) -> Result<u64> {
anyhow::bail!("not yet implemented: filled in by PR 6 (ado-aw run)")
let url = format!(
"{}/{}/_apis/build/builds?api-version=7.1",
ctx.org_url.trim_end_matches('/'),
ctx.project,
);

let mut body = serde_json::json!({
"definition": { "id": definition_id }
});
if let Some(b) = branch {
body["sourceBranch"] = serde_json::Value::String(b.to_string());
}
if !parameters.is_empty() {
// ADO `templateParameters` is a string-keyed map of stringly-
// typed values; the caller has already coerced everything to
// strings via `parse_parameters`.
body["templateParameters"] = serde_json::Value::Object(parameters.clone());
}

debug!("POST queue build for definition {}: {}", definition_id, url);

let resp = auth
.apply(client.post(&url))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.with_context(|| format!("Failed to queue build for definition {}", definition_id))?;

let status = resp.status();
if !status.is_success() {
let resp_body = resp.text().await.unwrap_or_default();
anyhow::bail!(
"ADO API returned {} when queuing build for definition {}: {}",
status,
definition_id,
resp_body
);
}

let resp_body: serde_json::Value = resp
.json()
.await
.context("Failed to parse queue-build response")?;

resp_body
.get("id")
.and_then(|v| v.as_u64())
.context("queue_build response has no numeric 'id' field")
}

/// Fetch the full JSON body of a build.
///
/// Calls `GET /_apis/build/builds/{id}?api-version=7.1`.
pub async fn get_build(
_client: &reqwest::Client,
_ctx: &AdoContext,
_auth: &AdoAuth,
_build_id: u64,
client: &reqwest::Client,
ctx: &AdoContext,
auth: &AdoAuth,
build_id: u64,
) -> Result<serde_json::Value> {
anyhow::bail!("not yet implemented: filled in by PR 6 (ado-aw run)")
let url = format!(
"{}/{}/_apis/build/builds/{}?api-version=7.1",
ctx.org_url.trim_end_matches('/'),
ctx.project,
build_id
);

debug!("GET build {}: {}", build_id, url);

let resp = auth
.apply(client.get(&url))
.send()
.await
.with_context(|| format!("Failed to fetch build {}", build_id))?;

let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!(
"ADO API returned {} when fetching build {}: {}",
status,
build_id,
body
);
}

resp.json()
.await
.with_context(|| format!("Failed to parse build {} response", build_id))
}

/// Fetch the most recent build for a definition.
Expand Down
65 changes: 65 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ mod init;
mod logging;
mod mcp;
mod ndjson;
mod run;
pub mod runtimes;
pub mod sanitize;
mod safeoutputs;
Expand Down Expand Up @@ -174,6 +175,43 @@ enum Commands {
#[arg(long, requires = "also_set_token")]
token: Option<String>,
},
/// Queue a build for every ADO definition that matches a local fixture (optionally wait for completion).
Run {
/// Path to the repository root (defaults to current directory). Used
/// to auto-discover compiled pipelines, same as `compile`.
path: Option<PathBuf>,
/// 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<String>,
/// Override: Azure DevOps project name (inferred from git remote by default).
#[arg(long)]
project: Option<String>,
/// 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<String>,
/// Source branch to queue. Defaults to the definition's `defaultBranch`.
#[arg(long)]
branch: Option<String>,
/// ADO `templateParameters` as `key=value` pairs. Repeatable and/or
/// comma-separated (`--parameters a=1,b=2 --parameters c=3`).
#[arg(long)]
parameters: Vec<String>,
/// Poll each queued build to completion before exiting; aggregate result
/// determines the exit code.
#[arg(long)]
wait: bool,
/// Seconds between polls when `--wait` is set.
#[arg(long, default_value_t = 10, requires = "wait")]
poll_interval: u64,
/// Maximum seconds to wait when `--wait` is set.
#[arg(long, default_value_t = 1800, requires = "wait")]
timeout: u64,
/// Print the planned queue body without calling the ADO API.
#[arg(long)]
dry_run: bool,
},
}

#[derive(Parser, Debug)]
Expand Down Expand Up @@ -524,6 +562,7 @@ async fn main() -> Result<()> {
Some(Commands::Init { .. }) => "init",
Some(Commands::Configure { .. }) => "configure",
Some(Commands::Enable { .. }) => "enable",
Some(Commands::Run { .. }) => "run",
None => "ado-aw",
};

Expand Down Expand Up @@ -656,6 +695,32 @@ async fn main() -> Result<()> {
})
.await?;
}
Commands::Run {
path,
org,
project,
pat,
branch,
parameters,
wait,
poll_interval,
timeout,
dry_run,
} => {
run::dispatch(run::RunOptions {
org: org.as_deref(),
project: project.as_deref(),
pat: pat.as_deref(),
path: path.as_deref(),
branch: branch.as_deref(),
parameters: &parameters,
wait,
poll_interval_secs: poll_interval,
timeout_secs: timeout,
dry_run,
})
.await?;
}
}
Ok(())
}
Expand Down
Loading
Loading