feat: add integrations

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-09 22:34:04 +01:00
parent 646581ff44
commit a6401e3b79
26 changed files with 3207 additions and 244 deletions

View File

@@ -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)]

View File

@@ -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()),
}),
}

View File

@@ -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"));
}
}