Skip to content
Merged
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 @@ -76,6 +76,7 @@ Every compiled pipeline runs as three sequential jobs:
│ ├── logging.rs # File-based logging infrastructure
│ ├── 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
│ ├── 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
12 changes: 12 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,15 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg
- `--path <path>` - Path to the repository root (defaults to current directory)
- `--dry-run` - Preview changes without applying them
- `--definition-ids <ids>` - Explicit pipeline definition IDs to update (comma-separated, skips auto-detection)

- `enable [PATH]` - Register an ADO build definition for each compiled pipeline discovered under `PATH` (or the current directory) and ensure it is `enabled`. For each fixture, matches against the existing ADO definitions by `yamlFilename` first, then by sanitized display name; creates a new definition when neither matches, flips `queueStatus` to `enabled` when an existing definition is `disabled` / `paused`, and skips when it is already `enabled`. Fail-soft per fixture; exits non-zero if any fixture failed.
- `--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).
- `--folder <ado-folder>` - ADO folder for newly-created definitions. Defaults to `\` (root). Only applied on create — existing definitions stay where they are.
- `--default-branch <ref>` - Default branch for newly-created definitions. Defaults to `refs/heads/main`.
- `--dry-run` - Print the planned actions (and the full POST body for creates) without calling the ADO API.
- `--also-set-token` - After creating a new definition, set its `GITHUB_TOKEN` variable (as an ADO secret).
- `--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.
282 changes: 260 additions & 22 deletions src/ado/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ pub struct DefinitionSummary {
pub id: u64,
pub name: String,
pub process: Option<ProcessInfo>,
/// `enabled`, `disabled`, or `paused`. Populated when `list_definitions`
/// is called with `includeAllProperties=true` (the default in
/// [`list_definitions`]). Older/cached responses may omit it.
#[serde(rename = "queueStatus")]
pub queue_status: Option<String>,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -786,18 +791,77 @@ pub async fn resolve_definitions(
// overhaul. Locking the surface here lets the parallel command PRs depend on
// stable function signatures from day one.

/// Characters that must be percent-encoded when used in a URL path
/// segment. Built from RFC 3986 §3.3: `pchar` allows unreserved
/// characters (`A-Z`, `a-z`, `0-9`, `-`, `_`, `.`, `~`),
/// percent-encoded triplets, sub-delims, and `:` / `@`. We additionally
/// encode `:`, `@`, `%`, and `/` so a repository name containing any
/// of those does not break out of the segment, and the U+0021 (`!`)
/// just for symmetry with common path-encoding tables. Notably this
/// preserves `-`, `_`, `.`, `~` which `NON_ALPHANUMERIC` would over-
/// encode (e.g. `my-repo` → `my%2Drepo`).
const PATH_SEGMENT: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'`')
.add(b'{')
.add(b'}')
.add(b'/')
.add(b'%')
.add(b'@')
.add(b':')
.add(b'!');

/// Look up an ADO Git repository's GUID by name.
///
/// Calls `GET /_apis/git/repositories/{repoName}?api-version=7.1` and reads
/// the `id` field. Required for `create_definition`, which needs a
/// `repository.id` (not just a name) on the POST body.
pub async fn get_repository_id(
_client: &reqwest::Client,
_ctx: &AdoContext,
_auth: &AdoAuth,
_repo_name: &str,
client: &reqwest::Client,
ctx: &AdoContext,
auth: &AdoAuth,
repo_name: &str,
) -> Result<String> {
anyhow::bail!("not yet implemented: filled in by PR 2 (ado-aw enable)")
let url = format!(
"{}/{}/_apis/git/repositories/{}?api-version=7.1",
ctx.org_url.trim_end_matches('/'),
percent_encoding::utf8_percent_encode(&ctx.project, PATH_SEGMENT),
percent_encoding::utf8_percent_encode(repo_name, PATH_SEGMENT),
);

debug!("Looking up repository '{}': {}", repo_name, url);

let resp = auth
.apply(client.get(&url))
.send()
.await
.with_context(|| format!("Failed to look up repository '{}'", repo_name))?;

let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!(
"ADO API returned {} when looking up repository '{}': {}",
status,
repo_name,
body
);
}

let body: serde_json::Value = resp
.json()
.await
.with_context(|| format!("Failed to parse repository response for '{}'", repo_name))?;

body.get("id")
.and_then(|v| v.as_str())
.map(str::to_string)
.with_context(|| format!("Repository '{}' response has no 'id' field", repo_name))
}

