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() } diff --git a/context/src/env/parser.rs b/context/src/env/parser.rs index 2e4e213c..aa0e693c 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,16 @@ impl<'a> CIInfoParser<'a> { // via apply_repo_overrides(), or users can set them via CUSTOM env vars. } + fn parse_circleci(&mut self) { + 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"); + } + 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..ad93679d 100644 --- a/context/tests/env.rs +++ b/context/tests/env.rs @@ -1260,6 +1260,193 @@ fn test_bitbucket_missing_job_url_vars() { ); } +#[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;