1
.playwright-mcp/console-2026-03-09T19-50-53-447Z.log
Normal file
1
.playwright-mcp/console-2026-03-09T19-50-53-447Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 227ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ https://client.dev.forage.sh/favicon.ico:0
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use forage_core::integrations::{
|
||||
CreateIntegrationInput, DeliveryStatus, Integration, IntegrationConfig, IntegrationError,
|
||||
IntegrationStore, IntegrationType, NotificationDelivery, NotificationRule, NOTIFICATION_TYPES,
|
||||
IntegrationStore, IntegrationType, NotificationDelivery, NotificationRule, SlackMessageRef,
|
||||
SlackUserLink, NOTIFICATION_TYPES,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
@@ -221,6 +222,36 @@ impl IntegrationStore for PgIntegrationStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_integration_config(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
name: &str,
|
||||
config: &IntegrationConfig,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let uuid: Uuid = id
|
||||
.parse()
|
||||
.map_err(|_| IntegrationError::NotFound(id.to_string()))?;
|
||||
let encrypted = self.encrypt_config(config)?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE integrations SET name = $1, config_encrypted = $2, updated_at = NOW()
|
||||
WHERE id = $3 AND organisation = $4",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(&encrypted)
|
||||
.bind(uuid)
|
||||
.bind(organisation)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(IntegrationError::NotFound(id.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_rules(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
@@ -392,6 +423,189 @@ impl IntegrationStore for PgIntegrationStore {
|
||||
|
||||
self.row_to_integration(row)
|
||||
}
|
||||
|
||||
// ── Slack user links ─────────────────────────────────────────
|
||||
|
||||
async fn get_slack_user_link(
|
||||
&self,
|
||||
user_id: &str,
|
||||
team_id: &str,
|
||||
) -> Result<Option<SlackUserLink>, IntegrationError> {
|
||||
let row: Option<SlackUserLinkRow> = sqlx::query_as(
|
||||
"SELECT id, user_id, team_id, team_name, slack_user_id, slack_username, created_at
|
||||
FROM slack_user_links WHERE user_id = $1 AND team_id = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(team_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
|
||||
Ok(row.map(|r| SlackUserLink {
|
||||
id: r.id.to_string(),
|
||||
user_id: r.user_id,
|
||||
team_id: r.team_id,
|
||||
team_name: r.team_name,
|
||||
slack_user_id: r.slack_user_id,
|
||||
slack_username: r.slack_username,
|
||||
created_at: r.created_at.to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn upsert_slack_user_link(
|
||||
&self,
|
||||
link: &SlackUserLink,
|
||||
) -> Result<(), IntegrationError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO slack_user_links (id, user_id, team_id, team_name, slack_user_id, slack_username, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
ON CONFLICT (user_id, team_id) DO UPDATE SET
|
||||
slack_user_id = EXCLUDED.slack_user_id,
|
||||
slack_username = EXCLUDED.slack_username,
|
||||
team_name = EXCLUDED.team_name",
|
||||
)
|
||||
.bind(Uuid::parse_str(&link.id).map_err(|e| IntegrationError::Store(e.to_string()))?)
|
||||
.bind(&link.user_id)
|
||||
.bind(&link.team_id)
|
||||
.bind(&link.team_name)
|
||||
.bind(&link.slack_user_id)
|
||||
.bind(&link.slack_username)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_slack_user_link(
|
||||
&self,
|
||||
user_id: &str,
|
||||
team_id: &str,
|
||||
) -> Result<(), IntegrationError> {
|
||||
sqlx::query("DELETE FROM slack_user_links WHERE user_id = $1 AND team_id = $2")
|
||||
.bind(user_id)
|
||||
.bind(team_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_slack_user_links(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<SlackUserLink>, IntegrationError> {
|
||||
let rows: Vec<SlackUserLinkRow> = sqlx::query_as(
|
||||
"SELECT id, user_id, team_id, team_name, slack_user_id, slack_username, created_at
|
||||
FROM slack_user_links WHERE user_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| SlackUserLink {
|
||||
id: r.id.to_string(),
|
||||
user_id: r.user_id,
|
||||
team_id: r.team_id,
|
||||
team_name: r.team_name,
|
||||
slack_user_id: r.slack_user_id,
|
||||
slack_username: r.slack_username,
|
||||
created_at: r.created_at.to_rfc3339(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ── Slack message refs ───────────────────────────────────────
|
||||
|
||||
async fn get_slack_message_ref(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
release_id: &str,
|
||||
) -> Result<Option<SlackMessageRef>, IntegrationError> {
|
||||
let iid =
|
||||
Uuid::parse_str(integration_id).map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
let row: Option<SlackMessageRefRow> = sqlx::query_as(
|
||||
"SELECT id, integration_id, release_id, channel_id, message_ts, last_event_type, destinations, release_title, created_at, updated_at
|
||||
FROM slack_message_refs WHERE integration_id = $1 AND release_id = $2",
|
||||
)
|
||||
.bind(iid)
|
||||
.bind(release_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
|
||||
Ok(row.map(|r| SlackMessageRef {
|
||||
id: r.id.to_string(),
|
||||
integration_id: r.integration_id.to_string(),
|
||||
release_id: r.release_id,
|
||||
channel_id: r.channel_id,
|
||||
message_ts: r.message_ts,
|
||||
last_event_type: r.last_event_type,
|
||||
destinations: serde_json::from_value(r.destinations).unwrap_or_default(),
|
||||
release_title: r.release_title,
|
||||
created_at: r.created_at.to_rfc3339(),
|
||||
updated_at: r.updated_at.to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn upsert_slack_message_ref(
|
||||
&self,
|
||||
msg_ref: &SlackMessageRef,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let iid = Uuid::parse_str(&msg_ref.integration_id)
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
let destinations_json = serde_json::to_value(&msg_ref.destinations)
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
sqlx::query(
|
||||
"INSERT INTO slack_message_refs (id, integration_id, release_id, channel_id, message_ts, last_event_type, destinations, release_title, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||
ON CONFLICT (integration_id, release_id) DO UPDATE SET
|
||||
message_ts = EXCLUDED.message_ts,
|
||||
last_event_type = EXCLUDED.last_event_type,
|
||||
destinations = EXCLUDED.destinations,
|
||||
release_title = EXCLUDED.release_title,
|
||||
updated_at = NOW()",
|
||||
)
|
||||
.bind(Uuid::parse_str(&msg_ref.id).map_err(|e| IntegrationError::Store(e.to_string()))?)
|
||||
.bind(iid)
|
||||
.bind(&msg_ref.release_id)
|
||||
.bind(&msg_ref.channel_id)
|
||||
.bind(&msg_ref.message_ts)
|
||||
.bind(&msg_ref.last_event_type)
|
||||
.bind(destinations_json)
|
||||
.bind(&msg_ref.release_title)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SlackUserLinkRow {
|
||||
id: Uuid,
|
||||
user_id: String,
|
||||
team_id: String,
|
||||
team_name: String,
|
||||
slack_user_id: String,
|
||||
slack_username: String,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SlackMessageRefRow {
|
||||
id: Uuid,
|
||||
integration_id: Uuid,
|
||||
release_id: String,
|
||||
channel_id: String,
|
||||
message_ts: String,
|
||||
last_event_type: String,
|
||||
destinations: serde_json::Value,
|
||||
release_title: String,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Slack user identity links (user-level "Sign in with Slack")
|
||||
CREATE TABLE IF NOT EXISTS slack_user_links (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
team_id TEXT NOT NULL,
|
||||
team_name TEXT NOT NULL DEFAULT '',
|
||||
slack_user_id TEXT NOT NULL,
|
||||
slack_username TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id, team_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_slack_user_links_user ON slack_user_links(user_id);
|
||||
CREATE INDEX idx_slack_user_links_team_slack ON slack_user_links(team_id, slack_user_id);
|
||||
|
||||
-- Slack message refs for update-in-place pattern (one message per release)
|
||||
CREATE TABLE IF NOT EXISTS slack_message_refs (
|
||||
id UUID PRIMARY KEY,
|
||||
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
|
||||
release_id TEXT NOT NULL,
|
||||
channel_id TEXT NOT NULL,
|
||||
message_ts TEXT NOT NULL,
|
||||
last_event_type TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (integration_id, release_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_slack_message_refs_lookup ON slack_message_refs(integration_id, release_id);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add per-destination status tracking and release title to slack_message_refs
|
||||
ALTER TABLE slack_message_refs ADD COLUMN IF NOT EXISTS destinations JSONB NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE slack_message_refs ADD COLUMN IF NOT EXISTS release_title TEXT NOT NULL DEFAULT '';
|
||||
@@ -572,6 +572,8 @@ pub struct ReleaseContext {
|
||||
/// Number of destinations involved
|
||||
#[prost(int32, tag="16")]
|
||||
pub destination_count: i32,
|
||||
#[prost(string, tag="17")]
|
||||
pub source_user_id: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct Notification {
|
||||
|
||||
@@ -69,19 +69,28 @@ impl FromRequestParts<AppState> for Session {
|
||||
.get_user(&session_data.access_token)
|
||||
.await
|
||||
{
|
||||
let orgs = state
|
||||
// Preserve existing orgs on failure — a transient gRPC error
|
||||
// should not wipe the cached org list.
|
||||
let previous_orgs = session_data
|
||||
.user
|
||||
.as_ref()
|
||||
.map(|u| u.orgs.clone())
|
||||
.unwrap_or_default();
|
||||
let orgs = match state
|
||||
.platform_client
|
||||
.list_my_organisations(&session_data.access_token)
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|o| CachedOrg {
|
||||
organisation_id: o.organisation_id,
|
||||
name: o.name,
|
||||
role: o.role,
|
||||
})
|
||||
.collect();
|
||||
{
|
||||
Ok(fresh) => fresh
|
||||
.into_iter()
|
||||
.map(|o| CachedOrg {
|
||||
organisation_id: o.organisation_id,
|
||||
name: o.name,
|
||||
role: o.role,
|
||||
})
|
||||
.collect(),
|
||||
Err(_) => previous_orgs,
|
||||
};
|
||||
session_data.user = Some(CachedUser {
|
||||
user_id: user.user_id.clone(),
|
||||
username: user.username.clone(),
|
||||
@@ -99,11 +108,46 @@ impl FromRequestParts<AppState> for Session {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Throttle last_seen_at writes: only update if older than 5 minutes
|
||||
let now = chrono::Utc::now();
|
||||
if now - session_data.last_seen_at > chrono::Duration::minutes(5) {
|
||||
session_data.last_seen_at = now;
|
||||
let _ = state.sessions.update(&session_id, session_data.clone()).await;
|
||||
// Backfill: if we have a user but empty orgs, try to fetch them.
|
||||
// This handles the case where list_my_organisations failed during login.
|
||||
let needs_org_backfill = session_data
|
||||
.user
|
||||
.as_ref()
|
||||
.is_some_and(|u| u.orgs.is_empty());
|
||||
|
||||
if needs_org_backfill {
|
||||
if let Ok(orgs) = state
|
||||
.platform_client
|
||||
.list_my_organisations(&session_data.access_token)
|
||||
.await
|
||||
{
|
||||
if !orgs.is_empty() {
|
||||
if let Some(ref mut user) = session_data.user {
|
||||
tracing::info!(
|
||||
user_id = %user.user_id,
|
||||
org_count = orgs.len(),
|
||||
"backfilled empty org list"
|
||||
);
|
||||
user.orgs = orgs
|
||||
.into_iter()
|
||||
.map(|o| CachedOrg {
|
||||
organisation_id: o.organisation_id,
|
||||
name: o.name,
|
||||
role: o.role,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
session_data.last_seen_at = chrono::Utc::now();
|
||||
let _ = state.sessions.update(&session_id, session_data.clone()).await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Throttle last_seen_at writes: only update if older than 5 minutes
|
||||
let now = chrono::Utc::now();
|
||||
if now - session_data.last_seen_at > chrono::Duration::minutes(5) {
|
||||
session_data.last_seen_at = now;
|
||||
let _ = state.sessions.update(&session_id, session_data.clone()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,45 @@ impl GrpcForestClient {
|
||||
fn authed_request<T>(access_token: &str, msg: T) -> Result<Request<T>, AuthError> {
|
||||
bearer_request(access_token, msg).map_err(AuthError::Other)
|
||||
}
|
||||
|
||||
/// Fetch release intent states using a service token (for background workers).
|
||||
pub async fn get_release_intent_states_with_token(
|
||||
&self,
|
||||
service_token: &str,
|
||||
organisation: &str,
|
||||
project: Option<&str>,
|
||||
include_completed: bool,
|
||||
) -> Result<Vec<forage_core::platform::ReleaseIntentState>, String> {
|
||||
let req = bearer_request(
|
||||
service_token,
|
||||
forage_grpc::GetReleaseIntentStatesRequest {
|
||||
organisation: organisation.into(),
|
||||
project: project.map(|p| p.into()),
|
||||
include_completed,
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("invalid token: {e}"))?;
|
||||
|
||||
let resp = self
|
||||
.release_client()
|
||||
.get_release_intent_states(req)
|
||||
.await
|
||||
.map_err(|e| format!("gRPC: {e}"))?;
|
||||
|
||||
Ok(resp
|
||||
.into_inner()
|
||||
.release_intents
|
||||
.into_iter()
|
||||
.map(|ri| forage_core::platform::ReleaseIntentState {
|
||||
release_intent_id: ri.release_intent_id,
|
||||
artifact_id: ri.artifact_id,
|
||||
project: ri.project,
|
||||
created_at: ri.created_at,
|
||||
stages: ri.stages.into_iter().map(convert_pipeline_stage_state).collect(),
|
||||
steps: ri.steps.into_iter().map(convert_release_step_state).collect(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn map_status(status: tonic::Status) -> AuthError {
|
||||
|
||||
@@ -167,13 +167,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
std::env::var("SLACK_CLIENT_ID"),
|
||||
std::env::var("SLACK_CLIENT_SECRET"),
|
||||
) {
|
||||
let base_url = std::env::var("FORAGE_BASE_URL")
|
||||
let redirect_host = std::env::var("SLACK_REDIRECT_HOST")
|
||||
.unwrap_or_else(|_| format!("http://localhost:{port}"));
|
||||
tracing::info!("Slack OAuth enabled");
|
||||
state = state.with_slack_config(crate::state::SlackConfig {
|
||||
client_id,
|
||||
client_secret,
|
||||
base_url,
|
||||
redirect_host,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -197,9 +197,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
state = state.with_integration_store(store.clone());
|
||||
|
||||
if let Ok(service_token) = std::env::var("FORAGE_SERVICE_TOKEN") {
|
||||
let forage_url = std::env::var("FORAGE_URL")
|
||||
.or_else(|_| std::env::var("SLACK_REDIRECT_HOST"))
|
||||
.unwrap_or_else(|_| format!("http://localhost:{port}"));
|
||||
|
||||
if let Some(ref js) = nats_jetstream {
|
||||
// JetStream mode: ingester publishes, consumer dispatches
|
||||
tracing::info!("starting notification pipeline (JetStream)");
|
||||
let grpc_for_consumer = forest_client.clone();
|
||||
let token_for_consumer = service_token.clone();
|
||||
mad.add(notification_ingester::NotificationIngester {
|
||||
grpc: forest_client,
|
||||
jetstream: js.clone(),
|
||||
@@ -208,6 +214,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
mad.add(notification_consumer::NotificationConsumer {
|
||||
jetstream: js.clone(),
|
||||
store: store.clone(),
|
||||
forage_url,
|
||||
grpc: grpc_for_consumer,
|
||||
service_token: token_for_consumer,
|
||||
});
|
||||
} else {
|
||||
// Fallback: direct dispatch (no durability)
|
||||
@@ -216,6 +225,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
grpc: forest_client,
|
||||
store: store.clone(),
|
||||
service_token,
|
||||
forage_url,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -10,6 +10,7 @@ use forage_core::integrations::IntegrationStore;
|
||||
use notmad::{Component, ComponentInfo, MadError};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::forest_client::GrpcForestClient;
|
||||
use crate::notification_worker::NotificationDispatcher;
|
||||
|
||||
/// Background component that pulls notification events from NATS JetStream
|
||||
@@ -17,6 +18,9 @@ use crate::notification_worker::NotificationDispatcher;
|
||||
pub struct NotificationConsumer {
|
||||
pub jetstream: jetstream::Context,
|
||||
pub store: Arc<dyn IntegrationStore>,
|
||||
pub forage_url: String,
|
||||
pub grpc: Arc<GrpcForestClient>,
|
||||
pub service_token: String,
|
||||
}
|
||||
|
||||
impl Component for NotificationConsumer {
|
||||
@@ -25,7 +29,10 @@ impl Component for NotificationConsumer {
|
||||
}
|
||||
|
||||
async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> {
|
||||
let dispatcher = Arc::new(NotificationDispatcher::new(self.store.clone()));
|
||||
let dispatcher = Arc::new(
|
||||
NotificationDispatcher::new(self.store.clone(), self.forage_url.clone())
|
||||
.with_grpc(self.grpc.clone(), self.service_token.clone()),
|
||||
);
|
||||
|
||||
let mut backoff = 1u64;
|
||||
|
||||
|
||||
@@ -15,15 +15,26 @@ use crate::forest_client::GrpcForestClient;
|
||||
pub struct NotificationDispatcher {
|
||||
http: reqwest::Client,
|
||||
store: Arc<dyn IntegrationStore>,
|
||||
forage_url: String,
|
||||
/// gRPC client for querying pipeline state (optional — absent in tests).
|
||||
grpc: Option<Arc<GrpcForestClient>>,
|
||||
/// Service token for authenticating gRPC calls to fetch pipeline state.
|
||||
service_token: String,
|
||||
}
|
||||
|
||||
impl NotificationDispatcher {
|
||||
pub fn new(store: Arc<dyn IntegrationStore>) -> Self {
|
||||
pub fn new(store: Arc<dyn IntegrationStore>, forage_url: String) -> Self {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("failed to build reqwest client");
|
||||
Self { http, store }
|
||||
Self { http, store, forage_url, grpc: None, service_token: String::new() }
|
||||
}
|
||||
|
||||
pub fn with_grpc(mut self, grpc: Arc<GrpcForestClient>, service_token: String) -> Self {
|
||||
self.grpc = Some(grpc);
|
||||
self.service_token = service_token;
|
||||
self
|
||||
}
|
||||
|
||||
/// Execute a dispatch task with retry (3 attempts, exponential backoff).
|
||||
@@ -35,8 +46,15 @@ impl NotificationDispatcher {
|
||||
..
|
||||
} => (integration_id.clone(), payload.notification_id.clone()),
|
||||
DispatchTask::Slack {
|
||||
integration_id, ..
|
||||
} => (integration_id.clone(), String::new()),
|
||||
integration_id,
|
||||
notification_id,
|
||||
..
|
||||
} => (integration_id.clone(), notification_id.clone()),
|
||||
DispatchTask::SlackDm {
|
||||
integration_id,
|
||||
notification_id,
|
||||
..
|
||||
} => (integration_id.clone(), notification_id.clone()),
|
||||
};
|
||||
|
||||
let delays = [1, 5, 25]; // seconds
|
||||
@@ -55,6 +73,26 @@ impl NotificationDispatcher {
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
// Don't retry errors that will never succeed
|
||||
let non_retryable = is_non_retryable_error(&e);
|
||||
if non_retryable {
|
||||
tracing::error!(
|
||||
integration_id = %integration_id,
|
||||
error = %e,
|
||||
"non-retryable delivery error"
|
||||
);
|
||||
let _ = self
|
||||
.store
|
||||
.record_delivery(
|
||||
&integration_id,
|
||||
¬ification_id,
|
||||
DeliveryStatus::Failed,
|
||||
Some(&e),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
integration_id = %integration_id,
|
||||
attempt = attempt + 1,
|
||||
@@ -125,38 +163,371 @@ impl NotificationDispatcher {
|
||||
}
|
||||
}
|
||||
DispatchTask::Slack {
|
||||
integration_id,
|
||||
webhook_url,
|
||||
access_token,
|
||||
channel_id,
|
||||
release_id,
|
||||
event_type,
|
||||
event,
|
||||
message,
|
||||
..
|
||||
} => {
|
||||
// Use Block Kit attachments for rich formatting
|
||||
let payload = serde_json::json!({
|
||||
"text": message.text,
|
||||
"attachments": [{
|
||||
"color": message.color,
|
||||
"blocks": message.blocks,
|
||||
}]
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(webhook_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send()
|
||||
// If we have a bot token, use chat.postMessage/chat.update for update-in-place
|
||||
if !access_token.is_empty() && !channel_id.is_empty() && !release_id.is_empty() {
|
||||
self.dispatch_slack_bot(
|
||||
integration_id,
|
||||
access_token,
|
||||
channel_id,
|
||||
release_id,
|
||||
event_type,
|
||||
event,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("slack http: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
Err(format!("Slack HTTP {status}: {body}"))
|
||||
// Fallback: webhook URL (no update-in-place possible)
|
||||
self.dispatch_slack_webhook(webhook_url, message).await
|
||||
}
|
||||
}
|
||||
DispatchTask::SlackDm {
|
||||
integration_id,
|
||||
access_token,
|
||||
slack_user_id,
|
||||
release_id,
|
||||
event_type,
|
||||
event,
|
||||
message: _,
|
||||
..
|
||||
} => {
|
||||
// DM uses the same bot token post/update pattern, but channel = user ID.
|
||||
// Prefix release_id so the message ref is distinct from channel messages.
|
||||
let dm_release_id = format!("dm:{slack_user_id}:{release_id}");
|
||||
self.dispatch_slack_bot(
|
||||
integration_id,
|
||||
access_token,
|
||||
slack_user_id, // Slack accepts user ID as channel for DMs
|
||||
&dm_release_id,
|
||||
event_type,
|
||||
event,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Post or update a Slack message via the bot token API.
|
||||
/// Merges per-destination status into the message ref and rebuilds the message.
|
||||
async fn dispatch_slack_bot(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
access_token: &str,
|
||||
channel: &str,
|
||||
release_id: &str,
|
||||
event_type: &str,
|
||||
event: &forage_core::integrations::router::NotificationEvent,
|
||||
) -> Result<(), String> {
|
||||
use forage_core::integrations::{DestinationStatus, SlackMessageRef};
|
||||
use forage_core::integrations::router::format_slack_message;
|
||||
|
||||
// Get existing ref (with accumulated destinations) if we already posted
|
||||
let existing_ref = self
|
||||
.store
|
||||
.get_slack_message_ref(integration_id, release_id)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
// Merge this notification's destination into the accumulated map
|
||||
let mut destinations = existing_ref
|
||||
.as_ref()
|
||||
.map(|r| r.destinations.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(ref r) = event.release {
|
||||
if !r.destination.is_empty() {
|
||||
let status = match event_type {
|
||||
"release_started" => "started",
|
||||
"release_succeeded" => "succeeded",
|
||||
"release_failed" => "failed",
|
||||
_ => "started",
|
||||
};
|
||||
destinations.insert(
|
||||
r.destination.clone(),
|
||||
DestinationStatus {
|
||||
environment: r.environment.clone(),
|
||||
status: status.to_string(),
|
||||
error: r.error_message.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the message with the full accumulated state
|
||||
let mut message = format_slack_message(event, &destinations, &self.forage_url);
|
||||
|
||||
// Query pipeline stages and insert before destinations
|
||||
if let Some(ref r) = event.release {
|
||||
if !r.release_intent_id.is_empty() {
|
||||
if let Some(stages) = self
|
||||
.fetch_pipeline_stages(&event.organisation, &event.project, &r.release_intent_id)
|
||||
.await
|
||||
{
|
||||
let pipeline_blocks =
|
||||
forage_core::integrations::router::format_pipeline_blocks(&stages);
|
||||
if !pipeline_blocks.is_empty() {
|
||||
// Insert pipeline before the destination section.
|
||||
// Find the last "context" block (metadata); pipeline goes right after it,
|
||||
// pushing destinations and errors down.
|
||||
let insert_at = message
|
||||
.blocks
|
||||
.iter()
|
||||
.rposition(|b| b["type"] == "context")
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(message.blocks.len());
|
||||
for (i, block) in pipeline_blocks.into_iter().enumerate() {
|
||||
message.blocks.insert(insert_at + i, block);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let release_title = event
|
||||
.release
|
||||
.as_ref()
|
||||
.filter(|r| !r.context_title.is_empty())
|
||||
.map(|r| r.context_title.clone())
|
||||
.or_else(|| existing_ref.as_ref().map(|r| r.release_title.clone()))
|
||||
.unwrap_or_else(|| event.title.clone());
|
||||
|
||||
let blocks_payload = serde_json::json!([{
|
||||
"color": message.color,
|
||||
"blocks": message.blocks,
|
||||
}]);
|
||||
|
||||
// The `text` field is a fallback for notifications/accessibility only.
|
||||
// Slack renders it above attachments in some clients, causing duplication.
|
||||
// Use a minimal fallback; the attachment blocks carry the rich content.
|
||||
let fallback_text = format!("Release update: {}/{}", event.organisation, event.project);
|
||||
|
||||
if let Some(ref msg_ref) = existing_ref {
|
||||
// Update existing message
|
||||
let payload = serde_json::json!({
|
||||
"channel": msg_ref.channel_id,
|
||||
"ts": msg_ref.message_ts,
|
||||
"text": fallback_text,
|
||||
"attachments": blocks_payload,
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post("https://slack.com/api/chat.update")
|
||||
.bearer_auth(access_token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("slack chat.update http: {e}"))?;
|
||||
|
||||
let body: serde_json::Value =
|
||||
resp.json().await.map_err(|e| format!("slack chat.update parse: {e}"))?;
|
||||
|
||||
if body["ok"].as_bool() != Some(true) {
|
||||
let err = body["error"].as_str().unwrap_or("unknown");
|
||||
if err == "message_not_found" {
|
||||
tracing::warn!(
|
||||
integration_id = %integration_id,
|
||||
release_id = %release_id,
|
||||
"slack message not found, posting new one"
|
||||
);
|
||||
// Fall through to post a new one
|
||||
} else {
|
||||
return Err(format!("slack chat.update: {err}"));
|
||||
}
|
||||
} else {
|
||||
// Update the ref with merged destinations
|
||||
let updated = SlackMessageRef {
|
||||
id: msg_ref.id.clone(),
|
||||
integration_id: integration_id.to_string(),
|
||||
release_id: release_id.to_string(),
|
||||
channel_id: msg_ref.channel_id.clone(),
|
||||
message_ts: msg_ref.message_ts.clone(),
|
||||
last_event_type: event_type.to_string(),
|
||||
destinations,
|
||||
release_title,
|
||||
created_at: msg_ref.created_at.clone(),
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
let _ = self.store.upsert_slack_message_ref(&updated).await;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Try to join the channel first
|
||||
let _ = self.slack_join_channel(access_token, channel).await;
|
||||
|
||||
// Post new message
|
||||
let payload = serde_json::json!({
|
||||
"channel": channel,
|
||||
"text": fallback_text,
|
||||
"attachments": blocks_payload,
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post("https://slack.com/api/chat.postMessage")
|
||||
.bearer_auth(access_token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("slack chat.postMessage http: {e}"))?;
|
||||
|
||||
let body: serde_json::Value =
|
||||
resp.json().await.map_err(|e| format!("slack chat.postMessage parse: {e}"))?;
|
||||
|
||||
if body["ok"].as_bool() != Some(true) {
|
||||
let err = body["error"].as_str().unwrap_or("unknown");
|
||||
return Err(format!("slack chat.postMessage: {err}"));
|
||||
}
|
||||
|
||||
// Store the message ref with initial destinations
|
||||
let ts = body["ts"].as_str().unwrap_or_default();
|
||||
let posted_channel = body["channel"].as_str().unwrap_or(channel);
|
||||
|
||||
if !ts.is_empty() && !release_id.is_empty() {
|
||||
let msg_ref = SlackMessageRef {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
integration_id: integration_id.to_string(),
|
||||
release_id: release_id.to_string(),
|
||||
channel_id: posted_channel.to_string(),
|
||||
message_ts: ts.to_string(),
|
||||
last_event_type: event_type.to_string(),
|
||||
destinations,
|
||||
release_title,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
let _ = self.store.upsert_slack_message_ref(&msg_ref).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Try to join a Slack channel. Silently succeeds if already a member or channel is private.
|
||||
async fn slack_join_channel(&self, access_token: &str, channel: &str) -> Result<(), String> {
|
||||
let payload = serde_json::json!({ "channel": channel });
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post("https://slack.com/api/conversations.join")
|
||||
.bearer_auth(access_token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("slack conversations.join http: {e}"))?;
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("slack conversations.join parse: {e}"))?;
|
||||
|
||||
if body["ok"].as_bool() == Some(true) {
|
||||
tracing::info!(channel = %channel, "bot joined slack channel");
|
||||
} else {
|
||||
let err = body["error"].as_str().unwrap_or("unknown");
|
||||
// channel_not_found, method_not_supported_for_channel_type (private), already_in_channel
|
||||
// These are all acceptable — we tried our best
|
||||
tracing::debug!(channel = %channel, error = %err, "conversations.join failed (may be private channel)");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch pipeline stages for a release intent via gRPC.
|
||||
/// Returns None if gRPC is not configured or the call fails.
|
||||
async fn fetch_pipeline_stages(
|
||||
&self,
|
||||
organisation: &str,
|
||||
project: &str,
|
||||
release_intent_id: &str,
|
||||
) -> Option<Vec<forage_core::platform::PipelineRunStageState>> {
|
||||
let grpc = self.grpc.as_ref()?;
|
||||
if self.service_token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
match grpc
|
||||
.get_release_intent_states_with_token(
|
||||
&self.service_token,
|
||||
organisation,
|
||||
Some(project),
|
||||
true, // include_completed so we get the current intent
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(intents) => {
|
||||
// Find the matching release intent
|
||||
intents
|
||||
.into_iter()
|
||||
.find(|i| i.release_intent_id == release_intent_id)
|
||||
.map(|i| i.stages)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
release_intent_id = %release_intent_id,
|
||||
error = %e,
|
||||
"failed to fetch pipeline stages"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback: post via incoming webhook URL (no update-in-place).
|
||||
async fn dispatch_slack_webhook(
|
||||
&self,
|
||||
webhook_url: &str,
|
||||
message: &forage_core::integrations::router::SlackMessage,
|
||||
) -> Result<(), String> {
|
||||
let payload = serde_json::json!({
|
||||
"text": message.text,
|
||||
"attachments": [{
|
||||
"color": message.color,
|
||||
"blocks": message.blocks,
|
||||
}]
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(webhook_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("slack http: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
Err(format!("Slack HTTP {status}: {body}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Slack API errors that will never succeed on retry.
|
||||
fn is_non_retryable_error(err: &str) -> bool {
|
||||
const NON_RETRYABLE: &[&str] = &[
|
||||
"channel_not_found",
|
||||
"not_in_channel",
|
||||
"is_archived",
|
||||
"invalid_auth",
|
||||
"token_revoked",
|
||||
"account_inactive",
|
||||
"no_permission",
|
||||
"missing_scope",
|
||||
"not_authed",
|
||||
"invalid_arguments",
|
||||
];
|
||||
NON_RETRYABLE.iter().any(|code| err.contains(code))
|
||||
}
|
||||
|
||||
// ── Proto conversion ────────────────────────────────────────────────
|
||||
@@ -174,11 +545,16 @@ pub fn proto_to_event(n: &forage_grpc::Notification) -> NotificationEvent {
|
||||
let release = n.release_context.as_ref().map(|r| ReleaseContext {
|
||||
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: if r.error_message.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -207,6 +583,8 @@ pub struct NotificationListener {
|
||||
pub store: Arc<dyn IntegrationStore>,
|
||||
/// Service token (PAT) for authenticating with forest-server's NotificationService.
|
||||
pub service_token: String,
|
||||
/// Base URL of the Forage web UI for deep links (e.g. "https://forage.example.com").
|
||||
pub forage_url: String,
|
||||
}
|
||||
|
||||
impl Component for NotificationListener {
|
||||
@@ -215,7 +593,10 @@ impl Component for NotificationListener {
|
||||
}
|
||||
|
||||
async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> {
|
||||
let dispatcher = Arc::new(NotificationDispatcher::new(self.store.clone()));
|
||||
let dispatcher = Arc::new(
|
||||
NotificationDispatcher::new(self.store.clone(), self.forage_url.clone())
|
||||
.with_grpc(self.grpc.clone(), self.service_token.clone()),
|
||||
);
|
||||
|
||||
// For now, listen on the global stream (no org filter).
|
||||
// Forest's ListenNotifications with no org filter returns all notifications
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use axum::extract::State;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Form, Router};
|
||||
use chrono::Utc;
|
||||
use forage_core::integrations::SlackUserLink;
|
||||
use minijinja::context;
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -35,6 +36,18 @@ pub fn router() -> Router<AppState> {
|
||||
"/settings/account/notifications",
|
||||
post(update_notification_preference),
|
||||
)
|
||||
.route(
|
||||
"/settings/account/slack/connect",
|
||||
get(slack_connect),
|
||||
)
|
||||
.route(
|
||||
"/settings/account/slack/callback",
|
||||
get(slack_user_callback),
|
||||
)
|
||||
.route(
|
||||
"/settings/account/slack/disconnect",
|
||||
post(slack_disconnect),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Signup ─────────────────────────────────────────────────────────
|
||||
@@ -95,34 +108,42 @@ async fn signup_submit(
|
||||
{
|
||||
Ok(tokens) => {
|
||||
// Fetch user info for the session cache
|
||||
let mut user_cache = state
|
||||
let user_cache = match state
|
||||
.forest_client
|
||||
.get_user(&tokens.access_token)
|
||||
.await
|
||||
.ok()
|
||||
.map(|u| CachedUser {
|
||||
user_id: u.user_id,
|
||||
username: u.username,
|
||||
emails: u.emails,
|
||||
orgs: vec![],
|
||||
});
|
||||
|
||||
// Cache org memberships in the session
|
||||
if let Some(ref mut user) = user_cache
|
||||
&& let Ok(orgs) = state
|
||||
.platform_client
|
||||
.list_my_organisations(&tokens.access_token)
|
||||
.await
|
||||
{
|
||||
user.orgs = orgs
|
||||
.into_iter()
|
||||
.map(|o| CachedOrg {
|
||||
organisation_id: o.organisation_id,
|
||||
name: o.name,
|
||||
role: o.role,
|
||||
Ok(u) => {
|
||||
let orgs = match state
|
||||
.platform_client
|
||||
.list_my_organisations(&tokens.access_token)
|
||||
.await
|
||||
{
|
||||
Ok(orgs) => orgs
|
||||
.into_iter()
|
||||
.map(|o| CachedOrg {
|
||||
organisation_id: o.organisation_id,
|
||||
name: o.name,
|
||||
role: o.role,
|
||||
})
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to fetch orgs during signup");
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
Some(CachedUser {
|
||||
user_id: u.user_id,
|
||||
username: u.username,
|
||||
emails: u.emails,
|
||||
orgs,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to fetch user during signup");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
let session_data = SessionData {
|
||||
@@ -247,34 +268,42 @@ async fn login_submit(
|
||||
.await
|
||||
{
|
||||
Ok(tokens) => {
|
||||
let mut user_cache = state
|
||||
let user_cache = match state
|
||||
.forest_client
|
||||
.get_user(&tokens.access_token)
|
||||
.await
|
||||
.ok()
|
||||
.map(|u| CachedUser {
|
||||
user_id: u.user_id,
|
||||
username: u.username,
|
||||
emails: u.emails,
|
||||
orgs: vec![],
|
||||
});
|
||||
|
||||
// Cache org memberships in the session
|
||||
if let Some(ref mut user) = user_cache
|
||||
&& let Ok(orgs) = state
|
||||
.platform_client
|
||||
.list_my_organisations(&tokens.access_token)
|
||||
.await
|
||||
{
|
||||
user.orgs = orgs
|
||||
.into_iter()
|
||||
.map(|o| CachedOrg {
|
||||
organisation_id: o.organisation_id,
|
||||
name: o.name,
|
||||
role: o.role,
|
||||
Ok(u) => {
|
||||
let orgs = match state
|
||||
.platform_client
|
||||
.list_my_organisations(&tokens.access_token)
|
||||
.await
|
||||
{
|
||||
Ok(orgs) => orgs
|
||||
.into_iter()
|
||||
.map(|o| CachedOrg {
|
||||
organisation_id: o.organisation_id,
|
||||
name: o.name,
|
||||
role: o.role,
|
||||
})
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to fetch orgs during login");
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
Some(CachedUser {
|
||||
user_id: u.user_id,
|
||||
username: u.username,
|
||||
emails: u.emails,
|
||||
orgs,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to fetch user during login");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
let session_data = SessionData {
|
||||
@@ -495,7 +524,17 @@ async fn account_page(
|
||||
.get_notification_preferences(&session.access_token)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
render_account(&state, &session, None, &prefs)
|
||||
|
||||
let slack_links = if let Some(store) = state.integration_store.as_ref() {
|
||||
store
|
||||
.list_slack_user_links(&session.user.user_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
render_account(&state, &session, None, &prefs, &slack_links)
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
@@ -504,6 +543,7 @@ fn render_account(
|
||||
session: &Session,
|
||||
error: Option<&str>,
|
||||
notification_prefs: &[forage_core::platform::NotificationPreference],
|
||||
slack_links: &[SlackUserLink],
|
||||
) -> Result<Response, Response> {
|
||||
let html = state
|
||||
.templates
|
||||
@@ -529,6 +569,14 @@ fn render_account(
|
||||
.filter(|p| p.enabled)
|
||||
.map(|p| format!("{}|{}", p.notification_type, p.channel))
|
||||
.collect::<Vec<_>>(),
|
||||
has_slack_oauth => state.slack_config.is_some(),
|
||||
slack_links => slack_links.iter().map(|l| context! {
|
||||
id => &l.id,
|
||||
team_id => &l.team_id,
|
||||
team_name => &l.team_name,
|
||||
slack_user_id => &l.slack_user_id,
|
||||
slack_username => &l.slack_username,
|
||||
}).collect::<Vec<_>>(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
@@ -559,7 +607,7 @@ async fn update_username_submit(
|
||||
}
|
||||
|
||||
if let Err(e) = validate_username(&form.username) {
|
||||
return render_account(&state, &session, Some(&e.0), &[]);
|
||||
return render_account(&state, &session, Some(&e.0), &[], &[]);
|
||||
}
|
||||
|
||||
match state
|
||||
@@ -581,11 +629,11 @@ async fn update_username_submit(
|
||||
Ok(Redirect::to("/settings/account").into_response())
|
||||
}
|
||||
Err(forage_core::auth::AuthError::AlreadyExists(_)) => {
|
||||
render_account(&state, &session, Some("Username is already taken."), &[])
|
||||
render_account(&state, &session, Some("Username is already taken."), &[], &[])
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("failed to update username: {e}");
|
||||
render_account(&state, &session, Some("Could not update username. Please try again."), &[])
|
||||
render_account(&state, &session, Some("Could not update username. Please try again."), &[], &[])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -613,11 +661,11 @@ async fn change_password_submit(
|
||||
}
|
||||
|
||||
if form.new_password != form.new_password_confirm {
|
||||
return render_account(&state, &session, Some("New passwords do not match."), &[]);
|
||||
return render_account(&state, &session, Some("New passwords do not match."), &[], &[]);
|
||||
}
|
||||
|
||||
if let Err(e) = validate_password(&form.new_password) {
|
||||
return render_account(&state, &session, Some(&e.0), &[]);
|
||||
return render_account(&state, &session, Some(&e.0), &[], &[]);
|
||||
}
|
||||
|
||||
match state
|
||||
@@ -632,11 +680,11 @@ async fn change_password_submit(
|
||||
{
|
||||
Ok(()) => Ok(Redirect::to("/settings/account").into_response()),
|
||||
Err(forage_core::auth::AuthError::InvalidCredentials) => {
|
||||
render_account(&state, &session, Some("Current password is incorrect."), &[])
|
||||
render_account(&state, &session, Some("Current password is incorrect."), &[], &[])
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("failed to change password: {e}");
|
||||
render_account(&state, &session, Some("Could not change password. Please try again."), &[])
|
||||
render_account(&state, &session, Some("Could not change password. Please try again."), &[], &[])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -662,7 +710,7 @@ async fn add_email_submit(
|
||||
}
|
||||
|
||||
if let Err(e) = validate_email(&form.email) {
|
||||
return render_account(&state, &session, Some(&e.0), &[]);
|
||||
return render_account(&state, &session, Some(&e.0), &[], &[]);
|
||||
}
|
||||
|
||||
match state
|
||||
@@ -687,11 +735,11 @@ async fn add_email_submit(
|
||||
Ok(Redirect::to("/settings/account").into_response())
|
||||
}
|
||||
Err(forage_core::auth::AuthError::AlreadyExists(_)) => {
|
||||
render_account(&state, &session, Some("Email is already registered."), &[])
|
||||
render_account(&state, &session, Some("Email is already registered."), &[], &[])
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("failed to add email: {e}");
|
||||
render_account(&state, &session, Some("Could not add email. Please try again."), &[])
|
||||
render_account(&state, &session, Some("Could not add email. Please try again."), &[], &[])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -736,7 +784,7 @@ async fn remove_email_submit(
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("failed to remove email: {e}");
|
||||
render_account(&state, &session, Some("Could not remove email. Please try again."), &[])
|
||||
render_account(&state, &session, Some("Could not remove email. Please try again."), &[], &[])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -780,3 +828,225 @@ async fn update_notification_preference(
|
||||
|
||||
Ok(Redirect::to("/settings/account").into_response())
|
||||
}
|
||||
|
||||
// ─── Slack user enrollment ────────────────────────────────────────────
|
||||
|
||||
async fn slack_connect(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
) -> Result<impl IntoResponse, Response> {
|
||||
let slack_config = state.slack_config.as_ref().ok_or_else(|| {
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Slack not configured",
|
||||
"Slack OAuth is not configured on this server.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let redirect_uri = format!(
|
||||
"{}/settings/account/slack/callback",
|
||||
slack_config.redirect_host
|
||||
);
|
||||
let url = format!(
|
||||
"https://slack.com/oauth/v2/authorize?client_id={}&user_scope=identity.basic&redirect_uri={}&state={}",
|
||||
urlencoding::encode(&slack_config.client_id),
|
||||
urlencoding::encode(&redirect_uri),
|
||||
urlencoding::encode(&session.user.user_id),
|
||||
);
|
||||
|
||||
Ok(Redirect::to(&url))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SlackUserCallbackQuery {
|
||||
code: Option<String>,
|
||||
state: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
async fn slack_user_callback(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Query(query): Query<SlackUserCallbackQuery>,
|
||||
) -> Result<Response, Response> {
|
||||
// Handle user-denied case
|
||||
if let Some(err) = query.error {
|
||||
tracing::warn!("Slack user OAuth denied: {err}");
|
||||
return Ok(Redirect::to("/settings/account").into_response());
|
||||
}
|
||||
|
||||
let code = query.code.ok_or_else(|| {
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid request",
|
||||
"Missing authorization code from Slack.",
|
||||
)
|
||||
})?;
|
||||
|
||||
// Verify state matches our user_id to prevent CSRF
|
||||
let state_param = query.state.unwrap_or_default();
|
||||
if state_param != session.user.user_id {
|
||||
return Err(error_page(
|
||||
&state,
|
||||
StatusCode::FORBIDDEN,
|
||||
"Invalid request",
|
||||
"State parameter mismatch. Please try connecting again.",
|
||||
));
|
||||
}
|
||||
|
||||
let slack_config = state.slack_config.as_ref().ok_or_else(|| {
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Not configured",
|
||||
"Slack OAuth is not configured.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let integration_store = state.integration_store.as_ref().ok_or_else(|| {
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Not available",
|
||||
"Slack account linking requires a database. Set DATABASE_URL to enable.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let redirect_uri = format!(
|
||||
"{}/settings/account/slack/callback",
|
||||
slack_config.redirect_host
|
||||
);
|
||||
|
||||
// Exchange the authorization code for a user access token
|
||||
let http = reqwest::Client::new();
|
||||
let token_resp = http
|
||||
.post("https://slack.com/api/oauth.v2.access")
|
||||
.form(&[
|
||||
("client_id", slack_config.client_id.as_str()),
|
||||
("client_secret", slack_config.client_secret.as_str()),
|
||||
("code", &code),
|
||||
("redirect_uri", &redirect_uri),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
internal_error(
|
||||
&state,
|
||||
"slack user oauth",
|
||||
&format!("Failed to contact Slack: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let resp_body: serde_json::Value = token_resp.json().await.map_err(|e| {
|
||||
internal_error(
|
||||
&state,
|
||||
"slack user oauth",
|
||||
&format!("Failed to parse Slack response: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
if resp_body.get("ok").and_then(|v| v.as_bool()) != Some(true) {
|
||||
let err_msg = resp_body
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown error");
|
||||
tracing::error!("Slack user OAuth error: {err_msg}");
|
||||
return Err(error_page(
|
||||
&state,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Slack error",
|
||||
&format!("Slack returned an error: {err_msg}"),
|
||||
));
|
||||
}
|
||||
|
||||
// For user-scoped OAuth, the user token is nested under authed_user
|
||||
let authed_user = resp_body.get("authed_user").ok_or_else(|| {
|
||||
internal_error(
|
||||
&state,
|
||||
"slack user oauth",
|
||||
&"Missing authed_user in Slack response",
|
||||
)
|
||||
})?;
|
||||
|
||||
let slack_user_id = authed_user["id"].as_str().unwrap_or("").to_string();
|
||||
let user_access_token = authed_user["access_token"].as_str().unwrap_or("").to_string();
|
||||
let team_id = resp_body["team"]["id"].as_str().unwrap_or("").to_string();
|
||||
let team_name = resp_body["team"]["name"].as_str().unwrap_or("").to_string();
|
||||
|
||||
// Fetch display name via users.identity (requires identity.basic user scope)
|
||||
let slack_username = if user_access_token.is_empty() {
|
||||
slack_user_id.clone()
|
||||
} else {
|
||||
let identity_name: Option<String> = async {
|
||||
let r = http
|
||||
.get("https://slack.com/api/users.identity")
|
||||
.bearer_auth(&user_access_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
let body: serde_json::Value = r.json().await.ok()?;
|
||||
let name = body["user"]["name"].as_str()?.to_string();
|
||||
if name.is_empty() { None } else { Some(name) }
|
||||
}
|
||||
.await;
|
||||
|
||||
identity_name.unwrap_or_else(|| slack_user_id.clone())
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let link = SlackUserLink {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
user_id: session.user.user_id.clone(),
|
||||
team_id,
|
||||
team_name,
|
||||
slack_user_id,
|
||||
slack_username,
|
||||
created_at: now,
|
||||
};
|
||||
|
||||
integration_store
|
||||
.upsert_slack_user_link(&link)
|
||||
.await
|
||||
.map_err(|e| internal_error(&state, "upsert slack user link", &e))?;
|
||||
|
||||
Ok(Redirect::to("/settings/account").into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SlackDisconnectForm {
|
||||
team_id: String,
|
||||
_csrf: String,
|
||||
}
|
||||
|
||||
async fn slack_disconnect(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Form(form): Form<SlackDisconnectForm>,
|
||||
) -> Result<Response, Response> {
|
||||
if !auth::validate_csrf(&session, &form._csrf) {
|
||||
return Err(error_page(
|
||||
&state,
|
||||
StatusCode::FORBIDDEN,
|
||||
"Invalid request",
|
||||
"CSRF validation failed.",
|
||||
));
|
||||
}
|
||||
|
||||
let integration_store = state.integration_store.as_ref().ok_or_else(|| {
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Not available",
|
||||
"Slack account linking requires a database.",
|
||||
)
|
||||
})?;
|
||||
|
||||
integration_store
|
||||
.delete_slack_user_link(&session.user.user_id, &form.team_id)
|
||||
.await
|
||||
.map_err(|e| internal_error(&state, "delete slack user link", &e))?;
|
||||
|
||||
Ok(Redirect::to("/settings/account").into_response())
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ pub fn router() -> Router<AppState> {
|
||||
"/orgs/{org}/settings/integrations/slack",
|
||||
post(create_slack),
|
||||
)
|
||||
.route(
|
||||
"/orgs/{org}/settings/integrations/{id}/reinstall",
|
||||
post(reinstall_slack),
|
||||
)
|
||||
.route(
|
||||
"/integrations/slack/callback",
|
||||
get(slack_oauth_callback),
|
||||
@@ -433,6 +437,7 @@ async fn integration_detail(
|
||||
created_at => &integration.created_at,
|
||||
},
|
||||
config => config_display,
|
||||
has_slack_oauth => state.slack_config.is_some(),
|
||||
rules => rules_ctx,
|
||||
deliveries => deliveries_ctx,
|
||||
test_sent => query.test.is_some(),
|
||||
@@ -575,17 +580,22 @@ async fn test_integration(
|
||||
release: Some(ReleaseContext {
|
||||
slug: "test-release".into(),
|
||||
artifact_id: "art_test".into(),
|
||||
release_intent_id: String::new(),
|
||||
destination: "staging".into(),
|
||||
environment: "staging".into(),
|
||||
source_username: session.user.username.clone(),
|
||||
source_user_id: session.user.user_id.clone(),
|
||||
commit_sha: "abc1234".into(),
|
||||
commit_branch: "main".into(),
|
||||
context_title: "Test notification from Forage".into(),
|
||||
context_web: String::new(),
|
||||
destination_count: 1,
|
||||
error_message: None,
|
||||
}),
|
||||
};
|
||||
|
||||
let tasks = forage_core::integrations::router::route_notification(&test_event, &[integration]);
|
||||
let dispatcher = NotificationDispatcher::new(Arc::clone(store));
|
||||
let dispatcher = NotificationDispatcher::new(Arc::clone(store), String::new());
|
||||
for task in &tasks {
|
||||
dispatcher.dispatch(task).await;
|
||||
}
|
||||
@@ -597,6 +607,409 @@ async fn test_integration(
|
||||
.into_response())
|
||||
}
|
||||
|
||||
// ─── Install Slack page ─────────────────────────────────────────────
|
||||
|
||||
async fn install_slack_page(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path(org): Path<String>,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, Response> {
|
||||
let cached_org = require_org_membership(&state, &session.user.orgs, &org)?;
|
||||
require_admin(&state, cached_org)?;
|
||||
require_integration_store(&state)?;
|
||||
|
||||
let slack_oauth_url = state.slack_config.as_ref().map(|sc| {
|
||||
format!(
|
||||
"https://slack.com/oauth/v2/authorize?client_id={}&scope=assistant:write,channels:join,chat:write,chat:write.public,im:history,im:read,im:write,incoming-webhook,links:read,links:write,reactions:write,users:read,users:read.email&redirect_uri={}/integrations/slack/callback&state={}",
|
||||
urlencoding::encode(&sc.client_id),
|
||||
urlencoding::encode(&sc.redirect_host),
|
||||
urlencoding::encode(&org),
|
||||
)
|
||||
});
|
||||
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/install_slack.html.jinja",
|
||||
context! {
|
||||
title => format!("Install Slack - {} - Forage", org),
|
||||
description => "Set up a Slack integration",
|
||||
user => context! {
|
||||
username => &session.user.username,
|
||||
user_id => &session.user.user_id,
|
||||
},
|
||||
current_org => &org,
|
||||
orgs => session.user.orgs.iter().map(|o| context! { name => &o.name, role => &o.role }).collect::<Vec<_>>(),
|
||||
csrf_token => &session.csrf_token,
|
||||
active_tab => "integrations",
|
||||
error => query.error,
|
||||
slack_oauth_url => slack_oauth_url,
|
||||
has_slack_oauth => state.slack_config.is_some(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| internal_error(&state, "template error", &e))?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
// ─── Create Slack (manual webhook URL fallback) ──────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateSlackForm {
|
||||
_csrf: String,
|
||||
name: String,
|
||||
webhook_url: String,
|
||||
#[serde(default)]
|
||||
channel_name: String,
|
||||
}
|
||||
|
||||
async fn create_slack(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path(org): Path<String>,
|
||||
Form(form): Form<CreateSlackForm>,
|
||||
) -> Result<Response, Response> {
|
||||
let cached_org = require_org_membership(&state, &session.user.orgs, &org)?;
|
||||
require_admin(&state, cached_org)?;
|
||||
require_integration_store(&state)?;
|
||||
validate_csrf(&session, &form._csrf)?;
|
||||
|
||||
if let Err(e) = validate_integration_name(&form.name) {
|
||||
return Ok(Redirect::to(&format!(
|
||||
"/orgs/{}/settings/integrations/install/slack?error={}",
|
||||
org,
|
||||
urlencoding::encode(&e.to_string())
|
||||
))
|
||||
.into_response());
|
||||
}
|
||||
|
||||
if let Err(e) = validate_slack_webhook_url(&form.webhook_url) {
|
||||
return Ok(Redirect::to(&format!(
|
||||
"/orgs/{}/settings/integrations/install/slack?error={}",
|
||||
org,
|
||||
urlencoding::encode(&e)
|
||||
))
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let channel = if form.channel_name.is_empty() {
|
||||
"#general".to_string()
|
||||
} else {
|
||||
form.channel_name
|
||||
};
|
||||
|
||||
let config = IntegrationConfig::Slack {
|
||||
team_id: String::new(),
|
||||
team_name: String::new(),
|
||||
channel_id: String::new(),
|
||||
channel_name: channel,
|
||||
access_token: String::new(),
|
||||
webhook_url: form.webhook_url,
|
||||
};
|
||||
|
||||
let store = state.integration_store.as_ref().unwrap();
|
||||
let created = store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: org.clone(),
|
||||
integration_type: IntegrationType::Slack,
|
||||
name: form.name,
|
||||
config,
|
||||
created_by: session.user.user_id.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| internal_error(&state, "create slack", &e))?;
|
||||
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/integration_installed.html.jinja",
|
||||
context! {
|
||||
title => format!("{} installed - Forage", created.name),
|
||||
description => "Integration installed successfully",
|
||||
user => context! {
|
||||
username => &session.user.username,
|
||||
user_id => &session.user.user_id,
|
||||
},
|
||||
current_org => &org,
|
||||
orgs => session.user.orgs.iter().map(|o| context! { name => &o.name, role => &o.role }).collect::<Vec<_>>(),
|
||||
csrf_token => &session.csrf_token,
|
||||
active_tab => "integrations",
|
||||
integration => context! {
|
||||
id => &created.id,
|
||||
name => &created.name,
|
||||
type_display => created.integration_type.display_name(),
|
||||
},
|
||||
api_token => created.api_token,
|
||||
},
|
||||
)
|
||||
.map_err(|e| internal_error(&state, "template error", &e))?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
// ─── Reinstall Slack ─────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReinstallForm {
|
||||
_csrf: String,
|
||||
}
|
||||
|
||||
async fn reinstall_slack(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path((org, id)): Path<(String, String)>,
|
||||
Form(form): Form<ReinstallForm>,
|
||||
) -> Result<Response, Response> {
|
||||
let cached_org = require_org_membership(&state, &session.user.orgs, &org)?;
|
||||
require_admin(&state, cached_org)?;
|
||||
require_integration_store(&state)?;
|
||||
validate_csrf(&session, &form._csrf)?;
|
||||
|
||||
// Verify the integration exists and is a Slack integration
|
||||
let store = state.integration_store.as_ref().unwrap();
|
||||
let integration = store.get_integration(&org, &id).await.map_err(|e| {
|
||||
error_page(
|
||||
&state,
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
"Not found",
|
||||
&format!("Integration not found: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
if integration.integration_type != IntegrationType::Slack {
|
||||
return Err(error_page(
|
||||
&state,
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"Invalid request",
|
||||
"Only Slack integrations can be reinstalled via OAuth.",
|
||||
));
|
||||
}
|
||||
|
||||
let slack_config = state.slack_config.as_ref().ok_or_else(|| {
|
||||
error_page(
|
||||
&state,
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Not configured",
|
||||
"Slack OAuth is not configured. Set SLACK_CLIENT_ID and SLACK_CLIENT_SECRET.",
|
||||
)
|
||||
})?;
|
||||
|
||||
// Encode org + integration ID in state so callback can update instead of create
|
||||
let oauth_state = format!("{}:reinstall:{}", org, id);
|
||||
let redirect_uri = format!(
|
||||
"{}/integrations/slack/callback",
|
||||
slack_config.redirect_host
|
||||
);
|
||||
let url = format!(
|
||||
"https://slack.com/oauth/v2/authorize?client_id={}&scope=assistant:write,channels:join,chat:write,chat:write.public,im:history,im:read,im:write,incoming-webhook,links:read,links:write,reactions:write,users:read,users:read.email&redirect_uri={}&state={}",
|
||||
urlencoding::encode(&slack_config.client_id),
|
||||
urlencoding::encode(&redirect_uri),
|
||||
urlencoding::encode(&oauth_state),
|
||||
);
|
||||
|
||||
Ok(Redirect::to(&url).into_response())
|
||||
}
|
||||
|
||||
// ─── Slack OAuth callback ────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SlackCallbackQuery {
|
||||
code: Option<String>,
|
||||
state: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
async fn slack_oauth_callback(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Query(query): Query<SlackCallbackQuery>,
|
||||
) -> Result<Response, Response> {
|
||||
let raw_state = query.state.ok_or_else(|| {
|
||||
error_page(
|
||||
&state,
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"Invalid request",
|
||||
"Missing state parameter from Slack callback.",
|
||||
)
|
||||
})?;
|
||||
|
||||
// Parse state: either "{org}" (new install) or "{org}:reinstall:{id}" (reinstall)
|
||||
let (org, reinstall_id) = if raw_state.contains(":reinstall:") {
|
||||
let parts: Vec<&str> = raw_state.splitn(2, ":reinstall:").collect();
|
||||
(parts[0].to_string(), Some(parts[1].to_string()))
|
||||
} else {
|
||||
(raw_state, None)
|
||||
};
|
||||
|
||||
// If Slack returned an error (user denied)
|
||||
if let Some(err) = query.error {
|
||||
let redirect_to = if let Some(ref rid) = reinstall_id {
|
||||
format!("/orgs/{}/settings/integrations/{}?error={}", org, rid, urlencoding::encode(&format!("Slack authorization denied: {err}")))
|
||||
} else {
|
||||
format!("/orgs/{}/settings/integrations/install/slack?error={}", org, urlencoding::encode(&format!("Slack authorization denied: {err}")))
|
||||
};
|
||||
return Ok(Redirect::to(&redirect_to).into_response());
|
||||
}
|
||||
|
||||
let code = query.code.ok_or_else(|| {
|
||||
error_page(
|
||||
&state,
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"Invalid request",
|
||||
"Missing authorization code from Slack.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let cached_org = require_org_membership(&state, &session.user.orgs, &org)?;
|
||||
require_admin(&state, cached_org)?;
|
||||
require_integration_store(&state)?;
|
||||
|
||||
let slack_config = state.slack_config.as_ref().ok_or_else(|| {
|
||||
error_page(
|
||||
&state,
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Not configured",
|
||||
"Slack OAuth is not configured. Set SLACK_CLIENT_ID and SLACK_CLIENT_SECRET.",
|
||||
)
|
||||
})?;
|
||||
|
||||
// Exchange code for token
|
||||
let http = reqwest::Client::new();
|
||||
let token_resp = http
|
||||
.post("https://slack.com/api/oauth.v2.access")
|
||||
.form(&[
|
||||
("client_id", slack_config.client_id.as_str()),
|
||||
("client_secret", slack_config.client_secret.as_str()),
|
||||
("code", &code),
|
||||
(
|
||||
"redirect_uri",
|
||||
&format!("{}/integrations/slack/callback", slack_config.redirect_host),
|
||||
),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
internal_error(&state, "slack oauth", &format!("Failed to contact Slack: {e}"))
|
||||
})?;
|
||||
|
||||
let resp_body: serde_json::Value = token_resp.json().await.map_err(|e| {
|
||||
internal_error(
|
||||
&state,
|
||||
"slack oauth",
|
||||
&format!("Failed to parse Slack response: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
if resp_body.get("ok").and_then(|v| v.as_bool()) != Some(true) {
|
||||
let err_msg = resp_body
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown error");
|
||||
return Ok(Redirect::to(&format!(
|
||||
"/orgs/{}/settings/integrations/install/slack?error={}",
|
||||
org,
|
||||
urlencoding::encode(&format!("Slack error: {err_msg}"))
|
||||
))
|
||||
.into_response());
|
||||
}
|
||||
|
||||
// Extract fields from Slack response
|
||||
let team_id = resp_body["team"]["id"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let team_name = resp_body["team"]["name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let access_token = resp_body["access_token"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let (channel_id, channel_name, webhook_url) =
|
||||
if let Some(wh) = resp_body.get("incoming_webhook") {
|
||||
(
|
||||
wh["channel_id"].as_str().unwrap_or("").to_string(),
|
||||
wh["channel"].as_str().unwrap_or("").to_string(),
|
||||
wh["url"].as_str().unwrap_or("").to_string(),
|
||||
)
|
||||
} else {
|
||||
(String::new(), String::new(), String::new())
|
||||
};
|
||||
|
||||
let integration_name = if channel_name.is_empty() {
|
||||
format!("Slack - {team_name}")
|
||||
} else {
|
||||
format!("Slack - {channel_name}")
|
||||
};
|
||||
|
||||
let config = IntegrationConfig::Slack {
|
||||
team_id,
|
||||
team_name,
|
||||
channel_id,
|
||||
channel_name,
|
||||
access_token,
|
||||
webhook_url,
|
||||
};
|
||||
|
||||
let store = state.integration_store.as_ref().unwrap();
|
||||
|
||||
if let Some(ref existing_id) = reinstall_id {
|
||||
// Reinstall: update existing integration's config
|
||||
store
|
||||
.update_integration_config(&org, existing_id, &integration_name, &config)
|
||||
.await
|
||||
.map_err(|e| internal_error(&state, "reinstall slack", &e))?;
|
||||
|
||||
Ok(Redirect::to(&format!(
|
||||
"/orgs/{}/settings/integrations/{}",
|
||||
org, existing_id
|
||||
))
|
||||
.into_response())
|
||||
} else {
|
||||
// New install: create integration
|
||||
let created = store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: org.clone(),
|
||||
integration_type: IntegrationType::Slack,
|
||||
name: integration_name,
|
||||
config,
|
||||
created_by: session.user.user_id.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| internal_error(&state, "create slack", &e))?;
|
||||
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/integration_installed.html.jinja",
|
||||
context! {
|
||||
title => format!("{} installed - Forage", created.name),
|
||||
description => "Integration installed successfully",
|
||||
user => context! {
|
||||
username => &session.user.username,
|
||||
user_id => &session.user.user_id,
|
||||
},
|
||||
current_org => &org,
|
||||
orgs => session.user.orgs.iter().map(|o| context! { name => &o.name, role => &o.role }).collect::<Vec<_>>(),
|
||||
csrf_token => &session.csrf_token,
|
||||
active_tab => "integrations",
|
||||
integration => context! {
|
||||
id => &created.id,
|
||||
name => &created.name,
|
||||
type_display => created.integration_type.display_name(),
|
||||
},
|
||||
api_token => created.api_token,
|
||||
},
|
||||
)
|
||||
.map_err(|e| internal_error(&state, "template error", &e))?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
fn notification_type_label(nt: &str) -> &str {
|
||||
@@ -608,3 +1021,14 @@ fn notification_type_label(nt: &str) -> &str {
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_slack_webhook_url(url: &str) -> Result<(), String> {
|
||||
if url.starts_with("https://hooks.slack.com/")
|
||||
|| url.starts_with("http://localhost")
|
||||
|| url.starts_with("http://127.0.0.1")
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Slack webhook URL must start with https://hooks.slack.com/".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use forage_core::session::SessionStore;
|
||||
pub struct SlackConfig {
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub base_url: String,
|
||||
pub redirect_host: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -4,5 +4,6 @@ mod integration_tests;
|
||||
mod nats_tests;
|
||||
mod pages_tests;
|
||||
mod platform_tests;
|
||||
mod slack_tests;
|
||||
mod token_tests;
|
||||
mod webhook_delivery_tests;
|
||||
|
||||
@@ -86,11 +86,16 @@ fn test_event(org: &str) -> NotificationEvent {
|
||||
release: Some(ReleaseContext {
|
||||
slug: "v3.0".into(),
|
||||
artifact_id: "art_nats".into(),
|
||||
release_intent_id: "ri_nats".into(),
|
||||
destination: "prod".into(),
|
||||
environment: "production".into(),
|
||||
source_username: "alice".into(),
|
||||
source_user_id: String::new(),
|
||||
commit_sha: "aabbccdd".into(),
|
||||
commit_branch: "main".into(),
|
||||
context_title: "Deploy v3.0 succeeded".into(),
|
||||
context_web: String::new(),
|
||||
destination_count: 1,
|
||||
error_message: None,
|
||||
}),
|
||||
}
|
||||
@@ -108,11 +113,16 @@ fn failed_event(org: &str) -> NotificationEvent {
|
||||
release: Some(ReleaseContext {
|
||||
slug: "v3.0".into(),
|
||||
artifact_id: "art_nats".into(),
|
||||
release_intent_id: "ri_nats".into(),
|
||||
destination: "prod".into(),
|
||||
environment: "production".into(),
|
||||
source_username: "bob".into(),
|
||||
source_user_id: String::new(),
|
||||
commit_sha: "deadbeef".into(),
|
||||
commit_branch: "hotfix".into(),
|
||||
context_title: "Deploy v3.0 failed".into(),
|
||||
context_web: String::new(),
|
||||
destination_count: 1,
|
||||
error_message: Some("OOM killed".into()),
|
||||
}),
|
||||
}
|
||||
@@ -144,7 +154,7 @@ async fn process_payload_routes_and_dispatches_to_webhook() {
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let payload = serde_json::to_vec(&envelope).unwrap();
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -170,7 +180,7 @@ async fn process_payload_skips_when_no_matching_integrations() {
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let payload = serde_json::to_vec(&envelope).unwrap();
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
let result = NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher).await;
|
||||
assert!(result.is_ok(), "should succeed with no matching integrations");
|
||||
}
|
||||
@@ -178,7 +188,7 @@ async fn process_payload_skips_when_no_matching_integrations() {
|
||||
#[tokio::test]
|
||||
async fn process_payload_rejects_invalid_json() {
|
||||
let store = Arc::new(InMemoryIntegrationStore::new());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
|
||||
let result =
|
||||
NotificationConsumer::process_payload(b"not-json", store.as_ref(), &dispatcher).await;
|
||||
@@ -219,7 +229,7 @@ async fn process_payload_respects_disabled_rules() {
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let payload = serde_json::to_vec(&envelope).unwrap();
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -285,7 +295,7 @@ async fn process_payload_dispatches_to_multiple_integrations() {
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let payload = serde_json::to_vec(&envelope).unwrap();
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -318,7 +328,7 @@ async fn process_payload_records_delivery_status() {
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let payload = serde_json::to_vec(&envelope).unwrap();
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -354,7 +364,7 @@ async fn process_payload_records_failed_delivery() {
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let payload = serde_json::to_vec(&envelope).unwrap();
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -463,7 +473,7 @@ async fn jetstream_publish_and_consume_delivers_webhook() {
|
||||
.expect("message error");
|
||||
|
||||
// Process through the consumer logic
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
NotificationConsumer::process_payload(&msg.payload, store.as_ref(), &dispatcher)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -542,7 +552,7 @@ async fn jetstream_multiple_messages_all_delivered() {
|
||||
.unwrap();
|
||||
|
||||
let mut messages = pull_consumer.messages().await.unwrap();
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
|
||||
for _ in 0..3 {
|
||||
let msg = tokio::time::timeout(Duration::from_secs(5), messages.next())
|
||||
@@ -632,7 +642,7 @@ async fn jetstream_message_for_wrong_org_skips_dispatch() {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
NotificationConsumer::process_payload(&msg.payload, store.as_ref(), &dispatcher)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
624
crates/forage-server/src/tests/slack_tests.rs
Normal file
624
crates/forage-server/src/tests/slack_tests.rs
Normal file
@@ -0,0 +1,624 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use forage_core::integrations::{
|
||||
CreateIntegrationInput, IntegrationConfig, IntegrationStore, IntegrationType,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::test_support::*;
|
||||
|
||||
fn build_app_with_integrations() -> (
|
||||
axum::Router,
|
||||
std::sync::Arc<forage_core::session::InMemorySessionStore>,
|
||||
std::sync::Arc<forage_core::integrations::InMemoryIntegrationStore>,
|
||||
) {
|
||||
let (state, sessions, integrations) =
|
||||
test_state_with_integrations(MockForestClient::new(), MockPlatformClient::new());
|
||||
let app = crate::build_router(state);
|
||||
(app, sessions, integrations)
|
||||
}
|
||||
|
||||
// ─── Install Slack page ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_slack_page_returns_200() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/integrations/install/slack")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(text.contains("Install Slack"));
|
||||
assert!(text.contains("Webhook URL"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_slack_page_returns_403_for_non_admin() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session_member(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/integrations/install/slack")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_slack_page_shows_manual_form_without_oauth() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/integrations/install/slack")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
// Should show manual webhook URL form
|
||||
assert!(text.contains("hooks.slack.com"));
|
||||
// Should NOT show "Add to Slack" button (no OAuth configured)
|
||||
assert!(!text.contains("Add to Slack"));
|
||||
}
|
||||
|
||||
// ─── Create Slack (manual webhook URL) ──────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_slack_success_shows_installed_page() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let body = "_csrf=test-csrf&name=%23deploys&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name=%23deploys";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/integrations/slack")
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(text.contains("installed"));
|
||||
assert!(text.contains("fgi_")); // API token shown
|
||||
assert!(text.contains("#deploys"));
|
||||
|
||||
// Verify it was created as Slack type
|
||||
let all = integrations.list_integrations("testorg").await.unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].name, "#deploys");
|
||||
match &all[0].config {
|
||||
IntegrationConfig::Slack { channel_name, webhook_url, .. } => {
|
||||
assert_eq!(channel_name, "#deploys");
|
||||
assert!(webhook_url.contains("hooks.slack.com"));
|
||||
}
|
||||
_ => panic!("expected Slack config"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_slack_defaults_channel_to_general() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let body = "_csrf=test-csrf&name=alerts&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name=";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/integrations/slack")
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let all = integrations.list_integrations("testorg").await.unwrap();
|
||||
match &all[0].config {
|
||||
IntegrationConfig::Slack { channel_name, .. } => {
|
||||
assert_eq!(channel_name, "#general");
|
||||
}
|
||||
_ => panic!("expected Slack config"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_slack_invalid_csrf_returns_403() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let body = "_csrf=wrong-csrf&name=%23deploys&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name=";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/integrations/slack")
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_slack_rejects_non_slack_url() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let body = "_csrf=test-csrf&name=%23deploys&webhook_url=https%3A%2F%2Fexample.com%2Fhook&channel_name=";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/integrations/slack")
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should redirect back to install page with error
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
||||
assert!(location.contains("install/slack"));
|
||||
assert!(location.contains("error="));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_slack_non_admin_returns_403() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session_member(&sessions).await;
|
||||
|
||||
let body = "_csrf=test-csrf&name=%23deploys&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name=";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/integrations/slack")
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_slack_rejects_empty_name() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let body = "_csrf=test-csrf&name=&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name=";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/integrations/slack")
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should redirect back with error
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
||||
assert!(location.contains("install/slack"));
|
||||
assert!(location.contains("error="));
|
||||
}
|
||||
|
||||
// ─── Slack integration detail ───────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn slack_integration_detail_shows_config() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let created = integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Slack,
|
||||
name: "#deploys".into(),
|
||||
config: IntegrationConfig::Slack {
|
||||
team_id: "T123".into(),
|
||||
team_name: "My Team".into(),
|
||||
channel_id: "C456".into(),
|
||||
channel_name: "#deploys".into(),
|
||||
access_token: "xoxb-test".into(),
|
||||
webhook_url: "https://hooks.slack.com/test".into(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&format!(
|
||||
"/orgs/testorg/settings/integrations/{}",
|
||||
created.id
|
||||
))
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(text.contains("#deploys"));
|
||||
assert!(text.contains("My Team"));
|
||||
assert!(text.contains("Slack"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slack_integration_detail_manual_mode_shows_webhook_url() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
// Manual mode: empty team_name
|
||||
let created = integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Slack,
|
||||
name: "manual-slack".into(),
|
||||
config: IntegrationConfig::Slack {
|
||||
team_id: String::new(),
|
||||
team_name: String::new(),
|
||||
channel_id: String::new(),
|
||||
channel_name: "#deploys".into(),
|
||||
access_token: String::new(),
|
||||
webhook_url: "https://hooks.slack.com/services/T123/B456/xyz".into(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&format!(
|
||||
"/orgs/testorg/settings/integrations/{}",
|
||||
created.id
|
||||
))
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(text.contains("hooks.slack.com"));
|
||||
}
|
||||
|
||||
// ─── Slack in integrations catalog ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn integrations_page_shows_slack_as_available() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/integrations")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
// Slack should be a clickable link to install page
|
||||
assert!(text.contains("install/slack"));
|
||||
}
|
||||
|
||||
// ─── Slack shows in installed list ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn integrations_page_shows_installed_slack() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Slack,
|
||||
name: "#alerts".into(),
|
||||
config: IntegrationConfig::Slack {
|
||||
team_id: "T123".into(),
|
||||
team_name: "Test".into(),
|
||||
channel_id: "C456".into(),
|
||||
channel_name: "#alerts".into(),
|
||||
access_token: "xoxb-test".into(),
|
||||
webhook_url: "https://hooks.slack.com/test".into(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/integrations")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(text.contains("#alerts"));
|
||||
assert!(text.contains("Slack"));
|
||||
}
|
||||
|
||||
// ─── Slack OAuth callback without session ───────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn slack_callback_without_state_returns_error() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/integrations/slack/callback?code=test-code")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slack_callback_with_error_redirects() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/integrations/slack/callback?state=testorg&error=access_denied")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
||||
assert!(location.contains("install/slack"));
|
||||
assert!(location.contains("error="));
|
||||
assert!(location.contains("access_denied"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slack_callback_without_oauth_config_returns_503() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/integrations/slack/callback?code=test-code&state=testorg")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// No SlackConfig set, so should return 503
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
// ─── Reinstall Slack ─────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn reinstall_slack_redirects_to_oauth_error_without_slack_config() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let created = integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Slack,
|
||||
name: "#deploys".into(),
|
||||
config: IntegrationConfig::Slack {
|
||||
team_id: "T123".into(),
|
||||
team_name: "My Team".into(),
|
||||
channel_id: "C456".into(),
|
||||
channel_name: "#deploys".into(),
|
||||
access_token: "xoxb-test".into(),
|
||||
webhook_url: "https://hooks.slack.com/test".into(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = format!("_csrf=test-csrf");
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&format!(
|
||||
"/orgs/testorg/settings/integrations/{}/reinstall",
|
||||
created.id
|
||||
))
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// No SlackConfig set → 503
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reinstall_slack_non_admin_returns_403() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session_member(&sessions).await;
|
||||
|
||||
let created = integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Slack,
|
||||
name: "#deploys".into(),
|
||||
config: IntegrationConfig::Slack {
|
||||
team_id: "T123".into(),
|
||||
team_name: "My Team".into(),
|
||||
channel_id: "C456".into(),
|
||||
channel_name: "#deploys".into(),
|
||||
access_token: "xoxb-test".into(),
|
||||
webhook_url: "https://hooks.slack.com/test".into(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = format!("_csrf=test-csrf");
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&format!(
|
||||
"/orgs/testorg/settings/integrations/{}/reinstall",
|
||||
created.id
|
||||
))
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reinstall_slack_invalid_csrf_returns_403() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let created = integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Slack,
|
||||
name: "#deploys".into(),
|
||||
config: IntegrationConfig::Slack {
|
||||
team_id: "T123".into(),
|
||||
team_name: "My Team".into(),
|
||||
channel_id: "C456".into(),
|
||||
channel_name: "#deploys".into(),
|
||||
access_token: "xoxb-test".into(),
|
||||
webhook_url: "https://hooks.slack.com/test".into(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = format!("_csrf=wrong-csrf");
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&format!(
|
||||
"/orgs/testorg/settings/integrations/{}/reinstall",
|
||||
created.id
|
||||
))
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
@@ -105,11 +105,16 @@ fn test_event(org: &str) -> NotificationEvent {
|
||||
release: Some(ReleaseContext {
|
||||
slug: "my-api-v2".into(),
|
||||
artifact_id: "art_abc".into(),
|
||||
release_intent_id: "ri_1".into(),
|
||||
destination: "prod-eu".into(),
|
||||
environment: "production".into(),
|
||||
source_username: "alice".into(),
|
||||
source_user_id: String::new(),
|
||||
commit_sha: "deadbeef1234567".into(),
|
||||
commit_branch: "main".into(),
|
||||
context_title: "Deploy v2.0 succeeded".into(),
|
||||
context_web: String::new(),
|
||||
destination_count: 1,
|
||||
error_message: None,
|
||||
}),
|
||||
}
|
||||
@@ -127,11 +132,16 @@ fn failed_event(org: &str) -> NotificationEvent {
|
||||
release: Some(ReleaseContext {
|
||||
slug: "my-api-v2".into(),
|
||||
artifact_id: "art_abc".into(),
|
||||
release_intent_id: "ri_2".into(),
|
||||
destination: "prod-eu".into(),
|
||||
environment: "production".into(),
|
||||
source_username: "bob".into(),
|
||||
source_user_id: String::new(),
|
||||
commit_sha: "cafebabe0000000".into(),
|
||||
commit_branch: "hotfix/fix-crash".into(),
|
||||
context_title: "Deploy v2.0 failed".into(),
|
||||
context_web: String::new(),
|
||||
destination_count: 1,
|
||||
error_message: Some("container exited with code 137".into()),
|
||||
}),
|
||||
}
|
||||
@@ -143,7 +153,7 @@ fn failed_event(org: &str) -> NotificationEvent {
|
||||
async fn dispatcher_delivers_webhook_to_http_server() {
|
||||
let (url, receiver) = start_receiver().await;
|
||||
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
|
||||
let event = test_event("testorg");
|
||||
let integration = store
|
||||
@@ -195,7 +205,7 @@ async fn dispatcher_delivers_webhook_to_http_server() {
|
||||
async fn dispatcher_signs_webhook_with_hmac() {
|
||||
let (url, receiver) = start_receiver().await;
|
||||
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
|
||||
let secret = "webhook-secret-42";
|
||||
let event = test_event("testorg");
|
||||
@@ -236,7 +246,7 @@ async fn dispatcher_signs_webhook_with_hmac() {
|
||||
async fn dispatcher_delivers_failed_event_with_error_message() {
|
||||
let (url, receiver) = start_receiver().await;
|
||||
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
|
||||
let event = failed_event("testorg");
|
||||
let integration = store
|
||||
@@ -275,7 +285,7 @@ async fn dispatcher_delivers_failed_event_with_error_message() {
|
||||
async fn dispatcher_records_successful_delivery() {
|
||||
let (url, _receiver) = start_receiver().await;
|
||||
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
|
||||
let event = test_event("testorg");
|
||||
let integration = store
|
||||
@@ -313,7 +323,7 @@ async fn dispatcher_retries_on_server_error() {
|
||||
*receiver.force_status.lock().unwrap() = Some(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
|
||||
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
|
||||
let event = test_event("testorg");
|
||||
let integration = store
|
||||
@@ -351,7 +361,7 @@ async fn dispatcher_retries_on_server_error() {
|
||||
async fn dispatcher_handles_unreachable_url() {
|
||||
// Port 1 is almost certainly not listening
|
||||
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
|
||||
let event = test_event("testorg");
|
||||
let integration = store
|
||||
@@ -421,7 +431,7 @@ async fn full_flow_event_routes_and_delivers() {
|
||||
// Should only match testorg's integration (not otherorg's)
|
||||
assert_eq!(tasks.len(), 1);
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
for task in &tasks {
|
||||
dispatcher.dispatch(task).await;
|
||||
}
|
||||
@@ -516,7 +526,7 @@ async fn disabled_rule_filters_event_type() {
|
||||
|
||||
assert_eq!(tasks.len(), 1, "release_failed should still match");
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
dispatcher.dispatch(&tasks[0]).await;
|
||||
|
||||
let deliveries = receiver.deliveries.lock().unwrap();
|
||||
@@ -566,7 +576,7 @@ async fn multiple_integrations_all_receive_same_event() {
|
||||
forage_core::integrations::router::route_notification_for_org(store.as_ref(), &event).await;
|
||||
assert_eq!(tasks.len(), 2);
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
for task in &tasks {
|
||||
dispatcher.dispatch(task).await;
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ Notification rules:
|
||||
|
||||
1. Admin clicks "Add Slack" → `POST /orgs/{org}/settings/integrations/slack` with CSRF
|
||||
2. Server generates OAuth state (CSRF + org), stores in session, redirects to:
|
||||
`https://slack.com/oauth/v2/authorize?client_id=...&scope=incoming-webhook,chat:write&redirect_uri=...&state=...`
|
||||
`https://slack.com/oauth/v2/authorize?client_id=...&scope=assistant:write,channels:join,chat:write,chat:write.public,im:history,im:read,im:write,incoming-webhook,links:read,links:write,reactions:write,users:read,users:read.email&redirect_uri=...&state=...`
|
||||
3. User authorizes in Slack
|
||||
4. Slack redirects to `GET /orgs/{org}/settings/integrations/slack/callback?code=...&state=...`
|
||||
5. Server validates state, exchanges code for access token via Slack API
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -84,6 +84,47 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Linked Slack accounts #}
|
||||
{% if has_slack_oauth %}
|
||||
<div class="mb-12">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Linked Slack accounts</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">Link your Slack identity to receive personal DMs about your releases.</p>
|
||||
|
||||
{% if slack_links %}
|
||||
<div class="space-y-2 mb-4">
|
||||
{% for link in slack_links %}
|
||||
<div class="flex items-center justify-between px-4 py-3 border border-gray-200 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-[#4A154B] rounded flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-4 h-4 text-white" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ link.team_name }}</p>
|
||||
<p class="text-xs text-gray-500">@{{ link.slack_username }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="/settings/account/slack/disconnect">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="team_id" value="{{ link.team_id }}">
|
||||
<button type="submit" class="text-xs text-red-600 hover:text-red-800">Disconnect</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a href="/settings/account/slack/connect"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50 text-gray-700">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="#4A154B" aria-hidden="true">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
|
||||
</svg>
|
||||
Add to Slack
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Notification preferences #}
|
||||
<div class="mb-12">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Notification preferences</h2>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4 mb-8">
|
||||
<div class="w-14 h-14 rounded-lg border border-gray-200 flex items-center justify-center bg-white shrink-0">
|
||||
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="#4A154B">
|
||||
<div class="w-14 h-14 rounded-lg border border-gray-200 flex items-center justify-center shrink-0">
|
||||
<svg class="w-7 h-7 text-gray-600" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -50,7 +50,7 @@
|
||||
<div class="mb-8">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">Connect with Slack</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">Click the button below to authorize Forage to post to a Slack channel. You'll choose which channel during the Slack authorization flow.</p>
|
||||
<a href="{{ slack_oauth_url }}" class="inline-flex items-center gap-3 px-5 py-3 bg-[#4A154B] text-white rounded-lg hover:bg-[#3e1240] transition-colors font-medium text-sm">
|
||||
<a href="{{ slack_oauth_url }}" class="inline-flex items-center gap-3 px-5 py-3 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors font-medium text-sm">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
|
||||
</svg>
|
||||
|
||||
@@ -31,6 +31,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{% if integration.integration_type == "slack" and has_slack_oauth is defined and has_slack_oauth %}
|
||||
<form method="POST" action="/orgs/{{ current_org }}/settings/integrations/{{ integration.id }}/reinstall" class="inline">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<button type="submit" class="px-3 py-1.5 text-sm border border-gray-300 rounded-md hover:bg-gray-50 transition-colors">
|
||||
Reinstall
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="POST" action="/orgs/{{ current_org }}/settings/integrations/{{ integration.id }}/toggle" class="inline">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="enabled" value="{{ 'false' if integration.enabled else 'true' }}">
|
||||
|
||||
@@ -82,22 +82,21 @@
|
||||
</a>
|
||||
|
||||
{# Slack #}
|
||||
<div class="border border-gray-200 rounded-lg p-5 opacity-60">
|
||||
<a href="/orgs/{{ current_org }}/settings/integrations/install/slack" class="group border border-gray-200 rounded-lg p-5 hover:border-gray-300 hover:shadow-sm transition-all">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 rounded-lg border border-gray-200 flex items-center justify-center bg-white shrink-0">
|
||||
<svg class="w-6 h-6 text-gray-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<div class="w-12 h-12 rounded-lg border border-gray-200 flex items-center justify-center shrink-0 group-hover:border-gray-300">
|
||||
<svg class="w-6 h-6 text-gray-600" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-500">Slack</span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">Coming soon</span>
|
||||
<span class="font-medium text-gray-900">Slack</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mt-1">Post deployment notifications directly to Slack channels. Rich formatting with release details, status, and quick links.</p>
|
||||
<p class="text-sm text-gray-500 mt-1">Post deployment notifications directly to Slack channels. Rich formatting with release details, status, and quick links.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{# Discord #}
|
||||
<div class="border border-gray-200 rounded-lg p-5 opacity-60">
|
||||
|
||||
Reference in New Issue
Block a user