/// Fetch the full JSON body of a build definition.
Expand All @@ -806,26 +870,107 @@ pub async fn get_repository_id(
/// the raw `serde_json::Value` so callers can mutate specific fields and
/// PUT the result back (the standard GET → mutate → PUT cycle).
pub async fn get_definition_full(
_client: &reqwest::Client,
_ctx: &AdoContext,
_auth: &AdoAuth,
_id: u64,
client: &reqwest::Client,
ctx: &AdoContext,
auth: &AdoAuth,
id: u64,
) -> Result<serde_json::Value> {
anyhow::bail!("not yet implemented: filled in by PR 2 (ado-aw enable) or PR 3 (ado-aw disable)")
let url = format!(
"{}/{}/_apis/build/definitions/{}?api-version=7.1",
ctx.org_url.trim_end_matches('/'),
percent_encoding::utf8_percent_encode(&ctx.project, PATH_SEGMENT),
id
);

let resp = auth
.apply(client.get(&url))
.send()
.await
.with_context(|| format!("Failed to fetch 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 definition {}: {}",
status,
id,
body
);
}

let body = resp
.text()
.await
.with_context(|| format!("Failed to read definition {} response body", id))?;

serde_json::from_str(&body).with_context(|| {
let snippet: String = body.chars().take(500).collect();
format!(
"Failed to parse definition {} as JSON. \
This usually means the PAT is invalid or expired. \
Response body (first 500 chars):\n{snippet}",
id
)
})
}

/// PATCH the `queueStatus` field on a build definition.
///
/// `status` must be one of `"enabled"`, `"disabled"`, or `"paused"`.
/// Implements the GET → mutate → PUT cycle internally.
/// Implements the GET → mutate → PUT cycle internally; the full definition
/// is round-tripped to satisfy the PUT API's "you must send the whole
/// document" requirement.
pub async fn patch_queue_status(
_client: &reqwest::Client,
_ctx: &AdoContext,
_auth: &AdoAuth,
_id: u64,
_status: &str,
client: &reqwest::Client,
ctx: &AdoContext,
auth: &AdoAuth,
id: u64,
status: &str,
) -> Result<()> {
anyhow::bail!("not yet implemented: filled in by PR 2 (ado-aw enable) or PR 3 (ado-aw disable)")
match status {
"enabled" | "disabled" | "paused" => {}
other => anyhow::bail!(
"patch_queue_status: invalid status '{}', expected one of enabled/disabled/paused",
other
),
}

let mut definition = get_definition_full(client, ctx, auth, id)
.await
.with_context(|| format!("Failed to fetch definition {} before patching", id))?;

definition["queueStatus"] = serde_json::Value::String(status.to_string());

let put_url = format!(
"{}/{}/_apis/build/definitions/{}?api-version=7.1",
ctx.org_url.trim_end_matches('/'),
percent_encoding::utf8_percent_encode(&ctx.project, PATH_SEGMENT),
id
);

debug!("PUT definition {} with queueStatus={}: {}", id, status, put_url);

let resp = auth
.apply(client.put(&put_url))
.header("Content-Type", "application/json")
.json(&definition)
.send()
.await
.with_context(|| format!("Failed to update queueStatus on definition {}", id))?;

let resp_status = resp.status();
if !resp_status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!(
"ADO API returned {} when updating queueStatus on definition {}: {}",
resp_status,
id,
body
);
}

Ok(())
}

