feat(enable): support GitHub-source pipelines via --service-connection#905
Conversation
Lets `ado-aw enable` register ADO build definitions whose source YAML lives in a GitHub repository — required to bulk-register `tests/safe-outputs/*.lock.yml` smoke pipelines in the `msazuresphere/AgentPlayground` ADO project from a `githubnext/ado-aw` checkout. Provider is autodetected from `git remote get-url origin`: ADO Git remotes emit the existing TfsGit create-definition body; `github.com` remotes emit a GitHub body whose `repository.properties.connectedServiceId` references a project-level GitHub service connection resolved from the new `--service-connection` flag (accepts friendly name or GUID). `--repository-name owner/repo` overrides the auto-detected source coords for the rare cross-fork case. Lifecycle commands (`list`/`disable`/`remove`/`run`/`status`) need no code changes — they use the lexical `match_definitions` matcher which only inspects `yamlFilename` and `name`. A pinning test (`match_definitions_in_works_for_github_source_definition`) locks that contract in. Out of scope: GitHub Enterprise, BitBucket Cloud, persisting `--org`/`--project` to a local config, marker emit-path migration for GitHub source, and `secrets --all-repos`/`--source` discovery migration. See the PR description for the rationale. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔍 Rust PR ReviewSummary: Looks good overall — clean model separation, solid test coverage. Two issues worth addressing before merge. Findings🐛 Bugs / Logic Issues
|
Three findings from the automated Rust PR review:
1. enable.rs:479 — silent empty-string failure on invariant break.
Restructure the per-fixture loop to bind `github_full_name` as an
unconditional `String` (empty for the `AdoGit` variant, populated
for `Github`) so `RepositoryRef::Github { full_name }` no longer
has to `unwrap_or("")` on an Option that, by construction, is
always `Some`. Fails-fast at the type level if a future refactor
moves the invariant.
2. ado/mod.rs:1183 — endpointNames query value must encode `&` / `=`.
`PATH_SEGMENT` deliberately leaves both unescaped (they're legal
sub-delims in URI path segments). For a query-string value that
would shatter a name like `foo&bar=baz` across multiple params.
Switch to `percent_encoding::NON_ALPHANUMERIC` for that one
substitution; project (still a path segment) keeps `PATH_SEGMENT`.
New unit test pins the contract.
3. ado/mod.rs RepoSource::url() doc + discovery.rs normalize_repo_url
`.git` suffix mismatch. `RepoSource::url()` returns the bare GitHub
URL while `build_create_body` POSTs the `.git` form, and the
discovery URL filter compared them via `normalize_repo_url` which
did not strip the suffix — so a future wiring would silently miss
every GitHub-source pipeline. Fix `normalize_repo_url` to strip
trailing `.git` (after the existing trailing-`/` trim) and update
`RepoSource::url()`'s doc to drop the "used by discovery" claim
(still deferred per the PR's out-of-scope list). New
`apply_scope_filter` test covers the `.git` round-trip in both
directions.
Verification: `cargo build` + `cargo clippy --all-targets
--all-features` clean. `cargo test` — 1787 unit tests + every
integration suite, 0 failures (2 new tests added).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔍 Rust PR ReviewSummary: Looks good — clean design, excellent test coverage, solid error handling. A couple of minor issues worth addressing before merge. Findings🐛 Bugs / Logic Issues
|
Four findings from the second Rust PR reviewer comment plus an
observed UX gap from the operator's first-run failure mode:
1. ado/mod.rs RepoSource::url() AdoGit double-slash on missing project.
Bind `project` to a local, add a `debug_assert!` documenting the
invariant, and document the release-build fallback in the
doc-comment. New test exercises the release fallback under
`cfg(not(debug_assertions))`.
2. enable.rs structural tagging of resolved identifiers. Replace the
`(repo_id, service_conn_id)` tuple with empty-string sentinels by a
new `ResolvedIdentifiers { AdoGit { repo_id }, Github { full_name,
service_connection_id } }` enum. The per-fixture loop now matches
on `(&resolved, &identifiers)` and bails on the impossible cross-
variant pairings — a future refactor can't silently swap the two
or forget to populate one half.
3. enable.rs split_owner_repo_arg `.git` suffix. Mirror the behaviour
of `split_owner_repo` in `src/ado/mod.rs`: trim a trailing `.git`
from the repo half so `--repository-name owner/repo.git` round-
trips identically to `owner/repo`. Guard against the pathological
`owner/.git` (strips to empty repo) with an explicit re-check.
Two new tests pin the contract.
4. enable.rs friendly --org/--project pre-check for GitHub source.
When the autodetected source is GitHub and `--org`/`--project` are
missing, bail with an audience-appropriate error that names both
flags and includes a worked example, rather than falling through
to `resolve_ado_context`'s generic "for `--definition-ids` outside
an ADO repo, both `--org` and `--project` are required" message
(which is from the lifecycle-command path and makes no sense for
the first-time `enable` operator). Matches the failure mode the
user just observed when running `ado-aw enable ./tests/safe-outputs`
with no flags.
Verification: `cargo clippy --all-targets --all-features` clean.
`cargo test` — 1789 unit tests (+2 new) + every integration suite,
0 failures.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔍 Rust PR ReviewSummary: Looks good — solid design, comprehensive tests, clear error messages. Two minor items worth noting before merge. Findings
|
Two findings from the third reviewer comment:
1. enable.rs resolve_source catch-all arm. The pattern
`Ok(mut source) => { /* GitHub */ }` matches every non-AdoGit
provider, not just Github. Restructure to lift the `Err(_)` arm
out and then `match parsed.provider` directly on the enum — adding
a new RepoProvider variant is now a compile-time exhaustiveness
error rather than a silent fall-through to the GitHub code path.
No behavioural change for existing callers.
2. ado/discovery.rs normalize_repo_url strip ordering. Current
`.trim_end_matches('/').trim_end_matches(".git")` collapses
`repo`, `repo/`, `repo.git`, `repo.git/` cleanly but leaves a
trailing `/` on the pathological `repo/.git` form. Add a final
`.trim_end_matches('/')` pass so every realistic and pathological
suffix combination canonicalises to the same form. New unit test
`normalize_repo_url_collapses_all_suffix_forms` pins the contract
across all six input shapes.
Verification: cargo clippy clean. cargo test — 1790 unit tests
(+1 new) + every integration suite, 0 failures.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔍 Rust PR ReviewSummary: Looks good overall — solid type-safe design, excellent test coverage, and a clean separation of concerns between source identity and deployment target. Two items worth fixing before merge. Findings🐛 Bugs / Logic Issues
The analogous Minimal fix — add a check after splitting: // Reject inputs with more than one slash (e.g. "owner/repo/extra").
if owner.contains('/') || parts[1].contains('/') {
anyhow::bail!("--repository-name must be in the form 'owner/repo' (got: '{}')", value);
}Or mirror
if let Ok(ctx) = parse_ado_remote(remote_url) {
let owner = ctx.org_name().unwrap_or("").to_string();If let owner = ctx
.org_name()
.ok_or_else(|| anyhow::anyhow!("could not extract org name from ADO remote '{}'", remote_url))?
.to_string();
|
Two bugs and one suggestion from the fourth reviewer comment:
1. enable.rs split_owner_repo_arg silently accepted extra path
segments. `splitn(2, '/')` on `owner/repo/extra` yielded
`["owner", "repo/extra"]` and passed the non-empty guard, sliding
`repo/extra` into the ADO `repository.name` POST field where it
would surface as an opaque API error. Switch to `splitn(3, '/')`
and require exactly two parts — `owner/repo/extra` now produces
three parts and triggers the targeted "must be in the form
'owner/repo'" message immediately. Two new tests cover the
three-segment and four-segment cases.
2. ado/mod.rs parse_git_remote: silent empty owner when
ado_ctx.org_name() returns None. The path is unreachable in
practice (every `parse_ado_remote`-accepted shape populates
`org_url` with a `/{org}` segment) but the type allowed it.
Replace `.unwrap_or("")` with `.ok_or_else(...)` so a future
regression in `org_name`'s parser fails fast at the boundary
rather than producing a `https://dev.azure.com//Project/_git/...`
URL that surfaces as a confusing ADO API error later.
3. ado/mod.rs new QUERY_VALUE AsciiSet. `NON_ALPHANUMERIC` (the
previous choice for the service-connection name encoding)
over-encoded RFC 3986 unreserved chars — `ado-aw-github` became
`ado%2Daw%2Dgithub`. Define a `QUERY_VALUE` set that preserves
the unreserved class (`-`, `_`, `.`, `~`) while still encoding
the query-syntax metacharacters (`&`, `=`, `#`, `?`, `+`, `%`)
and structural ASCII. Strictly correct either way; the literal
form is what humans read in logs and what strict server-side
matchers prefer. Existing test updated to use `QUERY_VALUE`; new
test pins that unreserved chars pass through literally.
Verification: cargo clippy clean. cargo test — 1792 unit tests
(+2 new) + every integration suite, 0 failures.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔍 Rust PR ReviewSummary: Solid implementation — well-structured, excellent test coverage (21 new tests), clear operator-facing error messages. A couple of minor robustness items worth addressing before merge. Findings🐛 Bugs / Logic Issues
|
Running `ado-aw enable PATH` against a subdirectory (e.g. `tests/safe-outputs`) produced two cascading path bugs that made the command unusable for the AgentPlayground operator flow: 1. `source_path = repo_path.join(&pipeline.source)` joined the *scan root* with the marker's repo-root-relative `source` field, producing doubled paths like `\\?\C:\repo\tests\safe-outputs\tests\safe-outputs\noop.md` — every fixture failed with "The system cannot find the path specified" (os error 3). 2. `compute_yaml_filename(&pipeline.yaml_path)` used the scan-relative yaml path, so the `process.yamlFilename` we'd POST to ADO was `/noop.lock.yml` (scan-relative) rather than the actual repo-relative `/tests/safe-outputs/noop.lock.yml`. Even if (1) hadn't blown up, ADO would have created pipelines pointing at the wrong location. Fix: - `find_repo_root` in `src/compile/mod.rs` is now `pub` so non-compile CLI commands can reuse the walk-up-to-`.git` resolver. - `enable::run` now resolves two distinct paths: `scan_root` (still the canonicalized `PATH` arg / cwd, used by `detect_pipelines`) and `repo_root` (anchor for `pipeline.source` joins and for `yamlFilename` derivation). - The per-fixture loop anchors `source_path` on `repo_root` and re-anchors `yaml_path` through `scan_root.join(...).strip_prefix( repo_root)` so the value handed to `process_one` is always repo-root-relative. - Bails with a clear "must be run inside a git checkout" error if no `.git` ancestor can be found (relevant only for `PATH`s outside any git repo — not a regression). Verification: - New integration test `enable_dry_run_against_subdirectory_uses_repo_root_relative_yaml_path` exercises the exact `enable --service-connection <guid> tests/safe-outputs --project ... --org ... --dry-run` invocation the user just ran and asserts the printed POST body contains `"yamlFilename": "/tests/safe-outputs/..."` with no "Failed to read source" errors. - Manual dry-run confirmed: 27 fixtures resolved, every POST body carries the correct `/tests/safe-outputs/<x>.lock.yml` filename. - `cargo clippy --all-targets --all-features` clean. - `cargo test` — 1792 unit tests (unchanged) + every integration suite (4 enable, +1 new), 0 failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CI failed because the new `enable_dry_run_against_subdirectory_uses_repo_root_relative_yaml_path` test relied on `list_definitions` succeeding (it always runs, even in --dry-run mode, since `decide_action` needs the existing-definitions snapshot to know which fixtures already exist). Locally this happened to work via `az` CLI fallback; CI has no Azure login and no tty, so the auth resolver fell through to the interactive prompt and crashed with "The input device is not a TTY" before the dry-run path could print anything. Fix: spin up a `wiremock::MockServer` that returns an empty definitions list, point the binary at it via the existing `ADO_AW_TEST_ORG_URL` test override, and pass an explicit dummy `--pat` so `resolve_auth` skips the Azure-CLI / interactive paths entirely. `wiremock` is already a dev-dependency (used by `tests/audit_it.rs`). Verified locally: all 4 enable integration tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔍 Rust PR ReviewSummary: Looks good — clean design, comprehensive tests, no significant bugs. Two minor issues worth noting. Findings
|
Summary
Lets
ado-aw enableregister ADO build definitions whose source YAML lives on GitHub. This unblocks bulk-registering thetests/safe-outputs/*.lock.ymlsmoke pipelines intomsazuresphere/AgentPlaygroundfrom agithubnext/ado-awcheckout — previouslyenablehard-coded"type": "TfsGit"and bailed early on non-ADO remotes (src/enable.rs:148-156, 201-211onmain).Mental model
AdoContexthistorically conflated two namespaces. This PR keepsAdoContextstrictly as the deployment target ({org_url, project}) and introduces a separate source identity type:ado_org,ado_project)--org/--project(or git remote when source is ADO Git)RepoSource)provider,owner,repo)parse_git_remote(git remote)For ADO-source checkouts both halves coincide (no behavioural change). For GitHub source they diverge — the source is
(Github, githubnext, ado-aw)while the deployment is(msazuresphere, AgentPlayground).What's new
Provider autodetect — no
--repository-typeflag.parse_git_remotetries ADO first (dev.azure.com, legacy*.visualstudio.com), thengithub.comHTTPS / SSH, then bails. The result feeds into a newRepositoryRefenum that branchesbuild_create_bodybetween the existing TfsGit body and a GitHub body of the form:Two new flags on
enable:--service-connection <name-or-guid>— required when source is GitHub; rejected for ADO Git. Friendly names are resolved viaGET /_apis/serviceendpoint/endpoints?type=github&endpointNames=<name>(project-scoped) with operator-friendly 0/>1-match errors. A UUID regex short-circuits the API call when a GUID is passed.--repository-name owner/repo— optional override; auto-detected from a GitHub remote.Lifecycle commands (
list/disable/remove/run/status) need no code changes — they route through the lexicalmatch_definitionsmatcher, which only inspectsprocess.yamlFilenameandnameand is therefore provider-agnostic. A pinning test (match_definitions_in_works_for_github_source_definition) locks that contract in. They work from a GitHub checkout via explicit--org/--project.Operator usage
The new
tests/safe-outputs/REGISTERED.mdmanual-handoff checklist captures the full workflow. Short version:Out of scope (deliberate, documented in the plan)
github.example.com) — parser matchesgithub.comonly.--org/--projectdeployment coords to a local config file. Lifecycle commands against GitHub-source pipelines require these explicitly until that lands.# ado-aw-metadata: {org, repo}marker stays a pure source-identity token; GitHub-source compilations continue to emit emptyorg/repoand rely on the existing wildcard fallback. The URL filter in discovery is sufficient for the immediate use case; cross-talk is theoretical and not exercised by AgentPlayground.secrets --all-repos/--sourcediscovery migration —secrets --definition-idsworks for GitHub source already; the discovery-path migration is its own focused follow-up.Test plan
cargo build— clean.cargo clippy --all-targets --all-features— clean.cargo test— 1785 unit tests + every integration suite, 0 failures.parse_git_remote— ADO HTTPS/SSH/legacy, GitHub HTTPS/SSH,.gitsuffix, GHES rejection, unrelated-host rejection, missing-repo-half rejection (9 cases).RepoSource::url()— TfsGit + Github shapes (2 cases).is_uuid_likeandpick_service_endpoint_id— UUID short-circuit, 0/1/>1-match triage, missing-value-array bail (6 cases).build_create_bodyGitHub-shape snapshot +repository.id-omission guarantee (2 cases).resolve_sourceandsplit_owner_repo_arg— autodetect rule for all 6 (provider × override) combinations and arg parsing (10 cases).match_definitions_in_works_for_github_source_definition— pins provider-agnostic matching.enable_help_describes_github_source_supportintegration test confirms the new flags advertise correctly.cargo run -- enable --helpmanually verified.msazuresphere/AgentPlaygroundto follow once a GitHub service connection is set up in the project (the one operator step that can't be automated — no REST API).