From 20ac3fedc6586a0e03618e3b64845dfcb6f46b68 Mon Sep 17 00:00:00 2001 From: Matt Matheson Date: Tue, 13 Jan 2026 13:01:57 -0800 Subject: [PATCH 1/3] add tests --- context/src/env/parser.rs | 39 ++++- context/tests/env.rs | 298 +++++++++++++++++++++++++++++++++++++- 2 files changed, 333 insertions(+), 4 deletions(-) diff --git a/context/src/env/parser.rs b/context/src/env/parser.rs index 9397df3e..67a01ac8 100644 --- a/context/src/env/parser.rs +++ b/context/src/env/parser.rs @@ -1,5 +1,5 @@ #[cfg(feature = "ruby")] -use magnus::{value::ReprValue, Module, Object}; +use magnus::{Module, Object, value::ReprValue}; #[cfg(feature = "pyo3")] use pyo3::prelude::*; #[cfg(feature = "pyo3")] @@ -224,12 +224,12 @@ impl<'a> CIInfoParser<'a> { CIPlatform::Semaphore => self.parse_semaphore(), CIPlatform::GitLabCI => self.parse_gitlab_ci(), CIPlatform::Drone => self.parse_drone(), + CIPlatform::BitbucketPipelines => self.parse_bitbucket_pipelines(), CIPlatform::Custom => self.parse_custom_info(), CIPlatform::CircleCI | CIPlatform::TravisCI | CIPlatform::Webappio | CIPlatform::AWSCodeBuild - | CIPlatform::BitbucketPipelines | CIPlatform::AzurePipelines | CIPlatform::Unknown => { // TODO(TRUNK-12908): Switch to using a crate for parsing the CI platform and related env vars @@ -429,6 +429,41 @@ impl<'a> CIInfoParser<'a> { self.ci_info.job_url = self.get_env_var("DRONE_BUILD_LINK"); } + fn parse_bitbucket_pipelines(&mut self) { + // Construct job URL from workspace, repo slug, and build number + // Format: https://bitbucket.org/{workspace}/{repo_slug}/pipelines/results/{build_number} + // With step: https://bitbucket.org/{workspace}/{repo_slug}/pipelines/results/{build_number}/steps/{step_uuid} + self.ci_info.job_url = match ( + self.get_env_var("BITBUCKET_WORKSPACE"), + self.get_env_var("BITBUCKET_REPO_SLUG"), + self.get_env_var("BITBUCKET_BUILD_NUMBER"), + ) { + (Some(workspace), Some(repo_slug), Some(build_number)) => { + let base_url = format!( + "https://bitbucket.org/{workspace}/{repo_slug}/pipelines/results/{build_number}" + ); + // Optionally append step UUID for more specific link + if let Some(step_uuid) = self.get_env_var("BITBUCKET_STEP_UUID") { + Some(format!("{base_url}/steps/{step_uuid}")) + } else { + Some(base_url) + } + } + _ => None, + }; + + self.ci_info.branch = self.get_env_var("BITBUCKET_BRANCH"); + self.ci_info.pr_number = Self::parse_pr_number(self.get_env_var("BITBUCKET_PR_ID")); + + // Use pipeline UUID as workflow identifier and step UUID as job identifier + self.ci_info.workflow = self.get_env_var("BITBUCKET_PIPELINE_UUID"); + self.ci_info.job = self.get_env_var("BITBUCKET_STEP_UUID"); + + // Note: Bitbucket Pipelines doesn't provide author/committer info, commit message, + // or PR title via environment variables. These will be populated from repo info + // via apply_repo_overrides(), or users can set them via CUSTOM env vars. + } + fn get_env_var>(&self, env_var: T) -> Option { self.env_vars .get(env_var.as_ref()) diff --git a/context/tests/env.rs b/context/tests/env.rs index 5b1b0caa..8ac53c3a 100644 --- a/context/tests/env.rs +++ b/context/tests/env.rs @@ -1,8 +1,7 @@ use context::env::{ - self, + self, EnvVars, parser::{BranchClass, CIInfo, CIPlatform, EnvParser}, validator::{EnvValidationIssue, EnvValidationIssueSubOptimal, EnvValidationLevel}, - EnvVars, }; #[test] @@ -962,6 +961,301 @@ fn test_simple_gitlab_stable_branches() { ); } +#[test] +fn test_simple_bitbucket() { + let workspace = String::from("my-workspace"); + let repo_slug = String::from("my-repo"); + let build_number = String::from("42"); + let branch = String::from("feature-branch"); + let pipeline_uuid = String::from("{12345678-1234-1234-1234-123456789abc}"); + let step_uuid = String::from("{abcdef12-3456-7890-abcd-ef1234567890}"); + + let env_vars = EnvVars::from_iter(vec![ + ( + String::from("BITBUCKET_BUILD_NUMBER"), + String::from(&build_number), + ), + ( + String::from("BITBUCKET_WORKSPACE"), + String::from(&workspace), + ), + ( + String::from("BITBUCKET_REPO_SLUG"), + String::from(&repo_slug), + ), + (String::from("BITBUCKET_BRANCH"), String::from(&branch)), + ( + String::from("BITBUCKET_PIPELINE_UUID"), + String::from(&pipeline_uuid), + ), + ( + String::from("BITBUCKET_STEP_UUID"), + String::from(&step_uuid), + ), + ]); + + let mut env_parser = EnvParser::new(); + env_parser.parse(&env_vars, &[], None); + + let ci_info = env_parser.into_ci_info_parser().unwrap().info_ci_info(); + + pretty_assertions::assert_eq!( + ci_info, + CIInfo { + platform: CIPlatform::BitbucketPipelines, + job_url: Some(format!( + "https://bitbucket.org/{workspace}/{repo_slug}/pipelines/results/{build_number}/steps/{step_uuid}" + )), + branch: Some(branch), + branch_class: Some(BranchClass::None), + pr_number: None, + actor: None, + committer_name: None, + committer_email: None, + author_name: None, + author_email: None, + commit_message: None, + title: None, + workflow: Some(pipeline_uuid), + job: Some(step_uuid), + } + ); + + let env_validation = env::validator::validate(&ci_info); + assert_eq!(env_validation.max_level(), EnvValidationLevel::SubOptimal); + pretty_assertions::assert_eq!( + env_validation.issues(), + &[ + EnvValidationIssue::SubOptimal(EnvValidationIssueSubOptimal::CIInfoActorTooShort( + String::from("") + )), + EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoAuthorEmailTooShort(String::from(""),), + ), + EnvValidationIssue::SubOptimal(EnvValidationIssueSubOptimal::CIInfoAuthorNameTooShort( + String::from(""), + ),), + EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoCommitMessageTooShort(String::from(""),), + ), + EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoCommitterEmailTooShort(String::from(""),), + ), + EnvValidationIssue::SubOptimal( + EnvValidationIssueSubOptimal::CIInfoCommitterNameTooShort(String::from(""),), + ), + EnvValidationIssue::SubOptimal(EnvValidationIssueSubOptimal::CIInfoTitleTooShort( + String::from(""), + ),), + ] + ); +} + +#[test] +fn test_bitbucket_pr() { + let workspace = String::from("my-workspace"); + let repo_slug = String::from("my-repo"); + let build_number = String::from("123"); + let branch = String::from("feature/add-tests"); + let pr_id = 456; + let pipeline_uuid = String::from("{pipeline-uuid-1234}"); + let step_uuid = String::from("{step-uuid-5678}"); + + let env_vars = EnvVars::from_iter(vec![ + ( + String::from("BITBUCKET_BUILD_NUMBER"), + String::from(&build_number), + ), + ( + String::from("BITBUCKET_WORKSPACE"), + String::from(&workspace), + ), + ( + String::from("BITBUCKET_REPO_SLUG"), + String::from(&repo_slug), + ), + (String::from("BITBUCKET_BRANCH"), String::from(&branch)), + (String::from("BITBUCKET_PR_ID"), pr_id.to_string()), + ( + String::from("BITBUCKET_PIPELINE_UUID"), + String::from(&pipeline_uuid), + ), + ( + String::from("BITBUCKET_STEP_UUID"), + String::from(&step_uuid), + ), + ]); + + let mut env_parser = EnvParser::new(); + env_parser.parse(&env_vars, &[], None); + + let ci_info = env_parser.into_ci_info_parser().unwrap().info_ci_info(); + + // Verify that PR branch class is correctly set when BITBUCKET_PR_ID is present + pretty_assertions::assert_eq!( + ci_info, + CIInfo { + platform: CIPlatform::BitbucketPipelines, + job_url: Some(format!( + "https://bitbucket.org/{workspace}/{repo_slug}/pipelines/results/{build_number}/steps/{step_uuid}" + )), + branch: Some(branch), + branch_class: Some(BranchClass::PullRequest), + pr_number: Some(pr_id), + actor: None, + committer_name: None, + committer_email: None, + author_name: None, + author_email: None, + commit_message: None, + title: None, + workflow: Some(pipeline_uuid), + job: Some(step_uuid), + } + ); +} + +#[test] +fn test_bitbucket_without_step_uuid() { + // Test that job URL works without step UUID (no /steps/ suffix) + // and that workflow/job are None when UUIDs not provided + let workspace = String::from("my-workspace"); + let repo_slug = String::from("my-repo"); + let build_number = String::from("99"); + let branch = String::from("develop"); + + let env_vars = EnvVars::from_iter(vec![ + ( + String::from("BITBUCKET_BUILD_NUMBER"), + String::from(&build_number), + ), + ( + String::from("BITBUCKET_WORKSPACE"), + String::from(&workspace), + ), + ( + String::from("BITBUCKET_REPO_SLUG"), + String::from(&repo_slug), + ), + (String::from("BITBUCKET_BRANCH"), String::from(&branch)), + ]); + + let mut env_parser = EnvParser::new(); + env_parser.parse(&env_vars, &[], None); + + let ci_info = env_parser.into_ci_info_parser().unwrap().info_ci_info(); + + pretty_assertions::assert_eq!( + ci_info, + CIInfo { + platform: CIPlatform::BitbucketPipelines, + job_url: Some(format!( + "https://bitbucket.org/{workspace}/{repo_slug}/pipelines/results/{build_number}" + )), + branch: Some(branch), + branch_class: Some(BranchClass::None), + pr_number: None, + actor: None, + committer_name: None, + committer_email: None, + author_name: None, + author_email: None, + commit_message: None, + title: None, + workflow: None, + job: None, + } + ); +} + +#[test] +fn test_bitbucket_stable_branch() { + let workspace = String::from("my-workspace"); + let repo_slug = String::from("my-repo"); + let build_number = String::from("200"); + let branch = String::from("main"); + + let env_vars = EnvVars::from_iter(vec![ + ( + String::from("BITBUCKET_BUILD_NUMBER"), + String::from(&build_number), + ), + ( + String::from("BITBUCKET_WORKSPACE"), + String::from(&workspace), + ), + ( + String::from("BITBUCKET_REPO_SLUG"), + String::from(&repo_slug), + ), + (String::from("BITBUCKET_BRANCH"), String::from(&branch)), + ]); + + let mut env_parser = EnvParser::new(); + env_parser.parse(&env_vars, &["main", "master"], None); + + let ci_info = env_parser.into_ci_info_parser().unwrap().info_ci_info(); + + pretty_assertions::assert_eq!( + ci_info, + CIInfo { + platform: CIPlatform::BitbucketPipelines, + job_url: Some(format!( + "https://bitbucket.org/{workspace}/{repo_slug}/pipelines/results/{build_number}" + )), + branch: Some(branch), + branch_class: Some(BranchClass::ProtectedBranch), + pr_number: None, + actor: None, + committer_name: None, + committer_email: None, + author_name: None, + author_email: None, + commit_message: None, + title: None, + workflow: None, + job: None, + } + ); +} + +#[test] +fn test_bitbucket_missing_job_url_vars() { + // Test that job_url is None when required vars are missing + let branch = String::from("feature-branch"); + + let env_vars = EnvVars::from_iter(vec![ + (String::from("BITBUCKET_BUILD_NUMBER"), String::from("42")), + // Missing BITBUCKET_WORKSPACE and BITBUCKET_REPO_SLUG + (String::from("BITBUCKET_BRANCH"), String::from(&branch)), + ]); + + let mut env_parser = EnvParser::new(); + env_parser.parse(&env_vars, &[], None); + + let ci_info = env_parser.into_ci_info_parser().unwrap().info_ci_info(); + + pretty_assertions::assert_eq!( + ci_info, + CIInfo { + platform: CIPlatform::BitbucketPipelines, + job_url: None, + branch: Some(branch), + branch_class: Some(BranchClass::None), + pr_number: None, + actor: None, + committer_name: None, + committer_email: None, + author_name: None, + author_email: None, + commit_message: None, + title: None, + workflow: None, + job: None, + } + ); +} + #[test] fn does_not_cross_contaminate_prioritizes_custom() { let pr_number = 123; From 8095efcd80fc6f6eb44e3ba28ae87b95c666b9e3 Mon Sep 17 00:00:00 2001 From: Matt Matheson Date: Tue, 13 Jan 2026 13:16:18 -0800 Subject: [PATCH 2/3] Update context/src/env/parser.rs Co-authored-by: Dylan Frankland Signed-off-by: Matt Matheson --- context/src/env/parser.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/context/src/env/parser.rs b/context/src/env/parser.rs index 67a01ac8..3b4fc206 100644 --- a/context/src/env/parser.rs +++ b/context/src/env/parser.rs @@ -433,24 +433,23 @@ impl<'a> CIInfoParser<'a> { // Construct job URL from workspace, repo slug, and build number // Format: https://bitbucket.org/{workspace}/{repo_slug}/pipelines/results/{build_number} // With step: https://bitbucket.org/{workspace}/{repo_slug}/pipelines/results/{build_number}/steps/{step_uuid} - self.ci_info.job_url = match ( + + if let (Some(workspace), Some(repo_slug), Some(build_number)) = ( self.get_env_var("BITBUCKET_WORKSPACE"), self.get_env_var("BITBUCKET_REPO_SLUG"), self.get_env_var("BITBUCKET_BUILD_NUMBER"), ) { - (Some(workspace), Some(repo_slug), Some(build_number)) => { + self.ci_info.job_url = Some({ let base_url = format!( "https://bitbucket.org/{workspace}/{repo_slug}/pipelines/results/{build_number}" ); - // Optionally append step UUID for more specific link if let Some(step_uuid) = self.get_env_var("BITBUCKET_STEP_UUID") { - Some(format!("{base_url}/steps/{step_uuid}")) + format!("{base_url}/steps/{step_uuid}") } else { - Some(base_url) + base_url } - } - _ => None, - }; + }); + } self.ci_info.branch = self.get_env_var("BITBUCKET_BRANCH"); self.ci_info.pr_number = Self::parse_pr_number(self.get_env_var("BITBUCKET_PR_ID")); From 5d12291a47e0d3ddbd86ac7bdfd7a963fd443248 Mon Sep 17 00:00:00 2001 From: Matt Matheson Date: Tue, 13 Jan 2026 14:29:30 -0800 Subject: [PATCH 3/3] fix the url encoding of the step uuid --- Cargo.lock | 1 + context/Cargo.toml | 1 + context/src/env/parser.rs | 23 ++++++++++++++++++++++- context/tests/env.rs | 8 ++++++-- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3bb439f4..38357c7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -952,6 +952,7 @@ dependencies = [ "magnus", "openssl", "openssl-src", + "percent-encoding", "pretty_assertions", "prost", "prost-wkt-types", diff --git a/context/Cargo.toml b/context/Cargo.toml index 9468726d..db73f052 100644 --- a/context/Cargo.toml +++ b/context/Cargo.toml @@ -13,6 +13,7 @@ itertools = "0.14.0" [dependencies] anyhow = "1.0.44" +percent-encoding = "2.3" bazel-bep = { path = "../bazel-bep" } chrono = "0.4.33" gix = { version = "0.74.0", default-features = false, features = [ diff --git a/context/src/env/parser.rs b/context/src/env/parser.rs index 3b4fc206..2e4e213c 100644 --- a/context/src/env/parser.rs +++ b/context/src/env/parser.rs @@ -1,5 +1,6 @@ #[cfg(feature = "ruby")] use magnus::{Module, Object, value::ReprValue}; +use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode}; #[cfg(feature = "pyo3")] use pyo3::prelude::*; #[cfg(feature = "pyo3")] @@ -444,7 +445,9 @@ impl<'a> CIInfoParser<'a> { "https://bitbucket.org/{workspace}/{repo_slug}/pipelines/results/{build_number}" ); if let Some(step_uuid) = self.get_env_var("BITBUCKET_STEP_UUID") { - format!("{base_url}/steps/{step_uuid}") + // URL-encode the step UUID for use in the URL path + let encoded_step_uuid = url_encode_path_segment(&step_uuid); + format!("{base_url}/steps/{encoded_step_uuid}") } else { base_url } @@ -640,6 +643,24 @@ pub fn clean_branch(branch: &str) -> String { return String::from(safe_truncate_string::(&new_branch)); } +/// Characters that need to be percent-encoded in URL path segments +/// This includes CONTROLS plus characters that are special in URLs +const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'<') + .add(b'>') + .add(b'`') + .add(b'?') + .add(b'{') + .add(b'}'); + +/// URL-encode a string for use in a URL path segment +fn url_encode_path_segment(s: &str) -> String { + utf8_percent_encode(s, PATH_SEGMENT_ENCODE_SET).to_string() +} + impl CIInfo { pub fn new(platform: CIPlatform) -> Self { Self { diff --git a/context/tests/env.rs b/context/tests/env.rs index 8ac53c3a..f0c13d10 100644 --- a/context/tests/env.rs +++ b/context/tests/env.rs @@ -999,12 +999,14 @@ fn test_simple_bitbucket() { let ci_info = env_parser.into_ci_info_parser().unwrap().info_ci_info(); + // step_uuid should be URL-encoded in the job_url (curly braces become %7B and %7D) + let encoded_step_uuid = step_uuid.replace('{', "%7B").replace('}', "%7D"); pretty_assertions::assert_eq!( ci_info, CIInfo { platform: CIPlatform::BitbucketPipelines, job_url: Some(format!( - "https://bitbucket.org/{workspace}/{repo_slug}/pipelines/results/{build_number}/steps/{step_uuid}" + "https://bitbucket.org/{workspace}/{repo_slug}/pipelines/results/{build_number}/steps/{encoded_step_uuid}" )), branch: Some(branch), branch_class: Some(BranchClass::None), @@ -1092,12 +1094,14 @@ fn test_bitbucket_pr() { let ci_info = env_parser.into_ci_info_parser().unwrap().info_ci_info(); // Verify that PR branch class is correctly set when BITBUCKET_PR_ID is present + // step_uuid should be URL-encoded in the job_url + let encoded_step_uuid = step_uuid.replace('{', "%7B").replace('}', "%7D"); pretty_assertions::assert_eq!( ci_info, CIInfo { platform: CIPlatform::BitbucketPipelines, job_url: Some(format!( - "https://bitbucket.org/{workspace}/{repo_slug}/pipelines/results/{build_number}/steps/{step_uuid}" + "https://bitbucket.org/{workspace}/{repo_slug}/pipelines/results/{build_number}/steps/{encoded_step_uuid}" )), branch: Some(branch), branch_class: Some(BranchClass::PullRequest),