/// Delete a build definition.
Expand All @@ -845,12 +990,46 @@ pub async fn delete_definition(
/// Calls `POST /_apis/build/definitions?api-version=7.1` with the supplied
/// JSON body and returns the new definition's `id`.
pub async fn create_definition(
_client: &reqwest::Client,
_ctx: &AdoContext,
_auth: &AdoAuth,
_body: &serde_json::Value,
client: &reqwest::Client,
ctx: &AdoContext,
auth: &AdoAuth,
body: &serde_json::Value,
) -> Result<u64> {
anyhow::bail!("not yet implemented: filled in by PR 2 (ado-aw enable)")
let url = format!(
"{}/{}/_apis/build/definitions?api-version=7.1",
ctx.org_url.trim_end_matches('/'),
percent_encoding::utf8_percent_encode(&ctx.project, PATH_SEGMENT),
);

debug!("POST new definition: {}", url);

let resp = auth
.apply(client.post(&url))
.header("Content-Type", "application/json")
.json(body)
.send()
.await
.context("Failed to create build definition")?;

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

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

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

/// Queue a build for a definition.
Expand Down Expand Up @@ -1005,6 +1184,7 @@ mod tests {
id,
name: name.to_string(),
process: None,
queue_status: None,
}
}

Expand All @@ -1015,6 +1195,7 @@ mod tests {
process: Some(ProcessInfo {
yaml_filename: Some(yaml_filename.to_string()),
}),
queue_status: None,
}
}

Expand Down Expand Up @@ -1148,4 +1329,61 @@ mod tests {
assert_eq!(format!("{}", MatchMethod::PipelineName), "pipeline-name");
assert_eq!(format!("{}", MatchMethod::Explicit), "explicit");
}

// ==================== DefinitionSummary deserialization ====================

#[test]
fn definition_summary_deserializes_queue_status() {
let raw = serde_json::json!({
"id": 42,
"name": "Daily noop",
"queueStatus": "disabled",
"process": { "yamlFilename": "/tests/noop.lock.yml" }
});
let def: DefinitionSummary = serde_json::from_value(raw).unwrap();
assert_eq!(def.id, 42);
assert_eq!(def.queue_status.as_deref(), Some("disabled"));
assert_eq!(
def.process
.as_ref()
.and_then(|p| p.yaml_filename.as_deref()),
Some("/tests/noop.lock.yml")
);
}

#[test]
fn definition_summary_queue_status_missing_is_none() {
let raw = serde_json::json!({ "id": 1, "name": "x" });
let def: DefinitionSummary = serde_json::from_value(raw).unwrap();
assert!(def.queue_status.is_none());
}

// ==================== PATH_SEGMENT percent-encoding ====================

#[test]
fn path_segment_preserves_rfc3986_unreserved_chars() {
// RFC 3986 unreserved set: A-Z / a-z / 0-9 / - / _ / . / ~
// These MUST NOT be percent-encoded in a URL path segment.
let encoded =
percent_encoding::utf8_percent_encode("my-repo_name.with~tilde", PATH_SEGMENT)
.to_string();
assert_eq!(encoded, "my-repo_name.with~tilde");
}

#[test]
fn path_segment_encodes_space_and_reserved_punctuation() {
let encoded =
percent_encoding::utf8_percent_encode("my repo/with?special#chars", PATH_SEGMENT)
.to_string();
// Spaces become %20, slashes %2F, ? becomes %3F, # becomes %23.
assert_eq!(encoded, "my%20repo%2Fwith%3Fspecial%23chars");
}

#[test]
fn path_segment_handles_non_ascii() {
let encoded =
percent_encoding::utf8_percent_encode("café-π", PATH_SEGMENT).to_string();
// Non-ASCII bytes get encoded per UTF-8.
assert_eq!(encoded, "caf%C3%A9-%CF%80");
}
}
Loading