diff --git a/src/logger.rs b/src/logger.rs index 081b265d..8cf9035c 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -3,6 +3,15 @@ pub const GROUP_TARGET: &str = "codspeed::group"; pub const OPENED_GROUP_TARGET: &str = "codspeed::group::opened"; pub const ANNOUNCEMENT_TARGET: &str = "codspeed::announcement"; +/// Default title used by provider loggers when an announcement is logged +/// without an explicit title. +pub const DEFAULT_ANNOUNCEMENT_TITLE: &str = "New CodSpeed Feature"; + +/// Internal delimiter (ASCII Unit Separator) used to encode an announcement +/// title alongside its message in a single log record. Reserved control +/// character that is not expected to appear in user-facing strings. +pub const ANNOUNCEMENT_DELIMITER: char = '\x1F'; + #[macro_export] /// Start a new log group. All logs between this and the next `end_group!` will be grouped together. /// @@ -51,9 +60,25 @@ macro_rules! end_group { #[macro_export] /// Logs at the announcement level. This is intended for important announcements like new features, /// that do not require immediate user action. +/// +/// Two forms are supported: +/// - `announcement!("message")`: logs a message with no explicit title; provider loggers fall +/// back to their default presentation (e.g. `"New CodSpeed Feature"` on GitHub Actions). +/// - `announcement!("title", "message")`: logs a message with a custom title; provider loggers +/// surface the title where supported (e.g. as the `title=` field of a GitHub Actions notice). macro_rules! announcement { - ($name:expr) => { - log::log!(target: $crate::logger::ANNOUNCEMENT_TARGET, log::Level::Info, "{}", $name); + ($message:expr) => { + log::log!(target: $crate::logger::ANNOUNCEMENT_TARGET, log::Level::Info, "{}", $message); + }; + ($title:expr, $message:expr) => { + log::log!( + target: $crate::logger::ANNOUNCEMENT_TARGET, + log::Level::Info, + "{}{}{}", + $title, + $crate::logger::ANNOUNCEMENT_DELIMITER, + $message + ); }; } @@ -86,12 +111,39 @@ pub(super) fn get_group_event(record: &log::Record) -> Option { } } -pub(super) fn get_announcement_event(record: &log::Record) -> Option { +/// A decoded announcement log record. +/// +/// Announcements are encoded into a single log record by [`announcement!`], optionally pairing +/// a `title` with the `message` via [`ANNOUNCEMENT_DELIMITER`]. Provider loggers consume this +/// to render announcements in their preferred format. +pub struct AnnouncementEvent { + pub title: Option, + pub message: String, +} + +/// Splits an announcement payload into its title and message parts using +/// [`ANNOUNCEMENT_DELIMITER`]. If no delimiter is present, the whole payload is treated as the +/// message and the title is `None`. +fn parse_announcement_args(raw: &str) -> AnnouncementEvent { + if let Some((title, message)) = raw.split_once(ANNOUNCEMENT_DELIMITER) { + AnnouncementEvent { + title: Some(title.to_string()), + message: message.to_string(), + } + } else { + AnnouncementEvent { + title: None, + message: raw.to_string(), + } + } +} + +pub(super) fn get_announcement_event(record: &log::Record) -> Option { if record.target() != ANNOUNCEMENT_TARGET { return None; } - Some(record.args().to_string()) + Some(parse_announcement_args(&record.args().to_string())) } #[macro_export] @@ -113,3 +165,52 @@ pub(super) fn get_json_event(record: &log::Record) -> Option { Some(JsonEvent(record.args().to_string())) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_announcement_without_title() { + let event = parse_announcement_args("hello"); + assert!(event.title.is_none()); + assert_eq!(event.message, "hello"); + } + + #[test] + fn parses_announcement_with_title() { + let raw = format!("OIDC Authentication{ANNOUNCEMENT_DELIMITER}Use OIDC instead of tokens."); + let event = parse_announcement_args(&raw); + assert_eq!(event.title.as_deref(), Some("OIDC Authentication")); + assert_eq!(event.message, "Use OIDC instead of tokens."); + } + + #[test] + fn parses_announcement_with_empty_title() { + let raw = format!("{ANNOUNCEMENT_DELIMITER}message-only"); + let event = parse_announcement_args(&raw); + assert_eq!(event.title.as_deref(), Some("")); + assert_eq!(event.message, "message-only"); + } + + #[test] + fn parses_announcement_preserving_multiline_message() { + let raw = format!("Title{ANNOUNCEMENT_DELIMITER}line1\nline2\nline3"); + let event = parse_announcement_args(&raw); + assert_eq!(event.title.as_deref(), Some("Title")); + assert_eq!(event.message, "line1\nline2\nline3"); + } + + #[test] + fn splits_at_first_delimiter_only() { + let raw = format!( + "Title{ANNOUNCEMENT_DELIMITER}message containing the {ANNOUNCEMENT_DELIMITER} char" + ); + let event = parse_announcement_args(&raw); + assert_eq!(event.title.as_deref(), Some("Title")); + assert_eq!( + event.message, + format!("message containing the {ANNOUNCEMENT_DELIMITER} char") + ); + } +} diff --git a/src/run_environment/buildkite/logger.rs b/src/run_environment/buildkite/logger.rs index ed744e1d..860cd950 100644 --- a/src/run_environment/buildkite/logger.rs +++ b/src/run_environment/buildkite/logger.rs @@ -6,6 +6,10 @@ use log::*; use simplelog::SharedLogger; use std::{env, io::Write}; +/// Title used for announcements when no explicit title is provided. Preserves the legacy +/// `[ANNOUNCEMENT]` prefix that this logger emitted before per-call titles were supported. +const DEFAULT_BUILDKITE_ANNOUNCEMENT_TITLE: &str = "ANNOUNCEMENT"; + /// A logger that prints logs in the format expected by Buildkite /// /// See https://buildkite.com/docs/pipelines/managing-log-output @@ -54,7 +58,11 @@ impl Log for BuildkiteLogger { } if let Some(announcement) = get_announcement_event(record) { - println!("[ANNOUNCEMENT] {announcement}"); + let title = announcement + .title + .as_deref() + .unwrap_or(DEFAULT_BUILDKITE_ANNOUNCEMENT_TITLE); + println!("[{title}] {}", announcement.message); return; } diff --git a/src/run_environment/github_actions/logger.rs b/src/run_environment/github_actions/logger.rs index 04a91e10..945118dd 100644 --- a/src/run_environment/github_actions/logger.rs +++ b/src/run_environment/github_actions/logger.rs @@ -1,5 +1,8 @@ use crate::{ - logger::{GroupEvent, get_announcement_event, get_group_event, get_json_event}, + logger::{ + DEFAULT_ANNOUNCEMENT_TITLE, GroupEvent, get_announcement_event, get_group_event, + get_json_event, + }, run_environment::logger::should_provider_logger_handle_record, }; use log::*; @@ -56,9 +59,13 @@ impl Log for GithubActionLogger { } if let Some(announcement) = get_announcement_event(record) { - let escaped_announcement = escape_multiline_message(&announcement); - // TODO: make the announcement title configurable - println!("::notice title=New CodSpeed Feature::{escaped_announcement}"); + let title = announcement + .title + .as_deref() + .unwrap_or(DEFAULT_ANNOUNCEMENT_TITLE); + let escaped_title = escape_multiline_message(title); + let escaped_message = escape_multiline_message(&announcement.message); + println!("::notice title={escaped_title}::{escaped_message}"); return; } diff --git a/src/run_environment/github_actions/provider.rs b/src/run_environment/github_actions/provider.rs index c8a024e7..70a08bff 100644 --- a/src/run_environment/github_actions/provider.rs +++ b/src/run_environment/github_actions/provider.rs @@ -292,6 +292,7 @@ impl RunEnvironmentProvider for GitHubActionsProvider { // Check if a static token is already set if config.token.is_some() { announcement!( + "OIDC Authentication", "You can now authenticate your CI workflows using OpenID Connect (OIDC) tokens instead of `CODSPEED_TOKEN` secrets.\n\ This makes integrating and authenticating jobs safer and simpler.\n\ Learn more at https://codspeed.io/docs/integrations/ci/github-actions/configuration#oidc-recommended\n" @@ -320,6 +321,7 @@ impl RunEnvironmentProvider for GitHubActionsProvider { } announcement!( + "OIDC Authentication", "You can now authenticate your CI workflows using OpenID Connect (OIDC).\n\ This makes integrating and authenticating jobs safer and simpler.\n\ Learn more at https://codspeed.io/docs/integrations/ci/github-actions/configuration#oidc-recommended\n" diff --git a/src/run_environment/gitlab_ci/logger.rs b/src/run_environment/gitlab_ci/logger.rs index 0e365c31..84f50080 100644 --- a/src/run_environment/gitlab_ci/logger.rs +++ b/src/run_environment/gitlab_ci/logger.rs @@ -112,7 +112,14 @@ impl Log for GitLabCILogger { } if let Some(announcement) = get_announcement_event(record) { - println!("{}", style(announcement).green()); + match announcement.title { + Some(title) => println!( + "{}: {}", + style(title).bold().green(), + style(announcement.message).green() + ), + None => println!("{}", style(announcement.message).green()), + } return; }