diff --git a/src/execute.rs b/src/execute.rs index 4c7b1bf9..b8f9b46f 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -145,6 +145,17 @@ fn log_execution_context(safe_output_dir: &Path, ctx: &ExecutionContext) { debug!("ADO project: {}", ctx.ado_project.as_deref().unwrap_or("")); debug!("Repository ID: {}", ctx.repository_id.as_deref().unwrap_or("")); debug!("Repository name: {}", ctx.repository_name.as_deref().unwrap_or("")); + debug!( + "Build ID: {}", + ctx.build_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "".to_string()) + ); + debug!("Build reason: {}", ctx.build_reason.as_deref().unwrap_or("")); + debug!( + "Triggered by definition: {}", + ctx.triggered_by_definition_name.as_deref().unwrap_or("") + ); if !ctx.allowed_repositories.is_empty() { debug!( "Allowed repositories: {}", @@ -376,6 +387,7 @@ mod tests { allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; let result = execute_safe_output(&entry, &ctx).await; @@ -410,6 +422,7 @@ mod tests { allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; let result = execute_safe_output(&entry, &ctx).await; @@ -560,6 +573,7 @@ mod tests { allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; let result = execute_safe_output(&entry, &ctx).await; @@ -604,6 +618,7 @@ mod tests { allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; let result = execute_safe_output(&entry, &ctx).await; @@ -648,6 +663,7 @@ mod tests { allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; let result = execute_safe_output(&entry, &ctx).await; @@ -697,6 +713,7 @@ mod tests { allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; let results = execute_safe_outputs(temp_dir.path(), &ctx).await; @@ -907,6 +924,7 @@ mod tests { allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; let results = execute_safe_outputs(temp_dir.path(), &ctx).await; @@ -952,6 +970,7 @@ mod tests { allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; let results = execute_safe_outputs(temp_dir.path(), &ctx).await.unwrap(); @@ -1055,6 +1074,7 @@ mod tests { allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; let result = execute_safe_output(&entry, &ctx).await; @@ -1084,6 +1104,7 @@ mod tests { allowed_repositories: HashMap::new(), agent_stats: None, dry_run: true, + ..Default::default() }; let result = execute_safe_output(&entry, &ctx).await; diff --git a/src/safeoutputs/add_build_tag.rs b/src/safeoutputs/add_build_tag.rs index 8e4711b1..335e001f 100644 --- a/src/safeoutputs/add_build_tag.rs +++ b/src/safeoutputs/add_build_tag.rs @@ -135,9 +135,11 @@ impl Executor for AddBuildTagResult { // 2b. Scope check: by default only the current build can be tagged if !config.allow_any_build { - let current_build_id: Option = std::env::var("BUILD_BUILDID") - .ok() - .and_then(|s| s.parse().ok()); + // Pulled from ctx (sourced from BUILD_BUILDID); narrowed to i32 to + // match the agent-supplied build_id type. + let current_build_id: Option = ctx + .build_id + .and_then(|id| i32::try_from(id).ok()); if let Some(current_id) = current_build_id { if self.build_id != current_id { return Ok(ExecutionResult::failure(format!( @@ -147,7 +149,7 @@ impl Executor for AddBuildTagResult { ))); } } - // If BUILD_BUILDID is not set (e.g. local execution), allow any build + // If build_id is not set (e.g. local execution), allow any build } // 3. Apply tag prefix if configured diff --git a/src/safeoutputs/create_wiki_page.rs b/src/safeoutputs/create_wiki_page.rs index 2b49c15b..67fdea5e 100644 --- a/src/safeoutputs/create_wiki_page.rs +++ b/src/safeoutputs/create_wiki_page.rs @@ -701,6 +701,7 @@ wiki-name: "MyProject.wiki" allowed_repositories: std::collections::HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; // wiki-name not in config → should return Err @@ -766,6 +767,7 @@ wiki-name: "MyProject.wiki" allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; let outcome = result.execute_impl(&ctx).await.unwrap(); @@ -806,6 +808,7 @@ wiki-name: "MyProject.wiki" allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; let outcome = result.execute_impl(&ctx).await.unwrap(); @@ -846,6 +849,7 @@ wiki-name: "MyProject.wiki" allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; // The GET will fail (network unreachable with a fake host), so the diff --git a/src/safeoutputs/result.rs b/src/safeoutputs/result.rs index 0935cca2..5de9f2c3 100644 --- a/src/safeoutputs/result.rs +++ b/src/safeoutputs/result.rs @@ -61,6 +61,53 @@ pub struct ExecutionContext { pub agent_stats: Option, /// When true, executors validate inputs but skip network calls pub dry_run: bool, + + // ── ADO build variables (from BUILD_*/SYSTEM_*) ─────────────────────── + /// Numeric build ID (`BUILD_BUILDID`) + pub build_id: Option, + /// Human-readable build number (`BUILD_BUILDNUMBER`) + #[allow(dead_code)] + pub build_number: Option, + /// What kicked off this run, e.g. `Manual`, `Schedule`, `ResourceTrigger`, + /// `PullRequest` (`BUILD_REASON`) + pub build_reason: Option, + /// Pipeline definition name (`BUILD_DEFINITIONNAME`) + #[allow(dead_code)] + pub definition_name: Option, + /// Full source ref, e.g. `refs/heads/main` (`BUILD_SOURCEBRANCH`) + #[allow(dead_code)] + pub source_branch: Option, + /// Short branch name, e.g. `main` (`BUILD_SOURCEBRANCHNAME`) + #[allow(dead_code)] + pub source_branch_name: Option, + /// Source commit SHA (`BUILD_SOURCEVERSION`) + #[allow(dead_code)] + pub source_version: Option, + + // ── ResourceTrigger upstream-pipeline variables ─────────────────────── + /// Upstream build ID when triggered by another pipeline + /// (`BUILD_TRIGGEREDBY_BUILDID`) + #[allow(dead_code)] + pub triggered_by_build_id: Option, + /// Upstream pipeline definition name (`BUILD_TRIGGEREDBY_DEFINITIONNAME`) + pub triggered_by_definition_name: Option, + /// Upstream pipeline build number (`BUILD_TRIGGEREDBY_BUILDNUMBER`) + #[allow(dead_code)] + pub triggered_by_build_number: Option, + /// Project hosting the upstream pipeline (`BUILD_TRIGGEREDBY_PROJECTID`) + #[allow(dead_code)] + pub triggered_by_project_id: Option, + + // ── PullRequest variables ───────────────────────────────────────────── + /// PR ID when `BUILD_REASON=PullRequest` (`SYSTEM_PULLREQUEST_PULLREQUESTID`) + #[allow(dead_code)] + pub pull_request_id: Option, + /// PR source branch (`SYSTEM_PULLREQUEST_SOURCEBRANCH`) + #[allow(dead_code)] + pub pull_request_source_branch: Option, + /// PR target branch (`SYSTEM_PULLREQUEST_TARGETBRANCH`) + #[allow(dead_code)] + pub pull_request_target_branch: Option, } impl ExecutionContext { @@ -80,12 +127,19 @@ impl ExecutionContext { } } -impl Default for ExecutionContext { - fn default() -> Self { +impl ExecutionContext { + /// Build an `ExecutionContext` from an arbitrary env-var lookup function. + /// + /// `Default::default()` calls this with `|k| std::env::var(k).ok()`. Tests + /// can pass a closure backed by a `HashMap` so they exercise field + /// population without mutating the (process-global) environment. + pub fn from_env_lookup(env: F) -> Self + where + F: Fn(&str) -> Option, + { // Try AZURE_DEVOPS_ORG_URL first, then fall back to Azure DevOps built-in var - let ado_org_url = std::env::var("AZURE_DEVOPS_ORG_URL") - .ok() - .or_else(|| std::env::var("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI").ok()); + let ado_org_url = env("AZURE_DEVOPS_ORG_URL") + .or_else(|| env("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI")); // Extract organization name from URL (e.g., "https://dev.azure.com/myorg/" -> "myorg") let ado_organization = ado_org_url.as_ref().and_then(|url| { @@ -97,29 +151,53 @@ impl Default for ExecutionContext { }); // Source directory is where git repos are checked out (BUILD_SOURCESDIRECTORY) - let source_directory = std::env::var("BUILD_SOURCESDIRECTORY") + let source_directory = env("BUILD_SOURCESDIRECTORY") .map(std::path::PathBuf::from) - .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default()); + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); Self { ado_org_url, ado_organization, - ado_project: std::env::var("SYSTEM_TEAMPROJECT").ok(), - access_token: std::env::var("SYSTEM_ACCESSTOKEN") - .ok() - .or_else(|| std::env::var("AZURE_DEVOPS_EXT_PAT").ok()), + ado_project: env("SYSTEM_TEAMPROJECT"), + access_token: env("SYSTEM_ACCESSTOKEN").or_else(|| env("AZURE_DEVOPS_EXT_PAT")), working_directory: std::env::current_dir().unwrap_or_default(), source_directory, tool_configs: HashMap::new(), - repository_id: std::env::var("BUILD_REPOSITORY_ID").ok(), - repository_name: std::env::var("BUILD_REPOSITORY_NAME").ok(), + repository_id: env("BUILD_REPOSITORY_ID"), + repository_name: env("BUILD_REPOSITORY_NAME"), allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + + // Build identification + build_id: env("BUILD_BUILDID").and_then(|s| s.parse().ok()), + build_number: env("BUILD_BUILDNUMBER"), + build_reason: env("BUILD_REASON"), + definition_name: env("BUILD_DEFINITIONNAME"), + source_branch: env("BUILD_SOURCEBRANCH"), + source_branch_name: env("BUILD_SOURCEBRANCHNAME"), + source_version: env("BUILD_SOURCEVERSION"), + + // ResourceTrigger upstream-pipeline variables + triggered_by_build_id: env("BUILD_TRIGGEREDBY_BUILDID"), + triggered_by_definition_name: env("BUILD_TRIGGEREDBY_DEFINITIONNAME"), + triggered_by_build_number: env("BUILD_TRIGGEREDBY_BUILDNUMBER"), + triggered_by_project_id: env("BUILD_TRIGGEREDBY_PROJECTID"), + + // Pull request variables + pull_request_id: env("SYSTEM_PULLREQUEST_PULLREQUESTID"), + pull_request_source_branch: env("SYSTEM_PULLREQUEST_SOURCEBRANCH"), + pull_request_target_branch: env("SYSTEM_PULLREQUEST_TARGETBRANCH"), } } } +impl Default for ExecutionContext { + fn default() -> Self { + Self::from_env_lookup(|k| std::env::var(k).ok()) + } +} + /// Result of executing a tool action in Stage 3 #[derive(Debug, Serialize)] pub struct ExecutionResult { @@ -590,4 +668,108 @@ mod tests { config.value ); } + + // ── ADO build variable capture tests (use from_env_lookup so they + // don't mutate the process-global environment) ───────────────────── + + fn env_from(map: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let owned: HashMap = map + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + move |k| owned.get(k).cloned() + } + + #[test] + fn test_from_env_lookup_populates_build_fields() { + let ctx = ExecutionContext::from_env_lookup(env_from(&[ + ("BUILD_BUILDID", "12345"), + ("BUILD_BUILDNUMBER", "20240101.1"), + ("BUILD_REASON", "Manual"), + ("BUILD_DEFINITIONNAME", "My Pipeline"), + ("BUILD_SOURCEBRANCH", "refs/heads/main"), + ("BUILD_SOURCEBRANCHNAME", "main"), + ("BUILD_SOURCEVERSION", "abc1234"), + ])); + assert_eq!(ctx.build_id, Some(12345)); + assert_eq!(ctx.build_number.as_deref(), Some("20240101.1")); + assert_eq!(ctx.build_reason.as_deref(), Some("Manual")); + assert_eq!(ctx.definition_name.as_deref(), Some("My Pipeline")); + assert_eq!(ctx.source_branch.as_deref(), Some("refs/heads/main")); + assert_eq!(ctx.source_branch_name.as_deref(), Some("main")); + assert_eq!(ctx.source_version.as_deref(), Some("abc1234")); + } + + #[test] + fn test_from_env_lookup_build_id_parses_numeric() { + let ctx = ExecutionContext::from_env_lookup(env_from(&[("BUILD_BUILDID", "987654")])); + assert_eq!(ctx.build_id, Some(987654)); + } + + #[test] + fn test_from_env_lookup_build_id_none_for_non_numeric() { + let ctx = ExecutionContext::from_env_lookup(env_from(&[("BUILD_BUILDID", "not-a-number")])); + assert!(ctx.build_id.is_none()); + } + + #[test] + fn test_from_env_lookup_build_id_none_when_unset() { + let ctx = ExecutionContext::from_env_lookup(env_from(&[])); + assert!(ctx.build_id.is_none()); + } + + #[test] + fn test_from_env_lookup_populates_triggered_by_fields() { + let ctx = ExecutionContext::from_env_lookup(env_from(&[ + ("BUILD_REASON", "ResourceTrigger"), + ("BUILD_TRIGGEREDBY_BUILDID", "42"), + ("BUILD_TRIGGEREDBY_DEFINITIONNAME", "Upstream Build"), + ("BUILD_TRIGGEREDBY_BUILDNUMBER", "20240101.7"), + ("BUILD_TRIGGEREDBY_PROJECTID", "proj-guid"), + ])); + assert_eq!(ctx.build_reason.as_deref(), Some("ResourceTrigger")); + assert_eq!(ctx.triggered_by_build_id.as_deref(), Some("42")); + assert_eq!( + ctx.triggered_by_definition_name.as_deref(), + Some("Upstream Build") + ); + assert_eq!(ctx.triggered_by_build_number.as_deref(), Some("20240101.7")); + assert_eq!(ctx.triggered_by_project_id.as_deref(), Some("proj-guid")); + } + + #[test] + fn test_from_env_lookup_triggered_by_none_when_unset() { + let ctx = ExecutionContext::from_env_lookup(env_from(&[])); + assert!(ctx.triggered_by_build_id.is_none()); + assert!(ctx.triggered_by_definition_name.is_none()); + assert!(ctx.triggered_by_build_number.is_none()); + assert!(ctx.triggered_by_project_id.is_none()); + } + + #[test] + fn test_from_env_lookup_populates_pull_request_fields() { + let ctx = ExecutionContext::from_env_lookup(env_from(&[ + ("BUILD_REASON", "PullRequest"), + ("SYSTEM_PULLREQUEST_PULLREQUESTID", "789"), + ("SYSTEM_PULLREQUEST_SOURCEBRANCH", "refs/heads/feature"), + ("SYSTEM_PULLREQUEST_TARGETBRANCH", "refs/heads/main"), + ])); + assert_eq!(ctx.pull_request_id.as_deref(), Some("789")); + assert_eq!( + ctx.pull_request_source_branch.as_deref(), + Some("refs/heads/feature") + ); + assert_eq!( + ctx.pull_request_target_branch.as_deref(), + Some("refs/heads/main") + ); + } + + #[test] + fn test_from_env_lookup_pull_request_none_when_unset() { + let ctx = ExecutionContext::from_env_lookup(env_from(&[])); + assert!(ctx.pull_request_id.is_none()); + assert!(ctx.pull_request_source_branch.is_none()); + assert!(ctx.pull_request_target_branch.is_none()); + } } diff --git a/src/safeoutputs/update_wiki_page.rs b/src/safeoutputs/update_wiki_page.rs index b4669949..c0824b54 100644 --- a/src/safeoutputs/update_wiki_page.rs +++ b/src/safeoutputs/update_wiki_page.rs @@ -673,6 +673,7 @@ wiki-name: "MyProject.wiki" allowed_repositories: std::collections::HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; // wiki-name not in config → should return Err @@ -738,6 +739,7 @@ wiki-name: "MyProject.wiki" allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; let outcome = result.execute_impl(&ctx).await.unwrap(); @@ -778,6 +780,7 @@ wiki-name: "MyProject.wiki" allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; let outcome = result.execute_impl(&ctx).await.unwrap(); @@ -818,6 +821,7 @@ wiki-name: "MyProject.wiki" allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; // The GET will fail (network unreachable with a fake host), so the diff --git a/src/safeoutputs/update_work_item.rs b/src/safeoutputs/update_work_item.rs index 3937df1e..0a58ec48 100644 --- a/src/safeoutputs/update_work_item.rs +++ b/src/safeoutputs/update_work_item.rs @@ -749,6 +749,7 @@ target: 42 allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; let exec_result = result.execute_sanitized(&ctx).await; @@ -800,6 +801,7 @@ target: 42 allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; let exec_result = result.execute_sanitized(&ctx).await.unwrap(); @@ -847,6 +849,7 @@ target: 42 allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; let exec_result = result.execute_sanitized(&ctx).await.unwrap(); @@ -896,6 +899,7 @@ target: 42 allowed_repositories: HashMap::new(), agent_stats: None, dry_run: false, + ..Default::default() }; let exec_result = result.execute_sanitized(&ctx).await.unwrap();