Skip to content

Commit 1b4273b

Browse files
authored
feat(cli): add ado-aw enable (#583)
1 parent cee48c1 commit 1b4273b

6 files changed

Lines changed: 1130 additions & 22 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ Every compiled pipeline runs as three sequential jobs:
7676
│ ├── logging.rs # File-based logging infrastructure
7777
│ ├── mcp.rs # SafeOutputs MCP server (stdio + HTTP)
7878
│ ├── configure.rs # `configure` CLI command — orchestration shim atop `src/ado/`
79+
│ ├── enable.rs # `enable` CLI command — registers ADO build definitions for compiled pipelines and ensures they are enabled
7980
│ ├── ado/ # Shared Azure DevOps REST helpers (auth, list/match/PATCH/POST)
8081
│ │ └── mod.rs # Used by `configure` and the lifecycle commands (enable, disable, remove, list, run, status, secrets)
8182
│ ├── detect.rs # Agentic pipeline detection (helper for `configure`)

docs/cli.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,15 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg
4343
- `--path <path>` - Path to the repository root (defaults to current directory)
4444
- `--dry-run` - Preview changes without applying them
4545
- `--definition-ids <ids>` - Explicit pipeline definition IDs to update (comma-separated, skips auto-detection)
46+
47+
- `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.
48+
- `--org <url>` - Override: Azure DevOps organization (URL or bare org name). Inferred from git remote by default.
49+
- `--project <name>` - Override: Azure DevOps project name (inferred from git remote by default).
50+
- `--pat <pat>` / `AZURE_DEVOPS_EXT_PAT` env var - PAT for ADO API authentication (Azure CLI fallback if omitted).
51+
- `--folder <ado-folder>` - ADO folder for newly-created definitions. Defaults to `\` (root). Only applied on create — existing definitions stay where they are.
52+
- `--default-branch <ref>` - Default branch for newly-created definitions. Defaults to `refs/heads/main`.
53+
- `--dry-run` - Print the planned actions (and the full POST body for creates) without calling the ADO API.
54+
- `--also-set-token` - After creating a new definition, set its `GITHUB_TOKEN` variable (as an ADO secret).
55+
- `--token <value>` - The token value for `--also-set-token`. Falls back to `$GITHUB_TOKEN`, then to an interactive prompt. Requires `--also-set-token`.
56+
57+
**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.

src/ado/mod.rs

Lines changed: 260 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,11 @@ pub struct DefinitionSummary {
200200
pub id: u64,
201201
pub name: String,
202202
pub process: Option<ProcessInfo>,
203+
/// `enabled`, `disabled`, or `paused`. Populated when `list_definitions`
204+
/// is called with `includeAllProperties=true` (the default in
205+
/// [`list_definitions`]). Older/cached responses may omit it.
206+
#[serde(rename = "queueStatus")]
207+
pub queue_status: Option<String>,
203208
}
204209

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

794+
/// Characters that must be percent-encoded when used in a URL path
795+
/// segment. Built from RFC 3986 §3.3: `pchar` allows unreserved
796+
/// characters (`A-Z`, `a-z`, `0-9`, `-`, `_`, `.`, `~`),
797+
/// percent-encoded triplets, sub-delims, and `:` / `@`. We additionally
798+
/// encode `:`, `@`, `%`, and `/` so a repository name containing any
799+
/// of those does not break out of the segment, and the U+0021 (`!`)
800+
/// just for symmetry with common path-encoding tables. Notably this
801+
/// preserves `-`, `_`, `.`, `~` which `NON_ALPHANUMERIC` would over-
802+
/// encode (e.g. `my-repo` → `my%2Drepo`).
803+
const PATH_SEGMENT: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
804+
.add(b' ')
805+
.add(b'"')
806+
.add(b'#')
807+
.add(b'<')
808+
.add(b'>')
809+
.add(b'?')
810+
.add(b'`')
811+
.add(b'{')
812+
.add(b'}')
813+
.add(b'/')
814+
.add(b'%')
815+
.add(b'@')
816+
.add(b':')
817+
.add(b'!');
818+
789819
/// Look up an ADO Git repository's GUID by name.
790820
///
791821
/// Calls `GET /_apis/git/repositories/{repoName}?api-version=7.1` and reads
792822
/// the `id` field. Required for `create_definition`, which needs a
793823
/// `repository.id` (not just a name) on the POST body.
794824
pub async fn get_repository_id(
795-
_client: &reqwest::Client,
796-
_ctx: &AdoContext,
797-
_auth: &AdoAuth,
798-
_repo_name: &str,
825+
client: &reqwest::Client,
826+
ctx: &AdoContext,
827+
auth: &AdoAuth,
828+
repo_name: &str,
799829
) -> Result<String> {
800-
anyhow::bail!("not yet implemented: filled in by PR 2 (ado-aw enable)")
830+
let url = format!(
831+
"{}/{}/_apis/git/repositories/{}?api-version=7.1",
832+
ctx.org_url.trim_end_matches('/'),
833+
percent_encoding::utf8_percent_encode(&ctx.project, PATH_SEGMENT),
834+
percent_encoding::utf8_percent_encode(repo_name, PATH_SEGMENT),
835+
);
836+
837+
debug!("Looking up repository '{}': {}", repo_name, url);
838+
839+
let resp = auth
840+
.apply(client.get(&url))
841+
.send()
842+
.await
843+
.with_context(|| format!("Failed to look up repository '{}'", repo_name))?;
844+
845+
let status = resp.status();
846+
if !status.is_success() {
847+
let body = resp.text().await.unwrap_or_default();
848+
anyhow::bail!(
849+
"ADO API returned {} when looking up repository '{}': {}",
850+
status,
851+
repo_name,
852+
body
853+
);
854+
}
855+
856+
let body: serde_json::Value = resp
857+
.json()
858+
.await
859+
.with_context(|| format!("Failed to parse repository response for '{}'", repo_name))?;
860+
861+
body.get("id")
862+
.and_then(|v| v.as_str())
863+
.map(str::to_string)
864+
.with_context(|| format!("Repository '{}' response has no 'id' field", repo_name))
801865
}
802866

803867
/// Fetch the full JSON body of a build definition.
@@ -806,26 +870,107 @@ pub async fn get_repository_id(
806870
/// the raw `serde_json::Value` so callers can mutate specific fields and
807871
/// PUT the result back (the standard GET → mutate → PUT cycle).
808872
pub async fn get_definition_full(
809-
_client: &reqwest::Client,
810-
_ctx: &AdoContext,
811-
_auth: &AdoAuth,
812-
_id: u64,
873+
client: &reqwest::Client,
874+
ctx: &AdoContext,
875+
auth: &AdoAuth,
876+
id: u64,
813877
) -> Result<serde_json::Value> {
814-
anyhow::bail!("not yet implemented: filled in by PR 2 (ado-aw enable) or PR 3 (ado-aw disable)")
878+
let url = format!(
879+
"{}/{}/_apis/build/definitions/{}?api-version=7.1",
880+
ctx.org_url.trim_end_matches('/'),
881+
percent_encoding::utf8_percent_encode(&ctx.project, PATH_SEGMENT),
882+
id
883+
);
884+
885+
let resp = auth
886+
.apply(client.get(&url))
887+
.send()
888+
.await
889+
.with_context(|| format!("Failed to fetch definition {}", id))?;
890+
891+
let status = resp.status();
892+
if !status.is_success() {
893+
let body = resp.text().await.unwrap_or_default();
894+
anyhow::bail!(
895+
"ADO API returned {} when fetching definition {}: {}",
896+
status,
897+
id,
898+
body
899+
);
900+
}
901+
902+
let body = resp
903+
.text()
904+
.await
905+
.with_context(|| format!("Failed to read definition {} response body", id))?;
906+
907+
serde_json::from_str(&body).with_context(|| {
908+
let snippet: String = body.chars().take(500).collect();
909+
format!(
910+
"Failed to parse definition {} as JSON. \
911+
This usually means the PAT is invalid or expired. \
912+
Response body (first 500 chars):\n{snippet}",
913+
id
914+
)
915+
})
815916
}
816917

817918
/// PATCH the `queueStatus` field on a build definition.
818919
///
819920
/// `status` must be one of `"enabled"`, `"disabled"`, or `"paused"`.
820-
/// Implements the GET → mutate → PUT cycle internally.
921+
/// Implements the GET → mutate → PUT cycle internally; the full definition
922+
/// is round-tripped to satisfy the PUT API's "you must send the whole
923+
/// document" requirement.
821924
pub async fn patch_queue_status(
822-
_client: &reqwest::Client,
823-
_ctx: &AdoContext,
824-
_auth: &AdoAuth,
825-
_id: u64,
826-
_status: &str,
925+
client: &reqwest::Client,
926+
ctx: &AdoContext,
927+
auth: &AdoAuth,
928+
id: u64,
929+
status: &str,
827930
) -> Result<()> {
828-
anyhow::bail!("not yet implemented: filled in by PR 2 (ado-aw enable) or PR 3 (ado-aw disable)")
931+
match status {
932+
"enabled" | "disabled" | "paused" => {}
933+
other => anyhow::bail!(
934+
"patch_queue_status: invalid status '{}', expected one of enabled/disabled/paused",
935+
other
936+
),
937+
}
938+
939+
let mut definition = get_definition_full(client, ctx, auth, id)
940+
.await
941+
.with_context(|| format!("Failed to fetch definition {} before patching", id))?;
942+
943+
definition["queueStatus"] = serde_json::Value::String(status.to_string());
944+
945+
let put_url = format!(
946+
"{}/{}/_apis/build/definitions/{}?api-version=7.1",
947+
ctx.org_url.trim_end_matches('/'),
948+
percent_encoding::utf8_percent_encode(&ctx.project, PATH_SEGMENT),
949+
id
950+
);
951+
952+
debug!("PUT definition {} with queueStatus={}: {}", id, status, put_url);
953+
954+
let resp = auth
955+
.apply(client.put(&put_url))
956+
.header("Content-Type", "application/json")
957+
.json(&definition)
958+
.send()
959+
.await
960+
.with_context(|| format!("Failed to update queueStatus on definition {}", id))?;
961+
962+
let resp_status = resp.status();
963+
if !resp_status.is_success() {
964+
let body = resp.text().await.unwrap_or_default();
965+
anyhow::bail!(
966+
"ADO API returned {} when updating queueStatus on definition {}: {}",
967+
resp_status,
968+
id,
969+
body
970+
);
971+
}
972+
973+
Ok(())
829974
}
830975

831976
/// Delete a build definition.
@@ -845,12 +990,46 @@ pub async fn delete_definition(
845990
/// Calls `POST /_apis/build/definitions?api-version=7.1` with the supplied
846991
/// JSON body and returns the new definition's `id`.
847992
pub async fn create_definition(
848-
_client: &reqwest::Client,
849-
_ctx: &AdoContext,
850-
_auth: &AdoAuth,
851-
_body: &serde_json::Value,
993+
client: &reqwest::Client,
994+
ctx: &AdoContext,
995+
auth: &AdoAuth,
996+
body: &serde_json::Value,
852997
) -> Result<u64> {
853-
anyhow::bail!("not yet implemented: filled in by PR 2 (ado-aw enable)")
998+
let url = format!(
999+
"{}/{}/_apis/build/definitions?api-version=7.1",
1000+
ctx.org_url.trim_end_matches('/'),
1001+
percent_encoding::utf8_percent_encode(&ctx.project, PATH_SEGMENT),
1002+
);
1003+
1004+
debug!("POST new definition: {}", url);
1005+
1006+
let resp = auth
1007+
.apply(client.post(&url))
1008+
.header("Content-Type", "application/json")
1009+
.json(body)
1010+
.send()
1011+
.await
1012+
.context("Failed to create build definition")?;
1013+
1014+
let status = resp.status();
1015+
if !status.is_success() {
1016+
let resp_body = resp.text().await.unwrap_or_default();
1017+
anyhow::bail!(
1018+
"ADO API returned {} when creating definition: {}",
1019+
status,
1020+
resp_body
1021+
);
1022+
}
1023+
1024+
let resp_body: serde_json::Value = resp
1025+
.json()
1026+
.await
1027+
.context("Failed to parse create-definition response")?;
1028+
1029+
resp_body
1030+
.get("id")
1031+
.and_then(|v| v.as_u64())
1032+
.context("create_definition response has no numeric 'id' field")
8541033
}
8551034

8561035
/// Queue a build for a definition.
@@ -1005,6 +1184,7 @@ mod tests {
10051184
id,
10061185
name: name.to_string(),
10071186
process: None,
1187+
queue_status: None,
10081188
}
10091189
}
10101190

@@ -1015,6 +1195,7 @@ mod tests {
10151195
process: Some(ProcessInfo {
10161196
yaml_filename: Some(yaml_filename.to_string()),
10171197
}),
1198+
queue_status: None,
10181199
}
10191200
}
10201201

@@ -1148,4 +1329,61 @@ mod tests {
11481329
assert_eq!(format!("{}", MatchMethod::PipelineName), "pipeline-name");
11491330
assert_eq!(format!("{}", MatchMethod::Explicit), "explicit");
11501331
}
1332+
1333+
// ==================== DefinitionSummary deserialization ====================
1334+
1335+
#[test]
1336+
fn definition_summary_deserializes_queue_status() {
1337+
let raw = serde_json::json!({
1338+
"id": 42,
1339+
"name": "Daily noop",
1340+
"queueStatus": "disabled",
1341+
"process": { "yamlFilename": "/tests/noop.lock.yml" }
1342+
});
1343+
let def: DefinitionSummary = serde_json::from_value(raw).unwrap();
1344+
assert_eq!(def.id, 42);
1345+
assert_eq!(def.queue_status.as_deref(), Some("disabled"));
1346+
assert_eq!(
1347+
def.process
1348+
.as_ref()
1349+
.and_then(|p| p.yaml_filename.as_deref()),
1350+
Some("/tests/noop.lock.yml")
1351+
);
1352+
}
1353+
1354+
#[test]
1355+
fn definition_summary_queue_status_missing_is_none() {
1356+
let raw = serde_json::json!({ "id": 1, "name": "x" });
1357+
let def: DefinitionSummary = serde_json::from_value(raw).unwrap();
1358+
assert!(def.queue_status.is_none());
1359+
}
1360+
1361+
// ==================== PATH_SEGMENT percent-encoding ====================
1362+
1363+
#[test]
1364+
fn path_segment_preserves_rfc3986_unreserved_chars() {
1365+
// RFC 3986 unreserved set: A-Z / a-z / 0-9 / - / _ / . / ~
1366+
// These MUST NOT be percent-encoded in a URL path segment.
1367+
let encoded =
1368+
percent_encoding::utf8_percent_encode("my-repo_name.with~tilde", PATH_SEGMENT)
1369+
.to_string();
1370+
assert_eq!(encoded, "my-repo_name.with~tilde");
1371+
}
1372+
1373+
#[test]
1374+
fn path_segment_encodes_space_and_reserved_punctuation() {
1375+
let encoded =
1376+
percent_encoding::utf8_percent_encode("my repo/with?special#chars", PATH_SEGMENT)
1377+
.to_string();
1378+
// Spaces become %20, slashes %2F, ? becomes %3F, # becomes %23.
1379+
assert_eq!(encoded, "my%20repo%2Fwith%3Fspecial%23chars");
1380+
}
1381+
1382+
#[test]
1383+
fn path_segment_handles_non_ascii() {
1384+
let encoded =
1385+
percent_encoding::utf8_percent_encode("café-π", PATH_SEGMENT).to_string();
1386+
// Non-ASCII bytes get encoded per UTF-8.
1387+
assert_eq!(encoded, "caf%C3%A9-%CF%80");
1388+
}
11511389
}

0 commit comments

Comments
 (0)