From 20ac3fedc6586a0e03618e3b64845dfcb6f46b68 Mon Sep 17 00:00:00 2001 From: Matt Matheson Date: Tue, 13 Jan 2026 13:01:57 -0800 Subject: [PATCH 1/6] 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/6] 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/6] 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), From 22800979a0c8940eb3ee64fb9437a8bc312aa4de Mon Sep 17 00:00:00 2001 From: Matt Matheson Date: Tue, 13 Jan 2026 15:12:14 -0800 Subject: [PATCH 4/6] support circleci env parsing --- context/src/env/parser.rs | 19 +++- context/tests/env.rs | 189 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+), 2 deletions(-) diff --git a/context/src/env/parser.rs b/context/src/env/parser.rs index 2e4e213c..7f514c59 100644 --- a/context/src/env/parser.rs +++ b/context/src/env/parser.rs @@ -226,9 +226,9 @@ impl<'a> CIInfoParser<'a> { CIPlatform::GitLabCI => self.parse_gitlab_ci(), CIPlatform::Drone => self.parse_drone(), CIPlatform::BitbucketPipelines => self.parse_bitbucket_pipelines(), + CIPlatform::CircleCI => self.parse_circleci(), CIPlatform::Custom => self.parse_custom_info(), - CIPlatform::CircleCI - | CIPlatform::TravisCI + CIPlatform::TravisCI | CIPlatform::Webappio | CIPlatform::AWSCodeBuild | CIPlatform::AzurePipelines @@ -466,6 +466,21 @@ impl<'a> CIInfoParser<'a> { // via apply_repo_overrides(), or users can set them via CUSTOM env vars. } + fn parse_circleci(&mut self) { + // CircleCI provides CIRCLE_BUILD_URL directly + self.ci_info.job_url = self.get_env_var("CIRCLE_BUILD_URL"); + self.ci_info.branch = self.get_env_var("CIRCLE_BRANCH"); + self.ci_info.pr_number = Self::parse_pr_number(self.get_env_var("CIRCLE_PR_NUMBER")); + self.ci_info.actor = self.get_env_var("CIRCLE_USERNAME"); + + self.ci_info.workflow = self.get_env_var("CIRCLE_WORKFLOW_ID"); + self.ci_info.job = self.get_env_var("CIRCLE_JOB"); + + // Note: CircleCI doesn't provide author/committer info or commit message + // 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 f0c13d10..4a7d3b02 100644 --- a/context/tests/env.rs +++ b/context/tests/env.rs @@ -1260,6 +1260,195 @@ fn test_bitbucket_missing_job_url_vars() { ); } +// ==================== CircleCI Tests ==================== + +#[test] +fn test_simple_circleci() { + let branch = String::from("circleci-project-setup"); + let build_url = String::from("https://circleci.com/gh/trunk-io/trunk2/6"); + let workflow_id = String::from("5a984496-cf63-4f63-b315-5776a3186d4b"); + let job_name = String::from("unit-tests"); + let username = String::from("mmatheson"); + + let env_vars = EnvVars::from_iter(vec![ + (String::from("CI"), String::from("true")), + (String::from("CIRCLECI"), String::from("true")), + (String::from("CIRCLE_JOB"), String::from(&job_name)), + ( + String::from("CIRCLE_SHA1"), + String::from("fcddd4c25274e885fc6fd584b0d04290289b8e3e"), + ), + (String::from("CIRCLE_BRANCH"), String::from(&branch)), + (String::from("CIRCLE_USERNAME"), String::from(&username)), + (String::from("CIRCLE_BUILD_NUM"), String::from("6")), + (String::from("CIRCLE_BUILD_URL"), String::from(&build_url)), + ( + String::from("CIRCLE_WORKFLOW_ID"), + String::from(&workflow_id), + ), + ( + String::from("CIRCLE_REPOSITORY_URL"), + String::from("git@github.com:trunk-io/trunk2.git"), + ), + ( + String::from("CIRCLE_WORKFLOW_JOB_ID"), + String::from("8fa2fd0d-e60a-42ac-9be3-67255ba5badc"), + ), + ( + String::from("CIRCLE_PROJECT_REPONAME"), + String::from("trunk2"), + ), + ( + String::from("CIRCLE_PROJECT_USERNAME"), + String::from("trunk-io"), + ), + ]); + + 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::CircleCI, + job_url: Some(build_url), + branch: Some(branch), + branch_class: Some(BranchClass::None), + pr_number: None, + actor: Some(username), + committer_name: None, + committer_email: None, + author_name: None, + author_email: None, + commit_message: None, + title: None, + workflow: Some(workflow_id), + job: Some(job_name), + } + ); +} + +#[test] +fn test_circleci_pr() { + let branch = String::from("feature/add-feature"); + let build_url = String::from("https://circleci.com/gh/my-org/my-repo/456"); + let workflow_id = String::from("workflow-def-456"); + let job_name = String::from("build-and-test"); + let username = String::from("janedoe"); + let pr_number = 42; + + let env_vars = EnvVars::from_iter(vec![ + (String::from("CIRCLECI"), String::from("true")), + (String::from("CIRCLE_BRANCH"), String::from(&branch)), + (String::from("CIRCLE_BUILD_URL"), String::from(&build_url)), + ( + String::from("CIRCLE_WORKFLOW_ID"), + String::from(&workflow_id), + ), + (String::from("CIRCLE_JOB"), String::from(&job_name)), + (String::from("CIRCLE_USERNAME"), String::from(&username)), + (String::from("CIRCLE_PR_NUMBER"), pr_number.to_string()), + ]); + + 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::CircleCI, + job_url: Some(build_url), + branch: Some(branch), + branch_class: Some(BranchClass::PullRequest), + pr_number: Some(pr_number), + actor: Some(username), + committer_name: None, + committer_email: None, + author_name: None, + author_email: None, + commit_message: None, + title: None, + workflow: Some(workflow_id), + job: Some(job_name), + } + ); +} + +#[test] +fn test_circleci_stable_branch() { + let branch = String::from("main"); + let build_url = String::from("https://circleci.com/gh/my-org/my-repo/789"); + + let env_vars = EnvVars::from_iter(vec![ + (String::from("CIRCLECI"), String::from("true")), + (String::from("CIRCLE_BRANCH"), String::from(&branch)), + (String::from("CIRCLE_BUILD_URL"), String::from(&build_url)), + ]); + + let stable_branches: &[&str] = &["main", "master"]; + + let mut env_parser = EnvParser::new(); + env_parser.parse(&env_vars, stable_branches, None); + + let ci_info = env_parser.into_ci_info_parser().unwrap().info_ci_info(); + + pretty_assertions::assert_eq!( + ci_info, + CIInfo { + platform: CIPlatform::CircleCI, + job_url: Some(build_url), + 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_circleci_minimal() { + // Test that CircleCI works with minimal env vars (just the platform identifier) + // When no branch is set, branch_class is None + let env_vars = EnvVars::from_iter(vec![(String::from("CIRCLECI"), String::from("true"))]); + + 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::CircleCI, + job_url: None, + branch: None, + branch_class: None, // No branch means no branch_class + 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 77ac489780616bbd43956a9c3dc857782f43569a Mon Sep 17 00:00:00 2001 From: Matt Matheson Date: Tue, 13 Jan 2026 15:14:19 -0800 Subject: [PATCH 5/6] cleanup --- context/src/env/parser.rs | 5 ----- context/tests/env.rs | 2 -- 2 files changed, 7 deletions(-) diff --git a/context/src/env/parser.rs b/context/src/env/parser.rs index 7f514c59..aa0e693c 100644 --- a/context/src/env/parser.rs +++ b/context/src/env/parser.rs @@ -467,7 +467,6 @@ impl<'a> CIInfoParser<'a> { } fn parse_circleci(&mut self) { - // CircleCI provides CIRCLE_BUILD_URL directly self.ci_info.job_url = self.get_env_var("CIRCLE_BUILD_URL"); self.ci_info.branch = self.get_env_var("CIRCLE_BRANCH"); self.ci_info.pr_number = Self::parse_pr_number(self.get_env_var("CIRCLE_PR_NUMBER")); @@ -475,10 +474,6 @@ impl<'a> CIInfoParser<'a> { self.ci_info.workflow = self.get_env_var("CIRCLE_WORKFLOW_ID"); self.ci_info.job = self.get_env_var("CIRCLE_JOB"); - - // Note: CircleCI doesn't provide author/committer info or commit message - // 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 { diff --git a/context/tests/env.rs b/context/tests/env.rs index 4a7d3b02..ad93679d 100644 --- a/context/tests/env.rs +++ b/context/tests/env.rs @@ -1260,8 +1260,6 @@ fn test_bitbucket_missing_job_url_vars() { ); } -// ==================== CircleCI Tests ==================== - #[test] fn test_simple_circleci() { let branch = String::from("circleci-project-setup"); From 20ffcd5d21ccfeec609972372ec7a200fe106e15 Mon Sep 17 00:00:00 2001 From: Christian Millar Date: Wed, 14 Jan 2026 22:43:36 +0000 Subject: [PATCH 6/6] [TRUNK-17370] Add endpoint information to unknown failure reason Wraps the unknown failure reason in an enum error, which we can extract in order to get an endpoint when we see a failure. --- api/src/client.rs | 133 +++++++++++++++++++++++++------------- cli/src/upload_command.rs | 8 ++- 2 files changed, 95 insertions(+), 46 deletions(-) diff --git a/api/src/client.rs b/api/src/client.rs index 879b7871..a09b7692 100644 --- a/api/src/client.rs +++ b/api/src/client.rs @@ -1,3 +1,4 @@ +use std::fmt; use std::path::Path; use std::sync::mpsc::Sender; @@ -11,6 +12,38 @@ use tokio::fs; use crate::call_api::CallApi; use crate::message; +#[derive(Debug)] +pub enum ApiErrorEndpoint { + CreateBundleUpload, + GetQuarantiningConfig, + PutBundleToS3, + TelemetryUploadMetrics, +} + +impl ApiErrorEndpoint { + pub fn unknown_error_reason(&self) -> String { + match self { + Self::CreateBundleUpload => "unknown_create_bundle_upload".into(), + Self::GetQuarantiningConfig => "unknown_get_quarantining_config".into(), + Self::PutBundleToS3 => "unknown_put_bundle_to_s3".into(), + Self::TelemetryUploadMetrics => "unknown_telemetry_upload_metrics".into(), + } + } +} + +impl fmt::Display for ApiErrorEndpoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CreateBundleUpload => write!(f, "Error in create bundle upload endpoint"), + Self::GetQuarantiningConfig => write!(f, "Error in get quarantine config endpoint"), + Self::PutBundleToS3 => write!(f, "Error in put bundle to s3 endpoint"), + Self::TelemetryUploadMetrics => write!(f, "Error in telemetry upload metrics endpoint"), + } + } +} + +impl std::error::Error for ApiErrorEndpoint {} + pub struct ApiClient { pub api_host: String, pub telemetry_host: String, @@ -150,6 +183,7 @@ impl ApiClient { } .call_api() .await + .map_err(|e| e.context(ApiErrorEndpoint::CreateBundleUpload)) } pub async fn get_quarantining_config( @@ -192,6 +226,7 @@ impl ApiClient { } .call_api() .await + .map_err(|e| e.context(ApiErrorEndpoint::GetQuarantiningConfig)) } pub async fn put_bundle_to_s3, B: AsRef>( @@ -231,6 +266,7 @@ impl ApiClient { } .call_api() .await + .map_err(|e| e.context(ApiErrorEndpoint::PutBundleToS3)) } pub async fn telemetry_upload_metrics( @@ -285,6 +321,7 @@ impl ApiClient { } .call_api() .await + .map_err(|e| e.context(ApiErrorEndpoint::TelemetryUploadMetrics)) } async fn deserialize_response( @@ -453,21 +490,23 @@ mod tests { api_client.api_host.clone_from(&state.host); assert!( - api_client - .get_quarantining_config(&message::GetQuarantineConfigRequest { - repo: context::repo::RepoUrlParts { - host: String::from("host"), - owner: String::from("owner"), - name: String::from("name"), - }, - org_url_slug: String::from("org_url_slug"), - test_identifiers: vec![], - remote_urls: vec![], - }) - .await - .unwrap_err() - .to_string() - .contains("501 Not Implemented") + format!( + "{:?}", + api_client + .get_quarantining_config(&message::GetQuarantineConfigRequest { + repo: context::repo::RepoUrlParts { + host: String::from("host"), + owner: String::from("owner"), + name: String::from("name"), + }, + org_url_slug: String::from("org_url_slug"), + test_identifiers: vec![], + remote_urls: vec![], + }) + .await + .unwrap_err() + ) + .contains("501 Not Implemented") ); assert_eq!(CALL_COUNT.load(Ordering::Relaxed), 1); } @@ -499,21 +538,23 @@ mod tests { api_client.api_host.clone_from(&state.host); assert!( - api_client - .get_quarantining_config(&message::GetQuarantineConfigRequest { - repo: context::repo::RepoUrlParts { - host: String::from("api_host"), - owner: String::from("owner"), - name: String::from("name"), - }, - remote_urls: vec![], - org_url_slug: String::from("org_url_slug"), - test_identifiers: vec![], - }) - .await - .unwrap_err() - .to_string() - .contains("500 Internal Server Error") + format!( + "{:?}", + api_client + .get_quarantining_config(&message::GetQuarantineConfigRequest { + repo: context::repo::RepoUrlParts { + host: String::from("api_host"), + owner: String::from("owner"), + name: String::from("name"), + }, + remote_urls: vec![], + org_url_slug: String::from("org_url_slug"), + test_identifiers: vec![], + }) + .await + .unwrap_err() + ) + .contains("500 Internal Server Error") ); assert_eq!(CALL_COUNT.load(Ordering::Relaxed), 6); } @@ -540,21 +581,23 @@ mod tests { api_client.api_host.clone_from(&state.host); assert!( - api_client - .get_quarantining_config(&message::GetQuarantineConfigRequest { - repo: context::repo::RepoUrlParts { - host: String::from("api_host"), - owner: String::from("owner"), - name: String::from("name"), - }, - remote_urls: vec![], - org_url_slug: String::from("org_url_slug"), - test_identifiers: vec![], - }) - .await - .unwrap_err() - .to_string() - .contains("404 Not Found") + format!( + "{:?}", + api_client + .get_quarantining_config(&message::GetQuarantineConfigRequest { + repo: context::repo::RepoUrlParts { + host: String::from("api_host"), + owner: String::from("owner"), + name: String::from("name"), + }, + remote_urls: vec![], + org_url_slug: String::from("org_url_slug"), + test_identifiers: vec![], + }) + .await + .unwrap_err() + ) + .contains("404 Not Found") ); } } diff --git a/cli/src/upload_command.rs b/cli/src/upload_command.rs index e4bdeefc..b1f0e563 100644 --- a/cli/src/upload_command.rs +++ b/cli/src/upload_command.rs @@ -3,7 +3,7 @@ use std::env; use std::path::PathBuf; use std::sync::mpsc::Sender; -use api::client::ApiClient; +use api::client::{ApiClient, ApiErrorEndpoint}; use api::{client::get_api_host, urls::url_for_test_case}; use bundle::{BundleMeta, BundlerUtil, Test, unzip_tarball}; use clap::{ArgAction, Args}; @@ -296,6 +296,12 @@ fn error_reason(error: &anyhow::Error) -> String { return status.to_string().replace(' ', "_").to_lowercase(); } } + + for cause in error.chain() { + if let Some(endpoint_context) = cause.downcast_ref::() { + return endpoint_context.unknown_error_reason(); + } + } "unknown".into() }