Skip to content

Commit 928cd59

Browse files
fix(compile): normalize legacy Azure Repos remotes (#1495)
* Initial plan * fix: normalize legacy Azure Repos remotes Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
1 parent 148a4e1 commit 928cd59

10 files changed

Lines changed: 205 additions & 55 deletions

File tree

src/ado/mod.rs

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ impl AdoContext {
106106
/// Supports:
107107
/// - HTTPS: `https://dev.azure.com/{org}/{project}/_git/{repo}`
108108
/// - HTTPS (legacy): `https://{org}.visualstudio.com/{project}/_git/{repo}`
109+
/// - HTTPS (legacy collection):
110+
/// `https://{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}`
109111
/// - SSH: `git@ssh.dev.azure.com:v3/{org}/{project}/{repo}`
110112
/// - SSH (legacy): `git@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}`
111113
pub fn parse_ado_remote(remote_url: &str) -> Result<AdoContext> {
@@ -158,11 +160,21 @@ pub fn parse_ado_remote(remote_url: &str) -> Result<AdoContext> {
158160
.unwrap_or_default();
159161

160162
// Expected: /{project}/_git/{repo}
161-
if segments.len() >= 3 && segments[1] == "_git" {
162-
let repo_name = segments[2].trim_end_matches(".git");
163+
// Also accepts the legacy collection-prefixed shape:
164+
// /DefaultCollection/{project}/_git/{repo}
165+
let repo_start = if segments
166+
.first()
167+
.is_some_and(|segment| segment.eq_ignore_ascii_case("DefaultCollection"))
168+
{
169+
1
170+
} else {
171+
0
172+
};
173+
if segments.len() >= repo_start + 3 && segments[repo_start + 1] == "_git" {
174+
let repo_name = segments[repo_start + 2].trim_end_matches(".git");
163175
return Ok(AdoContext {
164176
org_url: format!("https://dev.azure.com/{}", org),
165-
project: segments[0].to_string(),
177+
project: segments[repo_start].to_string(),
166178
repo_name: repo_name.to_string(),
167179
});
168180
}
@@ -2107,6 +2119,15 @@ mod tests {
21072119
assert_eq!(ctx.repo_name, "myrepo");
21082120
}
21092121

2122+
#[test]
2123+
fn test_parse_ado_remote_legacy_visualstudio_default_collection() {
2124+
let url = "https://myorg.visualstudio.com/DefaultCollection/MyProject/_git/myrepo.git";
2125+
let ctx = parse_ado_remote(url).unwrap();
2126+
assert_eq!(ctx.org_url, "https://dev.azure.com/myorg");
2127+
assert_eq!(ctx.project, "MyProject");
2128+
assert_eq!(ctx.repo_name, "myrepo");
2129+
}
2130+
21102131
#[test]
21112132
fn test_parse_ado_remote_legacy_ssh() {
21122133
let url = "git@vs-ssh.visualstudio.com:v3/myorg/myproject/myrepo";
@@ -2152,6 +2173,18 @@ mod tests {
21522173
assert_eq!(source.project.as_deref(), Some("MyProject"));
21532174
}
21542175

2176+
#[test]
2177+
fn parse_git_remote_ado_legacy_visualstudio_default_collection() {
2178+
let source = parse_git_remote(
2179+
"https://myorg.visualstudio.com/DefaultCollection/MyProject/_git/myrepo.git",
2180+
)
2181+
.unwrap();
2182+
assert_eq!(source.provider, RepoProvider::AdoGit);
2183+
assert_eq!(source.owner, "myorg");
2184+
assert_eq!(source.repo, "myrepo");
2185+
assert_eq!(source.project.as_deref(), Some("MyProject"));
2186+
}
2187+
21552188
#[test]
21562189
fn parse_git_remote_github_https() {
21572190
let source = parse_git_remote("https://github.com/githubnext/ado-aw").unwrap();

src/compile/extensions/exec_context/ci_push.rs

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
//! the contributor does ADO REST + git fetch deepening work that
88
//! adds startup latency, so most agents shouldn't pay for it.
99
//!
10-
//! Runtime gate: `or(eq(Build.Reason, 'IndividualCI'),
11-
//! eq(Build.Reason, 'BatchedCI'))`. Skips PRs, scheduled runs, and
10+
//! Runtime gate: `and(succeeded(), or(eq(Build.Reason, 'IndividualCI'),
11+
//! eq(Build.Reason, 'BatchedCI')))`. Skips PRs, scheduled runs, and
1212
//! resource triggers at zero cost.
1313
//!
1414
//! ## Artefacts (staged by the bundle on success)
@@ -52,7 +52,7 @@ use crate::compile::ir::condition::{Condition, Expr};
5252
use crate::compile::ir::step::{BashStep, Step};
5353
use crate::compile::types::CiPushContextConfig;
5454

55-
use super::contributor::ContextContributor;
55+
use super::contributor::{ContextContributor, succeeded_and};
5656

5757
/// CI-push-context contributor.
5858
pub(super) struct CiPushContextContributor {
@@ -99,7 +99,7 @@ impl ContextContributor for CiPushContextContributor {
9999
"Stage ci-push execution context (aw-context/ci-push/*)",
100100
script,
101101
)
102-
.with_condition(Condition::Or(vec![
102+
.with_condition(succeeded_and(Condition::Or(vec![
103103
Condition::Eq(
104104
Expr::Variable("Build.Reason".to_string()),
105105
Expr::Literal("IndividualCI".to_string()),
@@ -108,7 +108,7 @@ impl ContextContributor for CiPushContextContributor {
108108
Expr::Variable("Build.Reason".to_string()),
109109
Expr::Literal("BatchedCI".to_string()),
110110
),
111-
])),
111+
]))),
112112
Bundle::ExecContextCiPush,
113113
TokenSource::SystemAccessToken,
114114
);
@@ -177,10 +177,14 @@ mod tests {
177177
Step::Bash(b) => b,
178178
other => panic!("expected Bash, got {other:?}"),
179179
};
180-
// Condition: or(eq(Build.Reason, IndividualCI),
181-
// eq(Build.Reason, BatchedCI))
180+
// Condition: and(succeeded(), or(eq(Build.Reason, IndividualCI),
181+
// eq(Build.Reason, BatchedCI)))
182182
match &bash.condition {
183-
Some(Condition::Or(clauses)) => {
183+
Some(Condition::And(parts)) => {
184+
assert!(matches!(parts.as_slice(), [Condition::Succeeded, _]));
185+
let Condition::Or(clauses) = &parts[1] else {
186+
panic!("expected Or trigger condition, got {:?}", parts[1]);
187+
};
184188
assert_eq!(clauses.len(), 2);
185189
let reasons: Vec<&str> = clauses
186190
.iter()
@@ -192,7 +196,7 @@ mod tests {
192196
assert!(reasons.contains(&"IndividualCI"));
193197
assert!(reasons.contains(&"BatchedCI"));
194198
}
195-
other => panic!("expected Or condition, got {other:?}"),
199+
other => panic!("expected succeeded And condition, got {other:?}"),
196200
}
197201

198202
// Trust boundary: bearer present, projected as a secret via the

src/compile/extensions/exec_context/contributor.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020
//! and lets us evolve the contract freely.
2121
2222
use crate::compile::extensions::CompileContext;
23+
use crate::compile::ir::condition::Condition;
24+
25+
/// Preserve ADO's implicit step success gate when adding a runtime trigger predicate.
26+
pub(super) fn succeeded_and(condition: Condition) -> Condition {
27+
Condition::And(vec![Condition::Succeeded, condition])
28+
}
2329

2430
/// A unit of per-trigger execution-context generation.
2531
///

src/compile/extensions/exec_context/manual.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
//!
44
//! Activates whenever the agent declares any `parameters:` block (and
55
//! the `execution-context.manual.enabled` switch is not `false`).
6-
//! Runtime gate: `eq(variables['Build.Reason'], 'Manual')`.
6+
//! Runtime gate: `and(succeeded(), eq(variables['Build.Reason'], 'Manual'))`.
77
//!
88
//! ## Artefacts (staged by the bundle on success)
99
//!
@@ -56,7 +56,7 @@ use crate::compile::types::ManualContextConfig;
5656
#[cfg(test)]
5757
use crate::compile::types::FrontMatter;
5858

59-
use super::contributor::ContextContributor;
59+
use super::contributor::{ContextContributor, succeeded_and};
6060

6161
/// Manual-context contributor.
6262
pub(super) struct ManualContextContributor {
@@ -152,10 +152,10 @@ impl ContextContributor for ManualContextContributor {
152152
"Stage manual execution context (aw-context/manual/*)",
153153
script,
154154
)
155-
.with_condition(Condition::Eq(
155+
.with_condition(succeeded_and(Condition::Eq(
156156
Expr::Variable("Build.Reason".to_string()),
157157
Expr::Literal("Manual".to_string()),
158-
))
158+
)))
159159
.with_env(
160160
"BUILD_REQUESTEDFOR",
161161
EnvValue::ado_macro("Build.RequestedFor")?,
@@ -297,13 +297,17 @@ mod tests {
297297
other => panic!("expected Step::Bash, got {other:?}"),
298298
};
299299

300-
// Condition: gated on Build.Reason == 'Manual'.
300+
// Condition: preserve the default success gate and then gate on Manual.
301301
match &bash.condition {
302-
Some(Condition::Eq(Expr::Variable(v), Expr::Literal(l))) => {
302+
Some(Condition::And(parts)) => {
303+
assert!(matches!(parts.as_slice(), [Condition::Succeeded, _]));
304+
let Condition::Eq(Expr::Variable(v), Expr::Literal(l)) = &parts[1] else {
305+
panic!("expected eq(Build.Reason, 'Manual'), got {:?}", parts[1]);
306+
};
303307
assert_eq!(v, "Build.Reason");
304308
assert_eq!(l, "Manual");
305309
}
306-
other => panic!("expected eq(Build.Reason, 'Manual'), got {other:?}"),
310+
other => panic!("expected succeeded And condition, got {other:?}"),
307311
}
308312

309313
// Requestor identity env vars present.

src/compile/extensions/exec_context/pipeline.rs

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//! Activates whenever the agent declares an `on.pipeline` resource
55
//! trigger (and the `execution-context.pipeline.enabled` switch is
66
//! not `false`). Runtime gate:
7-
//! `eq(variables['Build.Reason'], 'ResourceTrigger')`.
7+
//! `and(succeeded(), eq(variables['Build.Reason'], 'ResourceTrigger'))`.
88
//!
99
//! ## Artefacts (staged by the bundle on success)
1010
//!
@@ -32,7 +32,7 @@
3232
//! bearer for the Build REST API. The token is never written to
3333
//! disk, never logged, never passed in argv.
3434
//! - The step is gated by
35-
//! `condition: eq(variables['Build.Reason'], 'ResourceTrigger')`
35+
//! `condition: and(succeeded(), eq(variables['Build.Reason'], 'ResourceTrigger'))`
3636
//! so it never runs on non-pipeline-completion builds.
3737
//! - All staged artefacts are short, structured ADO REST output —
3838
//! no user-controlled HTML, no free-text fields (the upstream
@@ -46,7 +46,7 @@ use crate::compile::ir::condition::{Condition, Expr};
4646
use crate::compile::ir::step::{BashStep, Step};
4747
use crate::compile::types::PipelineContextConfig;
4848

49-
use super::contributor::ContextContributor;
49+
use super::contributor::{ContextContributor, succeeded_and};
5050

5151
/// Pipeline-context contributor.
5252
pub(super) struct PipelineContextContributor {
@@ -98,10 +98,10 @@ impl ContextContributor for PipelineContextContributor {
9898
"Stage pipeline execution context (aw-context/pipeline/*)",
9999
script,
100100
)
101-
.with_condition(Condition::Eq(
101+
.with_condition(succeeded_and(Condition::Eq(
102102
Expr::Variable("Build.Reason".to_string()),
103103
Expr::Literal("ResourceTrigger".to_string()),
104-
)),
104+
))),
105105
Bundle::ExecContextPipeline,
106106
TokenSource::SystemAccessToken,
107107
);
@@ -181,13 +181,20 @@ mod tests {
181181
other => panic!("expected Step::Bash, got {other:?}"),
182182
};
183183

184-
// Runtime gate.
184+
// Runtime gate preserves the default success gate.
185185
match &bash.condition {
186-
Some(Condition::Eq(Expr::Variable(v), Expr::Literal(l))) => {
186+
Some(Condition::And(parts)) => {
187+
assert!(matches!(parts.as_slice(), [Condition::Succeeded, _]));
188+
let Condition::Eq(Expr::Variable(v), Expr::Literal(l)) = &parts[1] else {
189+
panic!(
190+
"expected eq(Build.Reason, 'ResourceTrigger'), got {:?}",
191+
parts[1]
192+
);
193+
};
187194
assert_eq!(v, "Build.Reason");
188195
assert_eq!(l, "ResourceTrigger");
189196
}
190-
other => panic!("expected eq(Build.Reason, 'ResourceTrigger'), got {other:?}"),
197+
other => panic!("expected succeeded And condition, got {other:?}"),
191198
}
192199

193200
// Bearer present.

src/compile/extensions/exec_context/pr.rs

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@
4242
//! regex validation, etc.) on top of the same Node-step exposure.
4343
//! - The token is never written to `.git/config`; `persistCredentials`
4444
//! is never `true`; no checkout override is emitted.
45-
//! - The step is gated by `condition: eq(variables['Build.Reason'],
46-
//! 'PullRequest')` so it never runs on non-PR builds.
45+
//! - The step is gated by `condition: and(succeeded(),
46+
//! eq(variables['Build.Reason'], 'PullRequest'))` so it never runs
47+
//! after an earlier failure or on non-PR builds.
4748
//!
4849
//! ## Wiring
4950
//!
@@ -66,7 +67,7 @@ use crate::compile::ir::env::EnvValue;
6667
use crate::compile::ir::step::{BashStep, Step};
6768
use crate::compile::types::PrContextConfig;
6869

69-
use super::contributor::ContextContributor;
70+
use super::contributor::{ContextContributor, succeeded_and};
7071

7172
/// PR-context contributor. Activates when `on.pr` is configured
7273
/// (unless explicitly disabled via `execution-context.pr.enabled: false`).
@@ -138,10 +139,10 @@ impl ContextContributor for PrContextContributor {
138139
} else {
139140
(
140141
"",
141-
Condition::Eq(
142+
succeeded_and(Condition::Eq(
142143
Expr::Variable("Build.Reason".to_string()),
143144
Expr::Literal("PullRequest".to_string()),
144-
),
145+
)),
145146
)
146147
};
147148
let script = format!("set -euo pipefail\n{prelude}node '{EXEC_CONTEXT_PR_PATH}'\n");
@@ -286,7 +287,7 @@ mod tests {
286287

287288
/// Synth-inactive: the auto-injected `SYSTEM_PULLREQUEST_*` and
288289
/// `SYSTEM_TEAMPROJECT` / `BUILD_*` context vars are NOT re-projected;
289-
/// condition is the typed `Eq(Variable("Build.Reason"), Literal("PullRequest"))`.
290+
/// condition preserves the default success gate and then gates on PullRequest.
290291
#[test]
291292
fn prepare_step_typed_synth_inactive_uses_plain_macros_and_narrow_condition() {
292293
let contributor = PrContextContributor::new(PrContextConfig::default(), false);
@@ -323,11 +324,18 @@ mod tests {
323324
assert!(!bash.env.contains_key("AW_SYNTHETIC_PR"));
324325

325326
match bash.condition.as_ref().expect("condition required") {
326-
Condition::Eq(Expr::Variable(name), Expr::Literal(lit)) => {
327+
Condition::And(parts) => {
328+
assert!(matches!(parts.as_slice(), [Condition::Succeeded, _]));
329+
let Condition::Eq(Expr::Variable(name), Expr::Literal(lit)) = &parts[1] else {
330+
panic!(
331+
"expected Condition::Eq(Variable, Literal), got {:?}",
332+
parts[1]
333+
);
334+
};
327335
assert_eq!(name, "Build.Reason");
328336
assert_eq!(lit, "PullRequest");
329337
}
330-
other => panic!("expected Condition::Eq(Variable, Literal), got {other:?}"),
338+
other => panic!("expected succeeded And condition, got {other:?}"),
331339
}
332340
}
333341
}

src/compile/extensions/exec_context/pr_checks.rs

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use crate::compile::ir::env::EnvValue;
2727
use crate::compile::ir::step::{BashStep, Step};
2828
use crate::compile::types::PrChecksContextConfig;
2929

30-
use super::contributor::ContextContributor;
30+
use super::contributor::{ContextContributor, succeeded_and};
3131

3232
pub(super) struct PrChecksContextContributor {
3333
config: PrChecksContextConfig,
@@ -88,10 +88,10 @@ impl ContextContributor for PrChecksContextContributor {
8888
)
8989
} else {
9090
(
91-
Condition::Eq(
91+
succeeded_and(Condition::Eq(
9292
Expr::Variable("Build.Reason".to_string()),
9393
Expr::Literal("PullRequest".to_string()),
94-
),
94+
)),
9595
"",
9696
)
9797
};
@@ -199,8 +199,8 @@ mod tests {
199199

200200
#[test]
201201
fn prepare_step_carries_bearer_condition_and_pr_id() {
202-
// Non-synth path: condition must be eq(Build.Reason, 'PullRequest'),
203-
// PR ID env must be the plain ADO macro (not a pipeline var).
202+
// Non-synth path: condition must preserve succeeded() and gate on
203+
// PullRequest; PR ID env must not be re-projected.
204204
let c = PrChecksContextContributor::new(
205205
PrChecksContextConfig {
206206
enabled: Some(true),
@@ -220,15 +220,20 @@ mod tests {
220220
bash.env.get("SYSTEM_ACCESSTOKEN"),
221221
Some(EnvValue::Secret(v)) if v == "System.AccessToken"
222222
));
223-
// Runtime gate: step must only fire on PR builds.
223+
// Runtime gate: step must only fire on PR builds after prior success.
224224
match bash.condition.as_ref().expect("condition required") {
225-
Condition::Eq(Expr::Variable(v), Expr::Literal(l)) => {
225+
Condition::And(parts) => {
226+
assert!(matches!(parts.as_slice(), [Condition::Succeeded, _]));
227+
let Condition::Eq(Expr::Variable(v), Expr::Literal(l)) = &parts[1] else {
228+
panic!(
229+
"expected Condition::Eq(Variable(Build.Reason), Literal(PullRequest)), got {:?}",
230+
parts[1]
231+
);
232+
};
226233
assert_eq!(v, "Build.Reason");
227234
assert_eq!(l, "PullRequest");
228235
}
229-
other => panic!(
230-
"expected Condition::Eq(Variable(Build.Reason), Literal(PullRequest)), got {other:?}"
231-
),
236+
other => panic!("expected succeeded And condition, got {other:?}"),
232237
}
233238
// Non-synth path: the bundle reads the auto-injected
234239
// SYSTEM_PULLREQUEST_PULLREQUESTID (and other context vars) directly,

0 commit comments

Comments
 (0)