@@ -97,6 +97,48 @@ pub const NOTIFICATION_TYPES: &[&str] = &[
|
||||
"release_failed",
|
||||
];
|
||||
|
||||
// ── Slack user links ─────────────────────────────────────────────────
|
||||
|
||||
/// Links a Forage user to their Slack identity in a workspace.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlackUserLink {
|
||||
pub id: String,
|
||||
pub user_id: String, // Forage/Forest user ID
|
||||
pub team_id: String, // Slack workspace ID
|
||||
pub team_name: String, // Slack workspace name (display)
|
||||
pub slack_user_id: String, // Slack user ID (U-xxx)
|
||||
pub slack_username: String, // Slack display name
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Per-destination deployment status within a release.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DestinationStatus {
|
||||
pub environment: String,
|
||||
pub status: String, // "started", "succeeded", "failed"
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Tracks a posted Slack message so we can update it in-place.
|
||||
/// One ref per (integration, release_slug) — accumulates all destinations.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlackMessageRef {
|
||||
pub id: String,
|
||||
pub integration_id: String,
|
||||
pub release_id: String, // release slug (shared across destinations)
|
||||
pub channel_id: String, // Slack channel where posted
|
||||
pub message_ts: String, // Slack message timestamp (for chat.update)
|
||||
pub last_event_type: String, // Last event that updated this message
|
||||
/// Accumulated per-destination statuses. Key = destination name.
|
||||
#[serde(default)]
|
||||
pub destinations: HashMap<String, DestinationStatus>,
|
||||
/// Cached release title for message rebuilds.
|
||||
#[serde(default)]
|
||||
pub release_title: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
// ── Delivery log ─────────────────────────────────────────────────────
|
||||
|
||||
/// Record of a notification delivery attempt.
|
||||
@@ -244,11 +286,60 @@ pub trait IntegrationStore: Send + Sync {
|
||||
limit: usize,
|
||||
) -> Result<Vec<NotificationDelivery>, IntegrationError>;
|
||||
|
||||
/// Update the configuration (and optionally the name) of an existing integration.
|
||||
async fn update_integration_config(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
name: &str,
|
||||
config: &IntegrationConfig,
|
||||
) -> Result<(), IntegrationError>;
|
||||
|
||||
/// Look up an integration by its API token hash. Used for API authentication.
|
||||
async fn get_integration_by_token_hash(
|
||||
&self,
|
||||
token_hash: &str,
|
||||
) -> Result<Integration, IntegrationError>;
|
||||
|
||||
// ── Slack user links ──────────────────────────────────────────────
|
||||
|
||||
/// Get the Slack user link for a Forage user in a given workspace, if any.
|
||||
async fn get_slack_user_link(
|
||||
&self,
|
||||
user_id: &str,
|
||||
team_id: &str,
|
||||
) -> Result<Option<SlackUserLink>, IntegrationError>;
|
||||
|
||||
/// Create or update the Slack user link for a given (user_id, team_id) pair.
|
||||
async fn upsert_slack_user_link(&self, link: &SlackUserLink) -> Result<(), IntegrationError>;
|
||||
|
||||
/// Remove the Slack user link for a given (user_id, team_id) pair.
|
||||
async fn delete_slack_user_link(
|
||||
&self,
|
||||
user_id: &str,
|
||||
team_id: &str,
|
||||
) -> Result<(), IntegrationError>;
|
||||
|
||||
/// List all Slack user links for a Forage user (one per connected workspace).
|
||||
async fn list_slack_user_links(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<SlackUserLink>, IntegrationError>;
|
||||
|
||||
// ── Slack message refs ────────────────────────────────────────────
|
||||
|
||||
/// Get the Slack message ref for a release in a specific integration, if any.
|
||||
async fn get_slack_message_ref(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
release_id: &str,
|
||||
) -> Result<Option<SlackMessageRef>, IntegrationError>;
|
||||
|
||||
/// Create or update the Slack message ref for a given (integration_id, release_id) pair.
|
||||
async fn upsert_slack_message_ref(
|
||||
&self,
|
||||
msg_ref: &SlackMessageRef,
|
||||
) -> Result<(), IntegrationError>;
|
||||
}
|
||||
|
||||
// ── Token generation ────────────────────────────────────────────────
|
||||
@@ -319,6 +410,8 @@ pub struct InMemoryIntegrationStore {
|
||||
deliveries: std::sync::Mutex<Vec<NotificationDelivery>>,
|
||||
/// Stores token_hash -> integration_id for lookup.
|
||||
token_hashes: std::sync::Mutex<HashMap<String, String>>,
|
||||
slack_user_links: std::sync::Mutex<Vec<SlackUserLink>>,
|
||||
slack_message_refs: std::sync::Mutex<Vec<SlackMessageRef>>,
|
||||
}
|
||||
|
||||
impl InMemoryIntegrationStore {
|
||||
@@ -328,6 +421,8 @@ impl InMemoryIntegrationStore {
|
||||
rules: std::sync::Mutex::new(Vec::new()),
|
||||
deliveries: std::sync::Mutex::new(Vec::new()),
|
||||
token_hashes: std::sync::Mutex::new(HashMap::new()),
|
||||
slack_user_links: std::sync::Mutex::new(Vec::new()),
|
||||
slack_message_refs: std::sync::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -453,6 +548,23 @@ impl IntegrationStore for InMemoryIntegrationStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_integration_config(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
name: &str,
|
||||
config: &IntegrationConfig,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let mut store = self.integrations.lock().unwrap();
|
||||
let integ = store
|
||||
.iter_mut()
|
||||
.find(|i| i.id == id && i.organisation == organisation)
|
||||
.ok_or_else(|| IntegrationError::NotFound(id.to_string()))?;
|
||||
integ.name = name.to_string();
|
||||
integ.config = config.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_rules(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
@@ -564,6 +676,80 @@ impl IntegrationStore for InMemoryIntegrationStore {
|
||||
.cloned()
|
||||
.ok_or(IntegrationError::NotFound(id))
|
||||
}
|
||||
|
||||
async fn get_slack_user_link(
|
||||
&self,
|
||||
user_id: &str,
|
||||
team_id: &str,
|
||||
) -> Result<Option<SlackUserLink>, IntegrationError> {
|
||||
let links = self.slack_user_links.lock().unwrap();
|
||||
Ok(links
|
||||
.iter()
|
||||
.find(|l| l.user_id == user_id && l.team_id == team_id)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn upsert_slack_user_link(&self, link: &SlackUserLink) -> Result<(), IntegrationError> {
|
||||
let mut links = self.slack_user_links.lock().unwrap();
|
||||
if let Some(existing) = links
|
||||
.iter_mut()
|
||||
.find(|l| l.user_id == link.user_id && l.team_id == link.team_id)
|
||||
{
|
||||
*existing = link.clone();
|
||||
} else {
|
||||
links.push(link.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_slack_user_link(
|
||||
&self,
|
||||
user_id: &str,
|
||||
team_id: &str,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let mut links = self.slack_user_links.lock().unwrap();
|
||||
links.retain(|l| !(l.user_id == user_id && l.team_id == team_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_slack_user_links(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<SlackUserLink>, IntegrationError> {
|
||||
let links = self.slack_user_links.lock().unwrap();
|
||||
Ok(links
|
||||
.iter()
|
||||
.filter(|l| l.user_id == user_id)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_slack_message_ref(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
release_id: &str,
|
||||
) -> Result<Option<SlackMessageRef>, IntegrationError> {
|
||||
let refs = self.slack_message_refs.lock().unwrap();
|
||||
Ok(refs
|
||||
.iter()
|
||||
.find(|r| r.integration_id == integration_id && r.release_id == release_id)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn upsert_slack_message_ref(
|
||||
&self,
|
||||
msg_ref: &SlackMessageRef,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let mut refs = self.slack_message_refs.lock().unwrap();
|
||||
if let Some(existing) = refs.iter_mut().find(|r| {
|
||||
r.integration_id == msg_ref.integration_id && r.release_id == msg_ref.release_id
|
||||
}) {
|
||||
*existing = msg_ref.clone();
|
||||
} else {
|
||||
refs.push(msg_ref.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -20,11 +20,21 @@ pub struct NotificationEnvelope {
|
||||
pub struct ReleaseContextEnvelope {
|
||||
pub slug: String,
|
||||
pub artifact_id: String,
|
||||
#[serde(default)]
|
||||
pub release_intent_id: String,
|
||||
pub destination: String,
|
||||
pub environment: String,
|
||||
pub source_username: String,
|
||||
#[serde(default)]
|
||||
pub source_user_id: String,
|
||||
pub commit_sha: String,
|
||||
pub commit_branch: String,
|
||||
#[serde(default)]
|
||||
pub context_title: String,
|
||||
#[serde(default)]
|
||||
pub context_web: String,
|
||||
#[serde(default)]
|
||||
pub destination_count: i32,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
@@ -41,11 +51,16 @@ impl From<&NotificationEvent> for NotificationEnvelope {
|
||||
release: e.release.as_ref().map(|r| ReleaseContextEnvelope {
|
||||
slug: r.slug.clone(),
|
||||
artifact_id: r.artifact_id.clone(),
|
||||
release_intent_id: r.release_intent_id.clone(),
|
||||
destination: r.destination.clone(),
|
||||
environment: r.environment.clone(),
|
||||
source_username: r.source_username.clone(),
|
||||
source_user_id: r.source_user_id.clone(),
|
||||
commit_sha: r.commit_sha.clone(),
|
||||
commit_branch: r.commit_branch.clone(),
|
||||
context_title: r.context_title.clone(),
|
||||
context_web: r.context_web.clone(),
|
||||
destination_count: r.destination_count,
|
||||
error_message: r.error_message.clone(),
|
||||
}),
|
||||
}
|
||||
@@ -65,11 +80,16 @@ impl From<NotificationEnvelope> for NotificationEvent {
|
||||
release: e.release.map(|r| ReleaseContext {
|
||||
slug: r.slug,
|
||||
artifact_id: r.artifact_id,
|
||||
release_intent_id: r.release_intent_id,
|
||||
destination: r.destination,
|
||||
environment: r.environment,
|
||||
source_username: r.source_username,
|
||||
source_user_id: r.source_user_id,
|
||||
commit_sha: r.commit_sha,
|
||||
commit_branch: r.commit_branch,
|
||||
context_title: r.context_title,
|
||||
context_web: r.context_web,
|
||||
destination_count: r.destination_count,
|
||||
error_message: r.error_message,
|
||||
}),
|
||||
}
|
||||
@@ -107,11 +127,16 @@ mod tests {
|
||||
release: Some(ReleaseContext {
|
||||
slug: "v1.2.3".into(),
|
||||
artifact_id: "art_123".into(),
|
||||
release_intent_id: "ri_1".into(),
|
||||
destination: "prod-eu".into(),
|
||||
environment: "production".into(),
|
||||
source_username: "alice".into(),
|
||||
source_user_id: "alice_id".into(),
|
||||
commit_sha: "abc1234def".into(),
|
||||
commit_branch: "main".into(),
|
||||
context_title: "Release failed".into(),
|
||||
context_web: String::new(),
|
||||
destination_count: 3,
|
||||
error_message: Some("health check timeout".into()),
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -19,11 +19,16 @@ pub struct NotificationEvent {
|
||||
pub struct ReleaseContext {
|
||||
pub slug: String,
|
||||
pub artifact_id: String,
|
||||
pub release_intent_id: String,
|
||||
pub destination: String,
|
||||
pub environment: String,
|
||||
pub source_username: String,
|
||||
pub source_user_id: String,
|
||||
pub commit_sha: String,
|
||||
pub commit_branch: String,
|
||||
pub context_title: String,
|
||||
pub context_web: String,
|
||||
pub destination_count: i32,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
@@ -38,9 +43,29 @@ pub enum DispatchTask {
|
||||
headers: std::collections::HashMap<String, String>,
|
||||
payload: WebhookPayload,
|
||||
},
|
||||
/// Slack channel message via bot token (supports update-in-place).
|
||||
/// Falls back to webhook_url if access_token is empty.
|
||||
Slack {
|
||||
integration_id: String,
|
||||
webhook_url: String,
|
||||
access_token: String,
|
||||
channel_id: String,
|
||||
release_id: String,
|
||||
notification_id: String,
|
||||
event_type: String,
|
||||
/// The full event, needed to rebuild the message after merging destination state.
|
||||
event: NotificationEvent,
|
||||
message: SlackMessage,
|
||||
},
|
||||
/// Personal DM to a user who linked their Slack account.
|
||||
SlackDm {
|
||||
integration_id: String,
|
||||
access_token: String,
|
||||
slack_user_id: String,
|
||||
release_id: String,
|
||||
notification_id: String,
|
||||
event_type: String,
|
||||
event: NotificationEvent,
|
||||
message: SlackMessage,
|
||||
},
|
||||
}
|
||||
@@ -74,11 +99,28 @@ pub fn route_notification(
|
||||
headers: headers.clone(),
|
||||
payload: payload.clone(),
|
||||
},
|
||||
IntegrationConfig::Slack { webhook_url, .. } => {
|
||||
let message = format_slack_message(event);
|
||||
IntegrationConfig::Slack {
|
||||
webhook_url,
|
||||
access_token,
|
||||
channel_id,
|
||||
..
|
||||
} => {
|
||||
let message = format_slack_message(event, &std::collections::HashMap::new(), "");
|
||||
// Group by release slug (shared across all destinations in a release)
|
||||
let release_id = event
|
||||
.release
|
||||
.as_ref()
|
||||
.map(|r| r.slug.clone())
|
||||
.unwrap_or_default();
|
||||
DispatchTask::Slack {
|
||||
integration_id: integration.id.clone(),
|
||||
webhook_url: webhook_url.clone(),
|
||||
access_token: access_token.clone(),
|
||||
channel_id: channel_id.clone(),
|
||||
release_id,
|
||||
notification_id: event.id.clone(),
|
||||
event_type: event.notification_type.clone(),
|
||||
event: event.clone(),
|
||||
message,
|
||||
}
|
||||
}
|
||||
@@ -86,21 +128,105 @@ pub fn route_notification(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find matching integrations and produce dispatch tasks.
|
||||
/// Find matching integrations and produce dispatch tasks (channel + DM).
|
||||
pub async fn route_notification_for_org(
|
||||
store: &dyn IntegrationStore,
|
||||
event: &NotificationEvent,
|
||||
) -> Vec<DispatchTask> {
|
||||
match store
|
||||
let integrations = match store
|
||||
.list_matching_integrations(&event.organisation, &event.notification_type)
|
||||
.await
|
||||
{
|
||||
Ok(integrations) => route_notification(event, &integrations),
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
tracing::error!(org = %event.organisation, error = %e, "failed to list matching integrations");
|
||||
vec![]
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
let mut tasks = route_notification(event, &integrations);
|
||||
|
||||
// Produce personal DM tasks for the release owner (if they linked Slack)
|
||||
if let Some(release) = &event.release {
|
||||
tracing::debug!(
|
||||
source_user_id = %release.source_user_id,
|
||||
source_username = %release.source_username,
|
||||
"DM routing: checking release owner"
|
||||
);
|
||||
}
|
||||
// Only DM on actual deploy events, not bare annotations
|
||||
let dm_event_types = ["release_started", "release_succeeded", "release_failed"];
|
||||
if let Some(release) = event.release.as_ref().filter(|r| {
|
||||
!r.source_user_id.is_empty() && dm_event_types.contains(&event.notification_type.as_str())
|
||||
}) {
|
||||
let slack_count = integrations.iter().filter(|i| matches!(&i.config, IntegrationConfig::Slack { .. })).count();
|
||||
tracing::debug!(
|
||||
total_integrations = integrations.len(),
|
||||
slack_integrations = slack_count,
|
||||
"DM routing: iterating integrations for DM lookup"
|
||||
);
|
||||
// For each Slack integration with a bot token, check if the author linked that workspace
|
||||
for integration in &integrations {
|
||||
if let IntegrationConfig::Slack {
|
||||
team_id,
|
||||
access_token,
|
||||
..
|
||||
} = &integration.config
|
||||
{
|
||||
tracing::debug!(
|
||||
integration_id = %integration.id,
|
||||
team_id = %team_id,
|
||||
has_token = !access_token.is_empty(),
|
||||
"DM routing: checking slack integration"
|
||||
);
|
||||
if access_token.is_empty() || team_id.is_empty() {
|
||||
continue; // manual webhook, no bot token
|
||||
}
|
||||
// Look up the release author's Slack link for this workspace
|
||||
match store
|
||||
.get_slack_user_link(&release.source_user_id, team_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(link)) => {
|
||||
tracing::info!(
|
||||
user_id = %release.source_user_id,
|
||||
team_id = %team_id,
|
||||
slack_user_id = %link.slack_user_id,
|
||||
"DM routing: found slack link, creating DM task"
|
||||
);
|
||||
let message = format_slack_message(event, &std::collections::HashMap::new(), "");
|
||||
tasks.push(DispatchTask::SlackDm {
|
||||
integration_id: integration.id.clone(),
|
||||
access_token: access_token.clone(),
|
||||
slack_user_id: link.slack_user_id,
|
||||
release_id: release.slug.clone(),
|
||||
notification_id: event.id.clone(),
|
||||
event_type: event.notification_type.clone(),
|
||||
event: event.clone(),
|
||||
message,
|
||||
});
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!(
|
||||
user_id = %release.source_user_id,
|
||||
team_id = %team_id,
|
||||
"DM routing: no slack link found for user in this workspace"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
user = %release.source_user_id,
|
||||
team_id = %team_id,
|
||||
error = %e,
|
||||
"failed to look up slack user link for DM"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks
|
||||
}
|
||||
|
||||
fn build_webhook_payload(event: &NotificationEvent) -> WebhookPayload {
|
||||
@@ -125,125 +251,100 @@ fn build_webhook_payload(event: &NotificationEvent) -> WebhookPayload {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_slack_message(event: &NotificationEvent) -> SlackMessage {
|
||||
let color = match event.notification_type.as_str() {
|
||||
"release_succeeded" => "#36a64f",
|
||||
"release_failed" => "#dc3545",
|
||||
"release_started" => "#0d6efd",
|
||||
"release_annotated" => "#6c757d",
|
||||
_ => "#6c757d",
|
||||
/// Build a compact Slack message showing release progress across destinations.
|
||||
///
|
||||
/// `forage_url` is the base URL for deep links (e.g. "https://client.dev.forage.sh").
|
||||
/// When `accumulated` is non-empty, renders all known destination statuses.
|
||||
/// When empty (first message or webhook fallback), shows just the current event's destination.
|
||||
pub fn format_slack_message(
|
||||
event: &NotificationEvent,
|
||||
accumulated: &std::collections::HashMap<String, super::DestinationStatus>,
|
||||
forage_url: &str,
|
||||
) -> SlackMessage {
|
||||
let release = event.release.as_ref();
|
||||
|
||||
// Determine aggregate color from accumulated destinations
|
||||
let color = if accumulated.is_empty() {
|
||||
match event.notification_type.as_str() {
|
||||
"release_succeeded" => "#36a64f",
|
||||
"release_failed" => "#dc3545",
|
||||
"release_started" => "#0d6efd",
|
||||
"release_annotated" => "#6c757d",
|
||||
_ => "#6c757d",
|
||||
}
|
||||
} else {
|
||||
aggregate_color(accumulated)
|
||||
};
|
||||
|
||||
let status_emoji = match event.notification_type.as_str() {
|
||||
"release_succeeded" => ":white_check_mark:",
|
||||
"release_failed" => ":x:",
|
||||
"release_started" => ":rocket:",
|
||||
"release_annotated" => ":memo:",
|
||||
_ => ":bell:",
|
||||
};
|
||||
let title = &event.title;
|
||||
|
||||
let status_emoji = aggregate_emoji(if accumulated.is_empty() {
|
||||
&event.notification_type
|
||||
} else {
|
||||
return format_accumulated_message(event, color, accumulated, forage_url);
|
||||
});
|
||||
|
||||
// Fallback text (shown in notifications/previews)
|
||||
let text = format!("{} {}", status_emoji, event.title);
|
||||
let text = format!("{status_emoji} {title}");
|
||||
|
||||
// Build Block Kit blocks
|
||||
let mut blocks: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
// Header
|
||||
blocks.push(serde_json::json!({
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": event.title,
|
||||
"emoji": true
|
||||
}
|
||||
}));
|
||||
// Header: emoji + title, with "View Release" button
|
||||
let release_url = release
|
||||
.map(|r| build_release_url(event, r, forage_url))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Body section (if present)
|
||||
if !event.body.is_empty() {
|
||||
let mut header_block = serde_json::json!({
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": format!("{status_emoji} *{title}*")
|
||||
}
|
||||
});
|
||||
if !release_url.is_empty() {
|
||||
header_block["accessory"] = build_view_button(&release_url);
|
||||
}
|
||||
blocks.push(header_block);
|
||||
|
||||
// Commit/change title
|
||||
if let Some(r) = release.filter(|r| !r.context_title.is_empty()) {
|
||||
blocks.push(serde_json::json!({
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": event.body
|
||||
}
|
||||
"text": { "type": "mrkdwn", "text": format!(":memo: {}", r.context_title) }
|
||||
}));
|
||||
}
|
||||
|
||||
// Release metadata fields
|
||||
if let Some(ref r) = event.release {
|
||||
let mut fields = vec![
|
||||
serde_json::json!({
|
||||
"type": "mrkdwn",
|
||||
"text": format!("*Organisation*\n{}", event.organisation)
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "mrkdwn",
|
||||
"text": format!("*Project*\n{}", event.project)
|
||||
}),
|
||||
];
|
||||
// Metadata line
|
||||
if let Some(r) = release {
|
||||
blocks.push(build_metadata_context(event, r, forage_url));
|
||||
blocks.push(serde_json::json!({ "type": "divider" }));
|
||||
|
||||
// Single destination status
|
||||
if !r.destination.is_empty() {
|
||||
fields.push(serde_json::json!({
|
||||
"type": "mrkdwn",
|
||||
"text": format!("*Destination*\n`{}`", r.destination)
|
||||
}));
|
||||
}
|
||||
|
||||
if !r.environment.is_empty() {
|
||||
fields.push(serde_json::json!({
|
||||
"type": "mrkdwn",
|
||||
"text": format!("*Environment*\n{}", r.environment)
|
||||
}));
|
||||
}
|
||||
|
||||
if !r.commit_sha.is_empty() {
|
||||
let short_sha = &r.commit_sha[..r.commit_sha.len().min(7)];
|
||||
fields.push(serde_json::json!({
|
||||
"type": "mrkdwn",
|
||||
"text": format!("*Commit*\n`{}`", short_sha)
|
||||
}));
|
||||
}
|
||||
|
||||
if !r.commit_branch.is_empty() {
|
||||
fields.push(serde_json::json!({
|
||||
"type": "mrkdwn",
|
||||
"text": format!("*Branch*\n`{}`", r.commit_branch)
|
||||
}));
|
||||
}
|
||||
|
||||
if !r.source_username.is_empty() {
|
||||
fields.push(serde_json::json!({
|
||||
"type": "mrkdwn",
|
||||
"text": format!("*Author*\n{}", r.source_username)
|
||||
}));
|
||||
}
|
||||
|
||||
blocks.push(serde_json::json!({
|
||||
"type": "section",
|
||||
"fields": fields
|
||||
}));
|
||||
|
||||
// Error message (if any)
|
||||
if let Some(ref err) = r.error_message {
|
||||
let dest_emoji = match event.notification_type.as_str() {
|
||||
"release_succeeded" => ":white_check_mark:",
|
||||
"release_failed" => ":x:",
|
||||
"release_started" => ":arrows_counterclockwise:",
|
||||
_ => ":bell:",
|
||||
};
|
||||
let status_label = match event.notification_type.as_str() {
|
||||
"release_succeeded" => "Deployed",
|
||||
"release_failed" => "Failed",
|
||||
"release_started" => "Deploying",
|
||||
"release_annotated" => "Annotated",
|
||||
_ => "Unknown",
|
||||
};
|
||||
let mut dest_line = format!("{dest_emoji} `{}` {status_label}", r.destination);
|
||||
if let Some(ref err) = r.error_message {
|
||||
dest_line.push_str(&format!(" — _{err}_"));
|
||||
}
|
||||
blocks.push(serde_json::json!({
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": format!(":warning: *Error:* {}", err)
|
||||
}
|
||||
"text": { "type": "mrkdwn", "text": dest_line }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Context line with timestamp
|
||||
blocks.push(serde_json::json!({
|
||||
"type": "context",
|
||||
"elements": [{
|
||||
"type": "mrkdwn",
|
||||
"text": format!("{} | {}", event.notification_type.replace('_', " "), event.timestamp)
|
||||
}]
|
||||
}));
|
||||
|
||||
SlackMessage {
|
||||
text,
|
||||
color: color.to_string(),
|
||||
@@ -251,6 +352,340 @@ fn format_slack_message(event: &NotificationEvent) -> SlackMessage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the full multi-destination message from accumulated state.
|
||||
fn format_accumulated_message(
|
||||
event: &NotificationEvent,
|
||||
color: &str,
|
||||
destinations: &std::collections::HashMap<String, super::DestinationStatus>,
|
||||
forage_url: &str,
|
||||
) -> SlackMessage {
|
||||
let release = event.release.as_ref();
|
||||
let title = &event.title;
|
||||
let emoji = aggregate_emoji_from_destinations(destinations);
|
||||
let text = format!("{emoji} {title}");
|
||||
|
||||
let mut blocks: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
// Header with "View Release" button
|
||||
let release_url = release
|
||||
.map(|r| build_release_url(event, r, forage_url))
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut header_block = serde_json::json!({
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": format!("{emoji} *{title}*") }
|
||||
});
|
||||
if !release_url.is_empty() {
|
||||
header_block["accessory"] = build_view_button(&release_url);
|
||||
}
|
||||
blocks.push(header_block);
|
||||
|
||||
// Commit/change title (e.g. "fix: correct timezone handling in cron scheduler (#80)")
|
||||
if let Some(r) = release.filter(|r| !r.context_title.is_empty()) {
|
||||
blocks.push(serde_json::json!({
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": format!(":memo: {}", r.context_title) }
|
||||
}));
|
||||
}
|
||||
|
||||
// Metadata
|
||||
if let Some(r) = release {
|
||||
blocks.push(build_metadata_context(event, r, forage_url));
|
||||
}
|
||||
|
||||
blocks.push(serde_json::json!({ "type": "divider" }));
|
||||
|
||||
// Destination progress line
|
||||
let done = destinations
|
||||
.values()
|
||||
.filter(|d| d.status == "succeeded" || d.status == "failed")
|
||||
.count();
|
||||
let total = destinations.len();
|
||||
|
||||
// Compact destination line: ✅ dev ✅ staging 🔄 prod (2/3)
|
||||
let mut dest_parts: Vec<String> = Vec::new();
|
||||
let mut sorted_dests: Vec<_> = destinations.iter().collect();
|
||||
sorted_dests.sort_by_key(|(name, _)| name.as_str());
|
||||
|
||||
for (name, status) in &sorted_dests {
|
||||
let emoji = match status.status.as_str() {
|
||||
"succeeded" => ":white_check_mark:",
|
||||
"failed" => ":x:",
|
||||
"started" => ":arrows_counterclockwise:",
|
||||
_ => ":hourglass:",
|
||||
};
|
||||
dest_parts.push(format!("{emoji} {name}"));
|
||||
}
|
||||
|
||||
let dest_count = format!(" ({done}/{total})");
|
||||
|
||||
blocks.push(serde_json::json!({
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": format!("{}{dest_count}", dest_parts.join(" ")) }
|
||||
}));
|
||||
|
||||
// Show errors inline if any failed
|
||||
for (name, status) in &sorted_dests {
|
||||
if let Some(ref err) = status.error {
|
||||
blocks.push(serde_json::json!({
|
||||
"type": "context",
|
||||
"elements": [{ "type": "mrkdwn", "text": format!(":warning: *{name}:* {err}") }]
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
SlackMessage {
|
||||
text,
|
||||
color: color.to_string(),
|
||||
blocks,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the metadata context block shared by both message formats.
|
||||
fn build_metadata_context(
|
||||
event: &NotificationEvent,
|
||||
r: &ReleaseContext,
|
||||
_forage_url: &str,
|
||||
) -> serde_json::Value {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
parts.push(format!("*{}* / {}", event.project, r.slug));
|
||||
|
||||
if !r.commit_branch.is_empty() {
|
||||
parts.push(format!("`{}`", r.commit_branch));
|
||||
}
|
||||
if !r.commit_sha.is_empty() {
|
||||
parts.push(format!("`{}`", &r.commit_sha[..r.commit_sha.len().min(7)]));
|
||||
}
|
||||
if !r.source_username.is_empty() {
|
||||
parts.push(r.source_username.clone());
|
||||
}
|
||||
|
||||
// Source link (e.g. GitHub commit/PR)
|
||||
if !r.context_web.is_empty() {
|
||||
parts.push(format!("<{}|source>", r.context_web));
|
||||
}
|
||||
|
||||
serde_json::json!({
|
||||
"type": "context",
|
||||
"elements": [{ "type": "mrkdwn", "text": parts.join(" · ") }]
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the release URL for deep linking.
|
||||
fn build_release_url(
|
||||
event: &NotificationEvent,
|
||||
r: &ReleaseContext,
|
||||
forage_url: &str,
|
||||
) -> String {
|
||||
if forage_url.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
format!(
|
||||
"{}/orgs/{}/projects/{}/releases/{}",
|
||||
forage_url.trim_end_matches('/'),
|
||||
event.organisation,
|
||||
event.project,
|
||||
r.slug,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a "View Release" button block for the header section accessory.
|
||||
fn build_view_button(url: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "button",
|
||||
"text": { "type": "plain_text", "text": "View Release" },
|
||||
"url": url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build Slack blocks for pipeline stage progress.
|
||||
/// Renders each stage as a line: emoji + label (e.g. "✅ Deployed to `dev`", "⏳ Wait 5s").
|
||||
/// Stages are topologically sorted by `depends_on` to match pipeline execution order.
|
||||
pub fn format_pipeline_blocks(
|
||||
stages: &[crate::platform::PipelineRunStageState],
|
||||
) -> Vec<serde_json::Value> {
|
||||
if stages.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Topological sort by depends_on
|
||||
let sorted = topo_sort_stages(stages);
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
|
||||
for stage in &sorted {
|
||||
let emoji = match stage.status.as_str() {
|
||||
"SUCCEEDED" => ":white_check_mark:",
|
||||
"RUNNING" => ":arrows_counterclockwise:",
|
||||
"FAILED" => ":x:",
|
||||
"CANCELLED" => ":no_entry_sign:",
|
||||
_ => ":radio_button:", // PENDING
|
||||
};
|
||||
|
||||
let label = match stage.stage_type.as_str() {
|
||||
"deploy" => {
|
||||
let env = stage.environment.as_deref().unwrap_or("unknown");
|
||||
match stage.status.as_str() {
|
||||
"SUCCEEDED" => format!("Deployed to `{env}`"),
|
||||
"RUNNING" => format!("Deploying to `{env}`"),
|
||||
"FAILED" => format!("Deploy to `{env}` failed"),
|
||||
_ => format!("Deploy to `{env}`"),
|
||||
}
|
||||
}
|
||||
"wait" => {
|
||||
let duration = stage.duration_seconds.unwrap_or(0);
|
||||
let dur_str = if duration >= 60 {
|
||||
format!("{}m", duration / 60)
|
||||
} else {
|
||||
format!("{duration}s")
|
||||
};
|
||||
match stage.status.as_str() {
|
||||
"SUCCEEDED" => format!("Waited {dur_str}"),
|
||||
"RUNNING" => format!("Waiting {dur_str}"),
|
||||
_ => format!("Wait {dur_str}"),
|
||||
}
|
||||
}
|
||||
_ => format!("Stage {}", stage.stage_id),
|
||||
};
|
||||
|
||||
let mut line = format!("{emoji} {label}");
|
||||
if let Some(ref err) = stage.error_message {
|
||||
line.push_str(&format!(" — _{err}_"));
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
let done = stages
|
||||
.iter()
|
||||
.filter(|s| s.status == "SUCCEEDED" || s.status == "FAILED" || s.status == "CANCELLED")
|
||||
.count();
|
||||
let total = stages.len();
|
||||
|
||||
let mut blocks = Vec::new();
|
||||
|
||||
// Pipeline header + stages
|
||||
blocks.push(serde_json::json!({
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": lines.join("\n") }
|
||||
}));
|
||||
|
||||
// Progress count
|
||||
blocks.push(serde_json::json!({
|
||||
"type": "context",
|
||||
"elements": [{ "type": "mrkdwn", "text": format!("{done}/{total} stages complete") }]
|
||||
}));
|
||||
|
||||
// Divider before destinations
|
||||
blocks.push(serde_json::json!({ "type": "divider" }));
|
||||
|
||||
blocks
|
||||
}
|
||||
|
||||
/// Topological sort of pipeline stages by `depends_on`.
|
||||
/// Falls back to input order if the graph has issues.
|
||||
fn topo_sort_stages(
|
||||
stages: &[crate::platform::PipelineRunStageState],
|
||||
) -> Vec<crate::platform::PipelineRunStageState> {
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
// Build in-degree map
|
||||
let mut in_degree: HashMap<&str, usize> = HashMap::new();
|
||||
let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||
|
||||
for s in stages {
|
||||
in_degree.entry(s.stage_id.as_str()).or_insert(0);
|
||||
for dep in &s.depends_on {
|
||||
dependents
|
||||
.entry(dep.as_str())
|
||||
.or_default()
|
||||
.push(s.stage_id.as_str());
|
||||
*in_degree.entry(s.stage_id.as_str()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Kahn's algorithm
|
||||
let mut queue: VecDeque<&str> = in_degree
|
||||
.iter()
|
||||
.filter(|&(_, deg)| *deg == 0)
|
||||
.map(|(&id, _)| id)
|
||||
.collect();
|
||||
|
||||
let mut sorted_ids: Vec<String> = Vec::with_capacity(stages.len());
|
||||
while let Some(id) = queue.pop_front() {
|
||||
sorted_ids.push(id.to_string());
|
||||
if let Some(deps) = dependents.get(id) {
|
||||
for &dep in deps {
|
||||
if let Some(deg) = in_degree.get_mut(dep) {
|
||||
*deg -= 1;
|
||||
if *deg == 0 {
|
||||
queue.push_back(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build result in sorted order, falling back to input order for missing
|
||||
let id_to_idx: HashMap<&str, usize> = sorted_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, id)| (id.as_str(), i))
|
||||
.collect();
|
||||
|
||||
let mut indexed: Vec<(usize, &crate::platform::PipelineRunStageState)> = stages
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let idx = id_to_idx
|
||||
.get(s.stage_id.as_str())
|
||||
.copied()
|
||||
.unwrap_or(usize::MAX);
|
||||
(idx, s)
|
||||
})
|
||||
.collect();
|
||||
indexed.sort_by_key(|(idx, _)| *idx);
|
||||
|
||||
indexed.into_iter().map(|(_, s)| s.clone()).collect()
|
||||
}
|
||||
|
||||
fn aggregate_color(
|
||||
destinations: &std::collections::HashMap<String, super::DestinationStatus>,
|
||||
) -> &'static str {
|
||||
let has_failed = destinations.values().any(|d| d.status == "failed");
|
||||
let has_started = destinations.values().any(|d| d.status == "started");
|
||||
if has_failed {
|
||||
"#dc3545" // red
|
||||
} else if has_started {
|
||||
"#0d6efd" // blue (still in progress)
|
||||
} else {
|
||||
"#36a64f" // green (all done)
|
||||
}
|
||||
}
|
||||
|
||||
fn aggregate_emoji(event_type: &str) -> &'static str {
|
||||
match event_type {
|
||||
"release_succeeded" => ":white_check_mark:",
|
||||
"release_failed" => ":x:",
|
||||
"release_started" => ":rocket:",
|
||||
"release_annotated" => ":memo:",
|
||||
_ => ":bell:",
|
||||
}
|
||||
}
|
||||
|
||||
fn aggregate_emoji_from_destinations(
|
||||
destinations: &std::collections::HashMap<String, super::DestinationStatus>,
|
||||
) -> &'static str {
|
||||
let has_failed = destinations.values().any(|d| d.status == "failed");
|
||||
let has_started = destinations.values().any(|d| d.status == "started");
|
||||
if has_failed {
|
||||
":x:"
|
||||
} else if has_started {
|
||||
":rocket:"
|
||||
} else {
|
||||
":white_check_mark:"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -268,11 +703,16 @@ mod tests {
|
||||
release: Some(ReleaseContext {
|
||||
slug: "test-release".into(),
|
||||
artifact_id: "art_123".into(),
|
||||
release_intent_id: "ri_1".into(),
|
||||
destination: "prod-eu".into(),
|
||||
environment: "production".into(),
|
||||
source_username: "alice".into(),
|
||||
source_user_id: "alice_id".into(),
|
||||
commit_sha: "abc1234def".into(),
|
||||
commit_branch: "main".into(),
|
||||
context_title: "Release failed".into(),
|
||||
context_web: String::new(),
|
||||
destination_count: 3,
|
||||
error_message: Some("health check timeout".into()),
|
||||
}),
|
||||
}
|
||||
@@ -384,16 +824,211 @@ mod tests {
|
||||
fn slack_message_color_success() {
|
||||
let mut event = test_event();
|
||||
event.notification_type = "release_succeeded".into();
|
||||
let msg = format_slack_message(&event);
|
||||
let msg = format_slack_message(&event, &HashMap::new(), "");
|
||||
assert_eq!(msg.color, "#36a64f");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_message_includes_error() {
|
||||
let event = test_event();
|
||||
let msg = format_slack_message(&event);
|
||||
let msg = format_slack_message(&event, &HashMap::new(), "");
|
||||
// Error message is rendered in blocks, not the fallback text field
|
||||
let blocks_str = serde_json::to_string(&msg.blocks).unwrap();
|
||||
assert!(blocks_str.contains("health check timeout"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_message_accumulated_shows_all_destinations() {
|
||||
let event = test_event();
|
||||
let mut dests = HashMap::new();
|
||||
dests.insert("prod-eu".into(), super::super::DestinationStatus {
|
||||
environment: "production".into(),
|
||||
status: "succeeded".into(),
|
||||
error: None,
|
||||
});
|
||||
dests.insert("staging".into(), super::super::DestinationStatus {
|
||||
environment: "staging".into(),
|
||||
status: "started".into(),
|
||||
error: None,
|
||||
});
|
||||
let msg = format_slack_message(&event, &dests, "");
|
||||
// Should be blue (still deploying)
|
||||
assert_eq!(msg.color, "#0d6efd");
|
||||
let blocks_str = serde_json::to_string(&msg.blocks).unwrap();
|
||||
assert!(blocks_str.contains("prod-eu"));
|
||||
assert!(blocks_str.contains("staging"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_message_accumulated_all_succeeded() {
|
||||
let mut event = test_event();
|
||||
// Set destination_count to match the 2 destinations we provide
|
||||
if let Some(ref mut r) = event.release {
|
||||
r.destination_count = 2;
|
||||
}
|
||||
let mut dests = HashMap::new();
|
||||
dests.insert("prod-eu".into(), super::super::DestinationStatus {
|
||||
environment: "production".into(),
|
||||
status: "succeeded".into(),
|
||||
error: None,
|
||||
});
|
||||
dests.insert("staging".into(), super::super::DestinationStatus {
|
||||
environment: "staging".into(),
|
||||
status: "succeeded".into(),
|
||||
error: None,
|
||||
});
|
||||
let msg = format_slack_message(&event, &dests, "");
|
||||
assert_eq!(msg.color, "#36a64f"); // green — all done, no pending
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_message_in_progress_is_blue() {
|
||||
let event = test_event();
|
||||
let mut dests = HashMap::new();
|
||||
dests.insert("prod-eu".into(), super::super::DestinationStatus {
|
||||
environment: "production".into(),
|
||||
status: "started".into(),
|
||||
error: None,
|
||||
});
|
||||
// A destination still in progress → blue
|
||||
let msg = format_slack_message(&event, &dests, "");
|
||||
assert_eq!(msg.color, "#0d6efd"); // blue — in progress
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_message_accumulated_shows_errors() {
|
||||
let event = test_event();
|
||||
let mut dests = HashMap::new();
|
||||
dests.insert("prod-eu".into(), super::super::DestinationStatus {
|
||||
environment: "production".into(),
|
||||
status: "failed".into(),
|
||||
error: Some("OOM killed".into()),
|
||||
});
|
||||
let msg = format_slack_message(&event, &dests, "");
|
||||
assert_eq!(msg.color, "#dc3545"); // red
|
||||
let blocks_str = serde_json::to_string(&msg.blocks).unwrap();
|
||||
assert!(blocks_str.contains("OOM killed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_blocks_renders_stages() {
|
||||
use crate::platform::PipelineRunStageState;
|
||||
|
||||
let stages = vec![
|
||||
PipelineRunStageState {
|
||||
stage_id: "s1".into(),
|
||||
depends_on: vec![],
|
||||
stage_type: "deploy".into(),
|
||||
status: "SUCCEEDED".into(),
|
||||
environment: Some("dev".into()),
|
||||
duration_seconds: None,
|
||||
queued_at: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
error_message: None,
|
||||
wait_until: None,
|
||||
release_ids: vec![],
|
||||
},
|
||||
PipelineRunStageState {
|
||||
stage_id: "s2".into(),
|
||||
depends_on: vec!["s1".into()],
|
||||
stage_type: "wait".into(),
|
||||
status: "SUCCEEDED".into(),
|
||||
environment: None,
|
||||
duration_seconds: Some(3),
|
||||
queued_at: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
error_message: None,
|
||||
wait_until: None,
|
||||
release_ids: vec![],
|
||||
},
|
||||
PipelineRunStageState {
|
||||
stage_id: "s3".into(),
|
||||
depends_on: vec!["s2".into()],
|
||||
stage_type: "deploy".into(),
|
||||
status: "RUNNING".into(),
|
||||
environment: Some("staging".into()),
|
||||
duration_seconds: None,
|
||||
queued_at: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
error_message: None,
|
||||
wait_until: None,
|
||||
release_ids: vec![],
|
||||
},
|
||||
PipelineRunStageState {
|
||||
stage_id: "s4".into(),
|
||||
depends_on: vec!["s3".into()],
|
||||
stage_type: "wait".into(),
|
||||
status: "PENDING".into(),
|
||||
environment: None,
|
||||
duration_seconds: Some(5),
|
||||
queued_at: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
error_message: None,
|
||||
wait_until: None,
|
||||
release_ids: vec![],
|
||||
},
|
||||
PipelineRunStageState {
|
||||
stage_id: "s5".into(),
|
||||
depends_on: vec!["s4".into()],
|
||||
stage_type: "deploy".into(),
|
||||
status: "PENDING".into(),
|
||||
environment: Some("prod".into()),
|
||||
duration_seconds: None,
|
||||
queued_at: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
error_message: None,
|
||||
wait_until: None,
|
||||
release_ids: vec![],
|
||||
},
|
||||
];
|
||||
|
||||
let blocks = format_pipeline_blocks(&stages);
|
||||
assert_eq!(blocks.len(), 3); // stages block + progress context + divider
|
||||
|
||||
let text = blocks[0]["text"]["text"].as_str().unwrap();
|
||||
assert!(text.contains("Deployed to `dev`"));
|
||||
assert!(text.contains("Waited 3s"));
|
||||
assert!(text.contains("Deploying to `staging`"));
|
||||
assert!(text.contains("Wait 5s"));
|
||||
assert!(text.contains("Deploy to `prod`"));
|
||||
|
||||
let progress = blocks[1]["elements"][0]["text"].as_str().unwrap();
|
||||
assert_eq!(progress, "2/5 stages complete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_blocks_empty_stages_returns_nothing() {
|
||||
let blocks = format_pipeline_blocks(&[]);
|
||||
assert!(blocks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_blocks_shows_errors() {
|
||||
use crate::platform::PipelineRunStageState;
|
||||
|
||||
let stages = vec![PipelineRunStageState {
|
||||
stage_id: "s1".into(),
|
||||
depends_on: vec![],
|
||||
stage_type: "deploy".into(),
|
||||
status: "FAILED".into(),
|
||||
environment: Some("prod".into()),
|
||||
duration_seconds: None,
|
||||
queued_at: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
error_message: Some("OOM killed".into()),
|
||||
wait_until: None,
|
||||
release_ids: vec![],
|
||||
}];
|
||||
|
||||
let blocks = format_pipeline_blocks(&stages);
|
||||
let text = blocks[0]["text"]["text"].as_str().unwrap();
|
||||
assert!(text.contains("Deploy to `prod` failed"));
|
||||
assert!(text.contains("OOM killed"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user