diff --git a/src/api_client.rs b/src/api_client.rs index 69b1d892..80ce8f8c 100644 --- a/src/api_client.rs +++ b/src/api_client.rs @@ -81,39 +81,12 @@ nest! { #[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] -pub struct FetchLocalRunReportVars { +pub struct FetchLocalRunVars { pub owner: String, pub name: String, pub run_id: String, } -#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] -pub enum ReportConclusion { - AcknowledgedFailure, - Failure, - MissingBaseRun, - NoBenchmarks, - Success, -} - -impl Display for ReportConclusion { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ReportConclusion::AcknowledgedFailure => { - write!(f, "{}", style("Acknowledged Failure").yellow().bold()) - } - ReportConclusion::Failure => write!(f, "{}", style("Failure").red().bold()), - ReportConclusion::MissingBaseRun => { - write!(f, "{}", style("Missing Base Run").yellow().bold()) - } - ReportConclusion::NoBenchmarks => { - write!(f, "{}", style("No Benchmarks").yellow().bold()) - } - ReportConclusion::Success => write!(f, "{}", style("Success").green().bold()), - } - } -} - #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum RunStatus { @@ -123,18 +96,23 @@ pub enum RunStatus { Processing, } +// Custom deserializer to convert string values to i64 +fn deserialize_i64_from_string<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de; + let s = String::deserialize(deserializer)?; + s.parse().map_err(de::Error::custom) +} + nest! { #[derive(Debug, Deserialize, Serialize)]* #[serde(rename_all = "camelCase")]* - pub struct FetchLocalRunReportRun { + pub struct FetchLocalRunRun { pub id: String, pub status: RunStatus, pub url: String, - pub head_reports: Vec, - pub conclusion: ReportConclusion, - }>, pub results: Vec(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - use serde::de; - let s = String::deserialize(deserializer)?; - s.parse().map_err(de::Error::custom) +nest! { + #[derive(Debug, Deserialize, Serialize)]* + #[serde(rename_all = "camelCase")]* + struct FetchLocalRunData { + repository: struct FetchLocalRunRepository { + run: FetchLocalRunRun, + } + } +} + +pub struct FetchLocalRunResponse { + pub run: FetchLocalRunRun, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CompareRunsVars { + pub owner: String, + pub name: String, + pub base_run_id: String, + pub head_run_id: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "PascalCase")] +pub enum ResultComparisonCategory { + Acknowledged, + Archived, + Ignored, + Improvement, + New, + Regression, + Skipped, + Untouched, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub enum BenchmarkReportStatus { + Improvement, + Missing, + New, + NoChange, + Regression, +} + +impl Display for BenchmarkReportStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BenchmarkReportStatus::Improvement => { + write!(f, "{}", style("Improvement").green().bold()) + } + BenchmarkReportStatus::Missing => write!(f, "{}", style("Missing").yellow().bold()), + BenchmarkReportStatus::New => write!(f, "{}", style("New").cyan().bold()), + BenchmarkReportStatus::NoChange => write!(f, "{}", style("No Change").dim()), + BenchmarkReportStatus::Regression => write!(f, "{}", style("Regression").red().bold()), + } + } } nest! { #[derive(Debug, Deserialize, Serialize)]* #[serde(rename_all = "camelCase")]* - struct FetchLocalRunReportData { - repository: pub struct FetchLocalRunReportRepository { - settings: struct FetchLocalRunReportSettings { - allowed_regression: f64, - }, - run: FetchLocalRunReportRun, - } + pub struct CompareRunsBenchmarkResult { + pub value: Option, + pub base_value: Option, + pub change: Option, + pub category: ResultComparisonCategory, + pub status: BenchmarkReportStatus, + pub benchmark: pub struct CompareRunsBenchmark { + pub name: String, + pub executor: ExecutorName, + }, + } +} + +nest! { + #[derive(Debug, Deserialize, Serialize)]* + #[serde(rename_all = "camelCase")]* + pub struct CompareRunsHeadRun { + pub id: String, + pub status: RunStatus, } } nest! { #[derive(Debug, Deserialize, Serialize)]* #[serde(rename_all = "camelCase")]* - struct FetchLocalExecReportData { - project: pub struct FetchLocalExecReportProject { - run: FetchLocalRunReportRun, + struct CompareRunsData { + repository: struct CompareRunsRepository { + paginated_compare_runs: pub struct CompareRunsComparison { + pub impact: Option, + pub url: String, + pub head_run: CompareRunsHeadRun, + pub result_comparisons: Vec, + }, } } } -pub struct FetchLocalRunReportResponse { - pub allowed_regression: f64, - pub run: FetchLocalRunReportRun, +pub struct CompareRunsResponse { + pub comparison: CompareRunsComparison, +} + +pub enum CompareRunsOutcome { + Success(CompareRunsResponse), + BaseRunNotFound, + ExecutorMismatch, } #[derive(Serialize, Clone)] @@ -274,26 +323,47 @@ impl CodSpeedAPIClient { } } - pub async fn fetch_local_run_report( - &self, - vars: FetchLocalRunReportVars, - ) -> Result { + pub async fn compare_runs(&self, vars: CompareRunsVars) -> Result { let response = self .gql_client - .query_with_vars_unwrap::( - include_str!("queries/FetchLocalRunReport.gql"), - vars.clone(), + .query_with_vars_unwrap::( + include_str!("queries/CompareRuns.gql"), + vars, + ) + .await; + match response { + Ok(response) => Ok(CompareRunsOutcome::Success(CompareRunsResponse { + comparison: response.repository.paginated_compare_runs, + })), + Err(err) if err.contains_error_code("UNAUTHENTICATED") => { + bail!("Your session has expired, please login again using `codspeed auth login`") + } + Err(err) if err.contains_error_code("RUN_NOT_FOUND") => { + Ok(CompareRunsOutcome::BaseRunNotFound) + } + Err(err) if err.contains_error_code("NOT_FOUND") => { + Ok(CompareRunsOutcome::ExecutorMismatch) + } + Err(err) => bail!("Failed to compare runs: {err:?}"), + } + } + + pub async fn fetch_local_run(&self, vars: FetchLocalRunVars) -> Result { + let response = self + .gql_client + .query_with_vars_unwrap::( + include_str!("queries/FetchLocalRun.gql"), + vars, ) .await; match response { - Ok(response) => Ok(FetchLocalRunReportResponse { - allowed_regression: response.repository.settings.allowed_regression, + Ok(response) => Ok(FetchLocalRunResponse { run: response.repository.run, }), Err(err) if err.contains_error_code("UNAUTHENTICATED") => { bail!("Your session has expired, please login again using `codspeed auth login`") } - Err(err) => bail!("Failed to fetch local run report: {err}"), + Err(err) => bail!("Failed to fetch local run: {err}"), } } diff --git a/src/cli/exec/mod.rs b/src/cli/exec/mod.rs index 3d6a77ed..765f78ec 100644 --- a/src/cli/exec/mod.rs +++ b/src/cli/exec/mod.rs @@ -55,6 +55,7 @@ impl ExecArgs { fn build_orchestrator_config( args: ExecArgs, target: executor::BenchmarkTarget, + poll_results_options: PollResultsOptions, ) -> Result { let modes = args.shared.resolve_modes()?; let raw_upload_url = args @@ -86,7 +87,7 @@ fn build_orchestrator_config( allow_empty: args.shared.allow_empty, go_runner_version: args.shared.go_runner_version, show_full_output: args.shared.show_full_output, - poll_results_options: PollResultsOptions::for_exec(), + poll_results_options, extra_env: HashMap::new(), }) } @@ -99,12 +100,17 @@ pub async fn run( setup_cache_dir: Option<&Path>, ) -> Result<()> { let merged_args = args.merge_with_project_config(project_config); + let base_run_id = merged_args.shared.base.clone(); let target = executor::BenchmarkTarget::Exec { command: merged_args.command.clone(), name: merged_args.name.clone(), walltime_args: merged_args.walltime_args.clone(), }; - let config = build_orchestrator_config(merged_args, target)?; + let config = build_orchestrator_config( + merged_args, + target, + PollResultsOptions::new(false, base_run_id), + )?; execute_config(config, api_client, codspeed_config, setup_cache_dir).await } diff --git a/src/cli/run/helpers/mod.rs b/src/cli/run/helpers/mod.rs index f4c4097e..98af84a2 100644 --- a/src/cli/run/helpers/mod.rs +++ b/src/cli/run/helpers/mod.rs @@ -1,4 +1,3 @@ -pub(crate) mod benchmark_display; mod download_file; mod find_repository_root; mod format_duration; diff --git a/src/cli/run/mod.rs b/src/cli/run/mod.rs index 045c53f0..2036b34e 100644 --- a/src/cli/run/mod.rs +++ b/src/cli/run/mod.rs @@ -67,6 +67,7 @@ impl RunArgs { allow_empty: false, go_runner_version: None, show_full_output: false, + base: None, perf_run_args: PerfRunArgs { enable_perf: false, perf_unwinding_mode: None, @@ -143,6 +144,7 @@ pub async fn run( ) -> Result<()> { let output_json = args.message_format == Some(MessageFormat::Json); let project_config = discovered_config.map(|d| &d.config); + let base_run_id = args.shared.base.clone(); let run_target = if args.command.is_empty() { // No command provided - check for targets in project config @@ -171,13 +173,14 @@ pub async fn run( // SingleCommand: working_directory comes from --working-directory CLI flag only. // Config file's working-directory is NOT used. let command = args.command.join(" "); + let poll_opts = PollResultsOptions::new(output_json, base_run_id); let config = build_orchestrator_config( args, vec![executor::BenchmarkTarget::Entrypoint { command, name: None, }], - PollResultsOptions::for_run(output_json), + poll_opts, )?; let orchestrator = @@ -230,10 +233,12 @@ pub async fn run( let benchmark_targets = super::exec::multi_targets::build_benchmark_targets(targets, default_walltime)?; - let mut config = - build_orchestrator_config(args, benchmark_targets, PollResultsOptions::for_exec())?; + let mut config = build_orchestrator_config( + args, + benchmark_targets, + PollResultsOptions::new(false, base_run_id), + )?; config.working_directory = resolved_working_directory; - super::exec::execute_config(config, api_client, codspeed_config, setup_cache_dir) .await?; } diff --git a/src/cli/shared.rs b/src/cli/shared.rs index 15b95423..01793cd9 100644 --- a/src/cli/shared.rs +++ b/src/cli/shared.rs @@ -109,6 +109,10 @@ pub struct ExecAndRunSharedArgs { #[arg(long, default_value = "false")] pub show_full_output: bool, + /// Compare the results against this base run ID + #[arg(long)] + pub base: Option, + #[command(flatten)] pub perf_run_args: PerfRunArgs, } diff --git a/src/executor/config.rs b/src/executor/config.rs index e13c3aa9..390673a9 100644 --- a/src/executor/config.rs +++ b/src/executor/config.rs @@ -221,7 +221,7 @@ impl OrchestratorConfig { allow_empty: false, go_runner_version: None, show_full_output: false, - poll_results_options: PollResultsOptions::for_exec(), + poll_results_options: PollResultsOptions::new(false, None), extra_env: HashMap::new(), } } diff --git a/src/main.rs b/src/main.rs index 518b8d3e..35cef6f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,26 @@ use codspeed_runner::{clean_logger, cli}; -use console::style; +use console::{Term, style}; use log::log_enabled; +struct HiddenCursor(Term); + +impl HiddenCursor { + fn new() -> Self { + let term = Term::stderr(); + let _ = term.hide_cursor(); + Self(term) + } +} + +impl Drop for HiddenCursor { + fn drop(&mut self) { + let _ = self.0.show_cursor(); + } +} + #[tokio::main(flavor = "current_thread")] async fn main() { + let _cursor = HiddenCursor::new(); let res = cli::run().await; if let Err(err) = res { // Show the primary error diff --git a/src/queries/CompareRuns.gql b/src/queries/CompareRuns.gql new file mode 100644 index 00000000..9c9b0743 --- /dev/null +++ b/src/queries/CompareRuns.gql @@ -0,0 +1,23 @@ +query CompareRuns($owner: String!, $name: String!, $baseRunId: String!, $headRunId: String!) { + repository(owner: $owner, name: $name) { + paginatedCompareRuns(baseRunId: $baseRunId, headRunId: $headRunId) { + impact + url + headRun { + id + status + } + resultComparisons { + benchmark { + name + executor + } + value + baseValue + change + category + status + } + } + } +} diff --git a/src/queries/FetchLocalRunReport.gql b/src/queries/FetchLocalRun.gql similarity index 72% rename from src/queries/FetchLocalRunReport.gql rename to src/queries/FetchLocalRun.gql index 17f56a5b..4c211062 100644 --- a/src/queries/FetchLocalRunReport.gql +++ b/src/queries/FetchLocalRun.gql @@ -1,17 +1,9 @@ -query FetchLocalRunReport($owner: String!, $name: String!, $runId: String!) { +query FetchLocalRun($owner: String!, $name: String!, $runId: String!) { repository(owner: $owner, name: $name) { - settings { - allowedRegression - } run(id: $runId) { id status url - headReports { - id - impact - conclusion - } results { benchmark { name diff --git a/src/cli/run/helpers/benchmark_display.rs b/src/upload/benchmark_display.rs similarity index 82% rename from src/cli/run/helpers/benchmark_display.rs rename to src/upload/benchmark_display.rs index d9764db8..36533ebe 100644 --- a/src/cli/run/helpers/benchmark_display.rs +++ b/src/upload/benchmark_display.rs @@ -1,4 +1,6 @@ -use crate::api_client::FetchLocalRunBenchmarkResult; +use crate::api_client::{ + CompareRunsBenchmarkResult, FetchLocalRunBenchmarkResult, ResultComparisonCategory, +}; use crate::cli::run::helpers; use crate::executor::ExecutorName; use console::style; @@ -9,6 +11,9 @@ use tabled::settings::style::HorizontalLine; use tabled::settings::{Alignment, Color, Modify, Padding, Style}; use tabled::{Table, Tabled}; +/// Changes below this threshold are displayed as "~0%" to avoid noise. +pub(super) const CHANGE_DISPLAY_EPSILON: f64 = 0.005; + fn format_with_thousands_sep(n: u64) -> String { let s = n.to_string(); let mut result = String::new(); @@ -317,6 +322,102 @@ pub fn build_detailed_summary(result: &FetchLocalRunBenchmarkResult) -> String { } } +#[derive(Tabled)] +struct ComparisonRow { + #[tabled(rename = "Benchmark")] + name: String, + #[tabled(rename = "Base")] + base_value: String, + #[tabled(rename = "Head")] + head_value: String, + #[tabled(rename = "Change")] + change: String, + #[tabled(rename = "Status")] + status: String, +} + +pub fn build_comparison_table(results: &[CompareRunsBenchmarkResult]) -> String { + let mut grouped: HashMap<&ExecutorName, Vec<&CompareRunsBenchmarkResult>> = HashMap::new(); + for result in results { + grouped + .entry(&result.benchmark.executor) + .or_default() + .push(result); + } + + let executor_order = [ + ExecutorName::Valgrind, + ExecutorName::WallTime, + ExecutorName::Memory, + ]; + + let mut output = String::new(); + for executor in &executor_order { + if let Some(executor_results) = grouped.get(executor) { + if !output.is_empty() { + output.push('\n'); + } + let rows: Vec = executor_results + .iter() + .map(|result| { + let format_value = |v: Option| match v { + Some(v) => match executor { + ExecutorName::Memory => helpers::format_memory(v, Some(1)), + _ => helpers::format_duration(v, Some(2)), + }, + None => "-".to_string(), + }; + + let change_str = match result.change { + Some(c) if c.abs() < CHANGE_DISPLAY_EPSILON => { + format!("{}", style(format!("{:.1}%", c * 100.0)).dim()) + } + Some(c) if c > 0.0 => { + format!("{}", style(format!("+{:.1}%", c * 100.0)).green().bold()) + } + Some(c) => { + format!("{}", style(format!("{:.1}%", c * 100.0)).red().bold()) + } + None => "-".to_string(), + }; + + let status_str = match &result.category { + ResultComparisonCategory::New => { + format!("{}", style("New").cyan().bold()) + } + ResultComparisonCategory::Improvement => { + format!("{}", style("Improvement").green().bold()) + } + ResultComparisonCategory::Regression => { + format!("{}", style("Regression").red().bold()) + } + ResultComparisonCategory::Untouched => { + format!("{}", style("No Change").dim()) + } + _ => format!("{}", &result.status), + }; + + ComparisonRow { + name: result.benchmark.name.clone(), + base_value: format_value(result.base_value), + head_value: format!("{}", style(format_value(result.value)).cyan()), + change: change_str, + status: status_str, + } + }) + .collect(); + + output.push_str(&build_table_with_style( + &rows, + executor.label(), + executor.icon(), + )); + } + } + + output +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/upload/mod.rs b/src/upload/mod.rs index 9ba5d14d..cb4e855f 100644 --- a/src/upload/mod.rs +++ b/src/upload/mod.rs @@ -1,13 +1,12 @@ +mod benchmark_display; mod interfaces; pub mod poll_results; -mod polling; mod profile_archive; mod run_index_state; mod upload_metadata; mod uploader; pub use interfaces::*; -pub use polling::poll_run_report; pub use profile_archive::ProfileArchive; pub use run_index_state::RunIndexState; pub use uploader::{UploadResult, upload}; diff --git a/src/upload/poll_results.rs b/src/upload/poll_results.rs index 2683e637..1d5c8662 100644 --- a/src/upload/poll_results.rs +++ b/src/upload/poll_results.rs @@ -1,39 +1,38 @@ +use std::future::Future; +use std::time::Duration; + use console::style; +use tokio::time::{Instant, sleep}; -use crate::api_client::CodSpeedAPIClient; -use crate::cli::run::helpers::benchmark_display::{build_benchmark_table, build_detailed_summary}; +use super::benchmark_display::{ + self, build_benchmark_table, build_comparison_table, build_detailed_summary, +}; +use crate::api_client::{ + CodSpeedAPIClient, CompareRunsOutcome, CompareRunsResponse, CompareRunsVars, + FetchLocalRunResponse, FetchLocalRunVars, RunStatus, +}; use crate::local_logger::{start_spinner, stop_spinner}; use crate::prelude::*; -use super::{UploadResult, poll_run_report}; +use super::UploadResult; + +const RUN_PROCESSING_MAX_DURATION: Duration = Duration::from_secs(60 * 5); // 5 minutes +const POLLING_INTERVAL: Duration = Duration::from_secs(1); /// Options controlling poll_results display behavior. #[derive(Debug, Clone)] pub struct PollResultsOptions { - /// If true, show impact percentage (used by `codspeed run`) - pub show_impact: bool, /// If true, output JSON events (used by `codspeed run --message-format json`) pub output_json: bool, - /// If true, show detailed summary for single benchmark result (used by `codspeed exec`) - pub detailed_single: bool, + /// If set, compare the uploaded run against this base run ID + pub base_run_id: Option, } impl PollResultsOptions { - /// Options for `codspeed run` - pub fn for_run(output_json: bool) -> Self { + pub fn new(output_json: bool, base_run_id: Option) -> Self { Self { - show_impact: true, output_json, - detailed_single: false, - } - } - - /// Options for `codspeed exec` - pub fn for_exec() -> Self { - Self { - show_impact: false, - output_json: false, - detailed_single: true, + base_run_id, } } } @@ -43,45 +42,123 @@ pub async fn poll_results( upload_result: &UploadResult, options: &PollResultsOptions, ) -> Result<()> { - start_spinner("Waiting for results"); - let response = poll_run_report(api_client, upload_result).await; - stop_spinner(); - let response = response?; - - if options.show_impact { - let report = response.run.head_reports.into_iter().next(); - if let Some(report) = report { - if let Some(impact) = report.impact { - let rounded_impact = (impact * 100.0).round(); - let (arrow, impact_text) = if impact > 0.0 { - ( - style("\u{f062}").green(), - style(format!("+{rounded_impact}%")).green().bold(), - ) - } else if impact < 0.0 { - ( - style("\u{f063}").red(), - style(format!("{rounded_impact}%")).red().bold(), - ) - } else { - ( - style("\u{25CF}").dim(), - style(format!("{rounded_impact}%")).dim().bold(), - ) - }; - - let allowed = (response.allowed_regression * 100.0).round(); - info!("{arrow} Impact: {impact_text} (allowed regression: -{allowed}%)"); - } else { - info!( - "{} No impact detected, reason: {}", - style("\u{25CB}").dim(), - report.conclusion + if let Some(base_run_id) = &options.base_run_id { + start_spinner("Waiting for results"); + let compare_result = poll_compare_runs(api_client, upload_result, base_run_id).await; + stop_spinner(); + + match compare_result? { + CompareRunsOutcome::Success(response) => { + return display_comparison_results(upload_result, options, response).await; + } + // Fall back to single run display when comparison is not possible + CompareRunsOutcome::BaseRunNotFound => { + warn!( + "Base run ID \"{base_run_id}\" was not found, we cannot compare results against it." + ); + } + CompareRunsOutcome::ExecutorMismatch => { + warn!( + "Base run ID \"{base_run_id}\" uses a different executor, we cannot compare results against it." ); } } } + start_spinner("Waiting for results"); + let response = poll_local_run(api_client, upload_result).await; + stop_spinner(); + + display_single_run_results(upload_result, options, response?).await +} + +/// Poll using `fetch` until `get_status` returns neither Pending nor Processing, then return +/// the response or an error if the status is Failure or polling times out. +/// +/// If `fetch` returns `Ok(None)`, polling stops immediately and `Ok(None)` is returned. +async fn poll_until_processed( + fetch: impl Fn() -> Fut, + get_status: impl Fn(&T) -> &RunStatus, +) -> Result> +where + Fut: Future>>, +{ + let start = Instant::now(); + debug!("Waiting for results to be processed..."); + + loop { + if start.elapsed() > RUN_PROCESSING_MAX_DURATION { + bail!("Polling results timed out after 5 minutes. Please try again later."); + } + + let Some(response) = fetch().await? else { + return Ok(None); + }; + match get_status(&response) { + RunStatus::Pending | RunStatus::Processing => sleep(POLLING_INTERVAL).await, + RunStatus::Failure => bail!("Run failed to be processed, try again in a few minutes"), + _ => return Ok(Some(response)), + } + } +} + +async fn poll_local_run( + api_client: &CodSpeedAPIClient, + upload_result: &UploadResult, +) -> Result { + let vars = FetchLocalRunVars { + owner: upload_result.owner.clone(), + name: upload_result.repository.clone(), + run_id: upload_result.run_id.clone(), + }; + // fetch_local_run always returns Some — wrap to satisfy the shared signature + poll_until_processed( + || async { api_client.fetch_local_run(vars.clone()).await.map(Some) }, + |r: &FetchLocalRunResponse| &r.run.status, + ) + .await? + .ok_or_else(|| anyhow::anyhow!("unexpected None response from fetch_local_run")) +} + +async fn poll_compare_runs( + api_client: &CodSpeedAPIClient, + upload_result: &UploadResult, + base_run_id: &str, +) -> Result { + let vars = CompareRunsVars { + owner: upload_result.owner.clone(), + name: upload_result.repository.clone(), + base_run_id: base_run_id.to_string(), + head_run_id: upload_result.run_id.clone(), + }; + + let start = Instant::now(); + debug!("Waiting for results to be processed..."); + + loop { + if start.elapsed() > RUN_PROCESSING_MAX_DURATION { + bail!("Polling results timed out after 5 minutes. Please try again later."); + } + + match api_client.compare_runs(vars.clone()).await? { + outcome @ (CompareRunsOutcome::BaseRunNotFound + | CompareRunsOutcome::ExecutorMismatch) => return Ok(outcome), + CompareRunsOutcome::Success(response) => match &response.comparison.head_run.status { + RunStatus::Pending | RunStatus::Processing => sleep(POLLING_INTERVAL).await, + RunStatus::Failure => { + bail!("Run failed to be processed, try again in a few minutes") + } + _ => return Ok(CompareRunsOutcome::Success(response)), + }, + } + } +} + +async fn display_single_run_results( + upload_result: &UploadResult, + options: &PollResultsOptions, + response: FetchLocalRunResponse, +) -> Result<()> { if options.output_json { log_json!(format!( "{{\"event\": \"run_finished\", \"run_id\": \"{}\"}}", @@ -97,7 +174,7 @@ pub async fn poll_results( end_group!(); start_opened_group!("Benchmark results"); - if options.detailed_single && response.run.results.len() == 1 { + if response.run.results.len() == 1 { let summary = build_detailed_summary(&response.run.results[0]); info!("{summary}\n"); } else { @@ -114,11 +191,89 @@ pub async fn poll_results( } } + let run_id = &upload_result.run_id; info!( "\n{} {}", style("View full report:").dim(), - style(response.run.url).blue().bold().underlined() + style(&response.run.url).blue().bold().underlined(), + ); + show_comparison_suggestion(run_id); + } + + Ok(()) +} + +fn show_comparison_suggestion(run_id: &str) { + info!( + "\n{} {}", + style("To compare future runs against this one, use:").dim(), + style(format!("--base {run_id}")).cyan(), + ); +} + +async fn display_comparison_results( + upload_result: &UploadResult, + options: &PollResultsOptions, + response: CompareRunsResponse, +) -> Result<()> { + let comparison = &response.comparison; + + if options.output_json { + log_json!(format!( + "{{\"event\": \"run_finished\", \"run_id\": \"{}\"}}", + upload_result.run_id + )); + } + + if comparison.result_comparisons.is_empty() { + warn!( + "No benchmarks were found in the run. Make sure your command runs benchmarks that are instrumented with a CodSpeed integration." + ); + } else { + end_group!(); + start_opened_group!("Benchmark results"); + + if let Some(impact) = comparison.impact { + let pct = impact * 100.0; + let (arrow, impact_text) = if impact.abs() < benchmark_display::CHANGE_DISPLAY_EPSILON { + ( + style("\u{25CF}").dim(), + style(format!("{pct:.1}%")).dim().bold(), + ) + } else if impact > 0.0 { + ( + style("\u{f062}").green(), + style(format!("+{pct:.1}%")).green().bold(), + ) + } else { + ( + style("\u{f063}").red(), + style(format!("{pct:.1}%")).red().bold(), + ) + }; + info!("{arrow} Impact: {impact_text}"); + } + + let table = build_comparison_table(&comparison.result_comparisons); + info!("{table}\n"); + + if options.output_json { + for result in &comparison.result_comparisons { + if let Some(value) = result.value { + log_json!(format!( + "{{\"event\": \"benchmark_ran\", \"name\": \"{}\", \"time\": \"{value}\"}}", + result.benchmark.name + )); + } + } + } + + info!( + "\n{} {}", + style("View comparison report:").dim(), + style(&comparison.url).blue().bold().underlined() ); + show_comparison_suggestion(&upload_result.run_id); } Ok(()) diff --git a/src/upload/polling.rs b/src/upload/polling.rs deleted file mode 100644 index 29be3fbc..00000000 --- a/src/upload/polling.rs +++ /dev/null @@ -1,58 +0,0 @@ -use std::time::Duration; -use tokio::time::{Instant, sleep}; - -use crate::api_client::{ - CodSpeedAPIClient, FetchLocalRunReportResponse, FetchLocalRunReportVars, RunStatus, -}; -use crate::prelude::*; - -use super::UploadResult; - -pub const RUN_PROCESSING_MAX_DURATION: Duration = Duration::from_secs(60 * 5); // 5 minutes -pub const POLLING_INTERVAL: Duration = Duration::from_secs(1); - -/// Poll the API until the run is processed and return the response. -/// -/// Returns an error if polling times out or the run fails processing. -pub async fn poll_run_report( - api_client: &CodSpeedAPIClient, - upload_result: &UploadResult, -) -> Result { - let start = Instant::now(); - let fetch_local_run_report_vars = FetchLocalRunReportVars { - owner: upload_result.owner.clone(), - name: upload_result.repository.clone(), - run_id: upload_result.run_id.clone(), - }; - - debug!("Waiting for results to be processed..."); - - let response; - loop { - if start.elapsed() > RUN_PROCESSING_MAX_DURATION { - bail!("Polling results timed out after 5 minutes. Please try again later."); - } - - let fetch_result = api_client - .fetch_local_run_report(fetch_local_run_report_vars.clone()) - .await?; - - match fetch_result { - FetchLocalRunReportResponse { run, .. } - if run.status == RunStatus::Pending || run.status == RunStatus::Processing => - { - sleep(POLLING_INTERVAL).await; - } - response_from_api => { - response = response_from_api; - break; - } - } - } - - if response.run.status == RunStatus::Failure { - bail!("Run failed to be processed, try again in a few minutes"); - } - - Ok(response) -} diff --git a/src/cli/run/helpers/snapshots/codspeed_runner__cli__run__helpers__benchmark_display__tests__benchmark_table_formatting.snap b/src/upload/snapshots/codspeed_runner__upload__benchmark_display__tests__benchmark_table_formatting.snap similarity index 100% rename from src/cli/run/helpers/snapshots/codspeed_runner__cli__run__helpers__benchmark_display__tests__benchmark_table_formatting.snap rename to src/upload/snapshots/codspeed_runner__upload__benchmark_display__tests__benchmark_table_formatting.snap