feat: add integrations

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

View File

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

View File

@@ -97,6 +97,48 @@ pub const NOTIFICATION_TYPES: &[&str] = &[
"release_failed", "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 ───────────────────────────────────────────────────── // ── Delivery log ─────────────────────────────────────────────────────
/// Record of a notification delivery attempt. /// Record of a notification delivery attempt.
@@ -244,11 +286,60 @@ pub trait IntegrationStore: Send + Sync {
limit: usize, limit: usize,
) -> Result<Vec<NotificationDelivery>, IntegrationError>; ) -> 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. /// Look up an integration by its API token hash. Used for API authentication.
async fn get_integration_by_token_hash( async fn get_integration_by_token_hash(
&self, &self,
token_hash: &str, token_hash: &str,
) -> Result<Integration, IntegrationError>; ) -> 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 ──────────────────────────────────────────────── // ── Token generation ────────────────────────────────────────────────
@@ -319,6 +410,8 @@ pub struct InMemoryIntegrationStore {
deliveries: std::sync::Mutex<Vec<NotificationDelivery>>, deliveries: std::sync::Mutex<Vec<NotificationDelivery>>,
/// Stores token_hash -> integration_id for lookup. /// Stores token_hash -> integration_id for lookup.
token_hashes: std::sync::Mutex<HashMap<String, String>>, 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 { impl InMemoryIntegrationStore {
@@ -328,6 +421,8 @@ impl InMemoryIntegrationStore {
rules: std::sync::Mutex::new(Vec::new()), rules: std::sync::Mutex::new(Vec::new()),
deliveries: std::sync::Mutex::new(Vec::new()), deliveries: std::sync::Mutex::new(Vec::new()),
token_hashes: std::sync::Mutex::new(HashMap::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(()) 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( async fn list_rules(
&self, &self,
integration_id: &str, integration_id: &str,
@@ -564,6 +676,80 @@ impl IntegrationStore for InMemoryIntegrationStore {
.cloned() .cloned()
.ok_or(IntegrationError::NotFound(id)) .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)] #[cfg(test)]

View File

@@ -20,11 +20,21 @@ pub struct NotificationEnvelope {
pub struct ReleaseContextEnvelope { pub struct ReleaseContextEnvelope {
pub slug: String, pub slug: String,
pub artifact_id: String, pub artifact_id: String,
#[serde(default)]
pub release_intent_id: String,
pub destination: String, pub destination: String,
pub environment: String, pub environment: String,
pub source_username: String, pub source_username: String,
#[serde(default)]
pub source_user_id: String,
pub commit_sha: String, pub commit_sha: String,
pub commit_branch: 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>, pub error_message: Option<String>,
} }
@@ -41,11 +51,16 @@ impl From<&NotificationEvent> for NotificationEnvelope {
release: e.release.as_ref().map(|r| ReleaseContextEnvelope { release: e.release.as_ref().map(|r| ReleaseContextEnvelope {
slug: r.slug.clone(), slug: r.slug.clone(),
artifact_id: r.artifact_id.clone(), artifact_id: r.artifact_id.clone(),
release_intent_id: r.release_intent_id.clone(),
destination: r.destination.clone(), destination: r.destination.clone(),
environment: r.environment.clone(), environment: r.environment.clone(),
source_username: r.source_username.clone(), source_username: r.source_username.clone(),
source_user_id: r.source_user_id.clone(),
commit_sha: r.commit_sha.clone(), commit_sha: r.commit_sha.clone(),
commit_branch: r.commit_branch.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(), error_message: r.error_message.clone(),
}), }),
} }
@@ -65,11 +80,16 @@ impl From<NotificationEnvelope> for NotificationEvent {
release: e.release.map(|r| ReleaseContext { release: e.release.map(|r| ReleaseContext {
slug: r.slug, slug: r.slug,
artifact_id: r.artifact_id, artifact_id: r.artifact_id,
release_intent_id: r.release_intent_id,
destination: r.destination, destination: r.destination,
environment: r.environment, environment: r.environment,
source_username: r.source_username, source_username: r.source_username,
source_user_id: r.source_user_id,
commit_sha: r.commit_sha, commit_sha: r.commit_sha,
commit_branch: r.commit_branch, 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, error_message: r.error_message,
}), }),
} }
@@ -107,11 +127,16 @@ mod tests {
release: Some(ReleaseContext { release: Some(ReleaseContext {
slug: "v1.2.3".into(), slug: "v1.2.3".into(),
artifact_id: "art_123".into(), artifact_id: "art_123".into(),
release_intent_id: "ri_1".into(),
destination: "prod-eu".into(), destination: "prod-eu".into(),
environment: "production".into(), environment: "production".into(),
source_username: "alice".into(), source_username: "alice".into(),
source_user_id: "alice_id".into(),
commit_sha: "abc1234def".into(), commit_sha: "abc1234def".into(),
commit_branch: "main".into(), commit_branch: "main".into(),
context_title: "Release failed".into(),
context_web: String::new(),
destination_count: 3,
error_message: Some("health check timeout".into()), error_message: Some("health check timeout".into()),
}), }),
} }

View File

@@ -19,11 +19,16 @@ pub struct NotificationEvent {
pub struct ReleaseContext { pub struct ReleaseContext {
pub slug: String, pub slug: String,
pub artifact_id: String, pub artifact_id: String,
pub release_intent_id: String,
pub destination: String, pub destination: String,
pub environment: String, pub environment: String,
pub source_username: String, pub source_username: String,
pub source_user_id: String,
pub commit_sha: String, pub commit_sha: String,
pub commit_branch: String, pub commit_branch: String,
pub context_title: String,
pub context_web: String,
pub destination_count: i32,
pub error_message: Option<String>, pub error_message: Option<String>,
} }
@@ -38,9 +43,29 @@ pub enum DispatchTask {
headers: std::collections::HashMap<String, String>, headers: std::collections::HashMap<String, String>,
payload: WebhookPayload, payload: WebhookPayload,
}, },
/// Slack channel message via bot token (supports update-in-place).
/// Falls back to webhook_url if access_token is empty.
Slack { Slack {
integration_id: String, integration_id: String,
webhook_url: 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, message: SlackMessage,
}, },
} }
@@ -74,11 +99,28 @@ pub fn route_notification(
headers: headers.clone(), headers: headers.clone(),
payload: payload.clone(), payload: payload.clone(),
}, },
IntegrationConfig::Slack { webhook_url, .. } => { IntegrationConfig::Slack {
let message = format_slack_message(event); 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 { DispatchTask::Slack {
integration_id: integration.id.clone(), integration_id: integration.id.clone(),
webhook_url: webhook_url.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, message,
} }
} }
@@ -86,21 +128,105 @@ pub fn route_notification(
.collect() .collect()
} }
/// Find matching integrations and produce dispatch tasks. /// Find matching integrations and produce dispatch tasks (channel + DM).
pub async fn route_notification_for_org( pub async fn route_notification_for_org(
store: &dyn IntegrationStore, store: &dyn IntegrationStore,
event: &NotificationEvent, event: &NotificationEvent,
) -> Vec<DispatchTask> { ) -> Vec<DispatchTask> {
match store let integrations = match store
.list_matching_integrations(&event.organisation, &event.notification_type) .list_matching_integrations(&event.organisation, &event.notification_type)
.await .await
{ {
Ok(integrations) => route_notification(event, &integrations), Ok(i) => i,
Err(e) => { Err(e) => {
tracing::error!(org = %event.organisation, error = %e, "failed to list matching integrations"); 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 { 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 { /// Build a compact Slack message showing release progress across destinations.
let color = match event.notification_type.as_str() { ///
/// `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_succeeded" => "#36a64f",
"release_failed" => "#dc3545", "release_failed" => "#dc3545",
"release_started" => "#0d6efd", "release_started" => "#0d6efd",
"release_annotated" => "#6c757d", "release_annotated" => "#6c757d",
_ => "#6c757d", _ => "#6c757d",
}
} else {
aggregate_color(accumulated)
}; };
let status_emoji = match event.notification_type.as_str() { let title = &event.title;
"release_succeeded" => ":white_check_mark:",
"release_failed" => ":x:", let status_emoji = aggregate_emoji(if accumulated.is_empty() {
"release_started" => ":rocket:", &event.notification_type
"release_annotated" => ":memo:", } else {
_ => ":bell:", return format_accumulated_message(event, color, accumulated, forage_url);
}; });
// Fallback text (shown in notifications/previews) // 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(); let mut blocks: Vec<serde_json::Value> = Vec::new();
// Header // Header: emoji + title, with "View Release" button
blocks.push(serde_json::json!({ let release_url = release
"type": "header", .map(|r| build_release_url(event, r, forage_url))
"text": { .unwrap_or_default();
"type": "plain_text",
"text": event.title,
"emoji": true
}
}));
// Body section (if present) let mut header_block = serde_json::json!({
if !event.body.is_empty() {
blocks.push(serde_json::json!({
"type": "section", "type": "section",
"text": { "text": {
"type": "mrkdwn", "type": "mrkdwn",
"text": event.body "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": format!(":memo: {}", r.context_title) }
})); }));
} }
// Release metadata fields // Metadata line
if let Some(ref r) = event.release { if let Some(r) = release {
let mut fields = vec![ blocks.push(build_metadata_context(event, r, forage_url));
serde_json::json!({ blocks.push(serde_json::json!({ "type": "divider" }));
"type": "mrkdwn",
"text": format!("*Organisation*\n{}", event.organisation)
}),
serde_json::json!({
"type": "mrkdwn",
"text": format!("*Project*\n{}", event.project)
}),
];
// Single destination status
if !r.destination.is_empty() { if !r.destination.is_empty() {
fields.push(serde_json::json!({ let dest_emoji = match event.notification_type.as_str() {
"type": "mrkdwn", "release_succeeded" => ":white_check_mark:",
"text": format!("*Destination*\n`{}`", r.destination) "release_failed" => ":x:",
})); "release_started" => ":arrows_counterclockwise:",
} _ => ":bell:",
};
if !r.environment.is_empty() { let status_label = match event.notification_type.as_str() {
fields.push(serde_json::json!({ "release_succeeded" => "Deployed",
"type": "mrkdwn", "release_failed" => "Failed",
"text": format!("*Environment*\n{}", r.environment) "release_started" => "Deploying",
})); "release_annotated" => "Annotated",
} _ => "Unknown",
};
if !r.commit_sha.is_empty() { let mut dest_line = format!("{dest_emoji} `{}` {status_label}", r.destination);
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 { if let Some(ref err) = r.error_message {
dest_line.push_str(&format!(" — _{err}_"));
}
blocks.push(serde_json::json!({ blocks.push(serde_json::json!({
"type": "section", "type": "section",
"text": { "text": { "type": "mrkdwn", "text": dest_line }
"type": "mrkdwn",
"text": format!(":warning: *Error:* {}", err)
}
})); }));
} }
} }
// Context line with timestamp
blocks.push(serde_json::json!({
"type": "context",
"elements": [{
"type": "mrkdwn",
"text": format!("{} | {}", event.notification_type.replace('_', " "), event.timestamp)
}]
}));
SlackMessage { SlackMessage {
text, text,
color: color.to_string(), 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -268,11 +703,16 @@ mod tests {
release: Some(ReleaseContext { release: Some(ReleaseContext {
slug: "test-release".into(), slug: "test-release".into(),
artifact_id: "art_123".into(), artifact_id: "art_123".into(),
release_intent_id: "ri_1".into(),
destination: "prod-eu".into(), destination: "prod-eu".into(),
environment: "production".into(), environment: "production".into(),
source_username: "alice".into(), source_username: "alice".into(),
source_user_id: "alice_id".into(),
commit_sha: "abc1234def".into(), commit_sha: "abc1234def".into(),
commit_branch: "main".into(), commit_branch: "main".into(),
context_title: "Release failed".into(),
context_web: String::new(),
destination_count: 3,
error_message: Some("health check timeout".into()), error_message: Some("health check timeout".into()),
}), }),
} }
@@ -384,16 +824,211 @@ mod tests {
fn slack_message_color_success() { fn slack_message_color_success() {
let mut event = test_event(); let mut event = test_event();
event.notification_type = "release_succeeded".into(); 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"); assert_eq!(msg.color, "#36a64f");
} }
#[test] #[test]
fn slack_message_includes_error() { fn slack_message_includes_error() {
let event = test_event(); 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 // Error message is rendered in blocks, not the fallback text field
let blocks_str = serde_json::to_string(&msg.blocks).unwrap(); let blocks_str = serde_json::to_string(&msg.blocks).unwrap();
assert!(blocks_str.contains("health check timeout")); 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"));
}
} }

View File

@@ -1,6 +1,7 @@
use forage_core::integrations::{ use forage_core::integrations::{
CreateIntegrationInput, DeliveryStatus, Integration, IntegrationConfig, IntegrationError, CreateIntegrationInput, DeliveryStatus, Integration, IntegrationConfig, IntegrationError,
IntegrationStore, IntegrationType, NotificationDelivery, NotificationRule, NOTIFICATION_TYPES, IntegrationStore, IntegrationType, NotificationDelivery, NotificationRule, SlackMessageRef,
SlackUserLink, NOTIFICATION_TYPES,
}; };
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
@@ -221,6 +222,36 @@ impl IntegrationStore for PgIntegrationStore {
Ok(()) 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( async fn list_rules(
&self, &self,
integration_id: &str, integration_id: &str,
@@ -392,6 +423,189 @@ impl IntegrationStore for PgIntegrationStore {
self.row_to_integration(row) 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)] #[derive(sqlx::FromRow)]

View File

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

View File

@@ -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 '';

View File

@@ -572,6 +572,8 @@ pub struct ReleaseContext {
/// Number of destinations involved /// Number of destinations involved
#[prost(int32, tag="16")] #[prost(int32, tag="16")]
pub destination_count: i32, pub destination_count: i32,
#[prost(string, tag="17")]
pub source_user_id: ::prost::alloc::string::String,
} }
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct Notification { pub struct Notification {

View File

@@ -69,19 +69,28 @@ impl FromRequestParts<AppState> for Session {
.get_user(&session_data.access_token) .get_user(&session_data.access_token)
.await .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 .platform_client
.list_my_organisations(&session_data.access_token) .list_my_organisations(&session_data.access_token)
.await .await
.ok() {
.unwrap_or_default() Ok(fresh) => fresh
.into_iter() .into_iter()
.map(|o| CachedOrg { .map(|o| CachedOrg {
organisation_id: o.organisation_id, organisation_id: o.organisation_id,
name: o.name, name: o.name,
role: o.role, role: o.role,
}) })
.collect(); .collect(),
Err(_) => previous_orgs,
};
session_data.user = Some(CachedUser { session_data.user = Some(CachedUser {
user_id: user.user_id.clone(), user_id: user.user_id.clone(),
username: user.username.clone(), username: user.username.clone(),
@@ -98,6 +107,40 @@ impl FromRequestParts<AppState> for Session {
return Err(axum::response::Redirect::to("/login")); return Err(axum::response::Redirect::to("/login"));
} }
} }
} else {
// 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 { } else {
// Throttle last_seen_at writes: only update if older than 5 minutes // Throttle last_seen_at writes: only update if older than 5 minutes
let now = chrono::Utc::now(); let now = chrono::Utc::now();
@@ -106,6 +149,7 @@ impl FromRequestParts<AppState> for Session {
let _ = state.sessions.update(&session_id, session_data.clone()).await; let _ = state.sessions.update(&session_id, session_data.clone()).await;
} }
} }
}
let user = session_data let user = session_data
.user .user

View File

@@ -98,6 +98,45 @@ impl GrpcForestClient {
fn authed_request<T>(access_token: &str, msg: T) -> Result<Request<T>, AuthError> { fn authed_request<T>(access_token: &str, msg: T) -> Result<Request<T>, AuthError> {
bearer_request(access_token, msg).map_err(AuthError::Other) 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 { fn map_status(status: tonic::Status) -> AuthError {

View File

@@ -167,13 +167,13 @@ async fn main() -> anyhow::Result<()> {
std::env::var("SLACK_CLIENT_ID"), std::env::var("SLACK_CLIENT_ID"),
std::env::var("SLACK_CLIENT_SECRET"), 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}")); .unwrap_or_else(|_| format!("http://localhost:{port}"));
tracing::info!("Slack OAuth enabled"); tracing::info!("Slack OAuth enabled");
state = state.with_slack_config(crate::state::SlackConfig { state = state.with_slack_config(crate::state::SlackConfig {
client_id, client_id,
client_secret, client_secret,
base_url, redirect_host,
}); });
} }
@@ -197,9 +197,15 @@ async fn main() -> anyhow::Result<()> {
state = state.with_integration_store(store.clone()); state = state.with_integration_store(store.clone());
if let Ok(service_token) = std::env::var("FORAGE_SERVICE_TOKEN") { 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 { if let Some(ref js) = nats_jetstream {
// JetStream mode: ingester publishes, consumer dispatches // JetStream mode: ingester publishes, consumer dispatches
tracing::info!("starting notification pipeline (JetStream)"); 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 { mad.add(notification_ingester::NotificationIngester {
grpc: forest_client, grpc: forest_client,
jetstream: js.clone(), jetstream: js.clone(),
@@ -208,6 +214,9 @@ async fn main() -> anyhow::Result<()> {
mad.add(notification_consumer::NotificationConsumer { mad.add(notification_consumer::NotificationConsumer {
jetstream: js.clone(), jetstream: js.clone(),
store: store.clone(), store: store.clone(),
forage_url,
grpc: grpc_for_consumer,
service_token: token_for_consumer,
}); });
} else { } else {
// Fallback: direct dispatch (no durability) // Fallback: direct dispatch (no durability)
@@ -216,6 +225,7 @@ async fn main() -> anyhow::Result<()> {
grpc: forest_client, grpc: forest_client,
store: store.clone(), store: store.clone(),
service_token, service_token,
forage_url,
}); });
} }
} else { } else {

View File

@@ -10,6 +10,7 @@ use forage_core::integrations::IntegrationStore;
use notmad::{Component, ComponentInfo, MadError}; use notmad::{Component, ComponentInfo, MadError};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::forest_client::GrpcForestClient;
use crate::notification_worker::NotificationDispatcher; use crate::notification_worker::NotificationDispatcher;
/// Background component that pulls notification events from NATS JetStream /// Background component that pulls notification events from NATS JetStream
@@ -17,6 +18,9 @@ use crate::notification_worker::NotificationDispatcher;
pub struct NotificationConsumer { pub struct NotificationConsumer {
pub jetstream: jetstream::Context, pub jetstream: jetstream::Context,
pub store: Arc<dyn IntegrationStore>, pub store: Arc<dyn IntegrationStore>,
pub forage_url: String,
pub grpc: Arc<GrpcForestClient>,
pub service_token: String,
} }
impl Component for NotificationConsumer { impl Component for NotificationConsumer {
@@ -25,7 +29,10 @@ impl Component for NotificationConsumer {
} }
async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> { 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; let mut backoff = 1u64;

View File

@@ -15,15 +15,26 @@ use crate::forest_client::GrpcForestClient;
pub struct NotificationDispatcher { pub struct NotificationDispatcher {
http: reqwest::Client, http: reqwest::Client,
store: Arc<dyn IntegrationStore>, 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 { 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() let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(10))
.build() .build()
.expect("failed to build reqwest client"); .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). /// Execute a dispatch task with retry (3 attempts, exponential backoff).
@@ -35,8 +46,15 @@ impl NotificationDispatcher {
.. ..
} => (integration_id.clone(), payload.notification_id.clone()), } => (integration_id.clone(), payload.notification_id.clone()),
DispatchTask::Slack { DispatchTask::Slack {
integration_id, .. integration_id,
} => (integration_id.clone(), String::new()), 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 let delays = [1, 5, 25]; // seconds
@@ -55,6 +73,26 @@ impl NotificationDispatcher {
return; return;
} }
Err(e) => { 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,
&notification_id,
DeliveryStatus::Failed,
Some(&e),
)
.await;
return;
}
tracing::warn!( tracing::warn!(
integration_id = %integration_id, integration_id = %integration_id,
attempt = attempt + 1, attempt = attempt + 1,
@@ -125,11 +163,329 @@ impl NotificationDispatcher {
} }
} }
DispatchTask::Slack { DispatchTask::Slack {
integration_id,
webhook_url, webhook_url,
access_token,
channel_id,
release_id,
event_type,
event,
message, message,
.. ..
} => { } => {
// Use Block Kit attachments for rich formatting // 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
} else {
// 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!({ let payload = serde_json::json!({
"text": message.text, "text": message.text,
"attachments": [{ "attachments": [{
@@ -155,8 +511,23 @@ impl NotificationDispatcher {
Err(format!("Slack HTTP {status}: {body}")) 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 ──────────────────────────────────────────────── // ── 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 { let release = n.release_context.as_ref().map(|r| ReleaseContext {
slug: r.slug.clone(), slug: r.slug.clone(),
artifact_id: r.artifact_id.clone(), artifact_id: r.artifact_id.clone(),
release_intent_id: r.release_intent_id.clone(),
destination: r.destination.clone(), destination: r.destination.clone(),
environment: r.environment.clone(), environment: r.environment.clone(),
source_username: r.source_username.clone(), source_username: r.source_username.clone(),
source_user_id: r.source_user_id.clone(),
commit_sha: r.commit_sha.clone(), commit_sha: r.commit_sha.clone(),
commit_branch: r.commit_branch.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() { error_message: if r.error_message.is_empty() {
None None
} else { } else {
@@ -207,6 +583,8 @@ pub struct NotificationListener {
pub store: Arc<dyn IntegrationStore>, pub store: Arc<dyn IntegrationStore>,
/// Service token (PAT) for authenticating with forest-server's NotificationService. /// Service token (PAT) for authenticating with forest-server's NotificationService.
pub service_token: String, 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 { impl Component for NotificationListener {
@@ -215,7 +593,10 @@ impl Component for NotificationListener {
} }
async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> { 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). // For now, listen on the global stream (no org filter).
// Forest's ListenNotifications with no org filter returns all notifications // Forest's ListenNotifications with no org filter returns all notifications

View File

@@ -1,9 +1,10 @@
use axum::extract::State; use axum::extract::{Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Redirect, Response}; use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::{Form, Router}; use axum::{Form, Router};
use chrono::Utc; use chrono::Utc;
use forage_core::integrations::SlackUserLink;
use minijinja::context; use minijinja::context;
use serde::Deserialize; use serde::Deserialize;
@@ -35,6 +36,18 @@ pub fn router() -> Router<AppState> {
"/settings/account/notifications", "/settings/account/notifications",
post(update_notification_preference), 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 ───────────────────────────────────────────────────────── // ─── Signup ─────────────────────────────────────────────────────────
@@ -95,34 +108,42 @@ async fn signup_submit(
{ {
Ok(tokens) => { Ok(tokens) => {
// Fetch user info for the session cache // Fetch user info for the session cache
let mut user_cache = state let user_cache = match state
.forest_client .forest_client
.get_user(&tokens.access_token) .get_user(&tokens.access_token)
.await .await
.ok() {
.map(|u| CachedUser { Ok(u) => {
user_id: u.user_id, let orgs = match state
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 .platform_client
.list_my_organisations(&tokens.access_token) .list_my_organisations(&tokens.access_token)
.await .await
{ {
user.orgs = orgs Ok(orgs) => orgs
.into_iter() .into_iter()
.map(|o| CachedOrg { .map(|o| CachedOrg {
organisation_id: o.organisation_id, organisation_id: o.organisation_id,
name: o.name, name: o.name,
role: o.role, role: o.role,
}) })
.collect(); .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,
})
}
Err(e) => {
tracing::warn!(error = %e, "failed to fetch user during signup");
None
}
};
let now = Utc::now(); let now = Utc::now();
let session_data = SessionData { let session_data = SessionData {
@@ -247,34 +268,42 @@ async fn login_submit(
.await .await
{ {
Ok(tokens) => { Ok(tokens) => {
let mut user_cache = state let user_cache = match state
.forest_client .forest_client
.get_user(&tokens.access_token) .get_user(&tokens.access_token)
.await .await
.ok() {
.map(|u| CachedUser { Ok(u) => {
user_id: u.user_id, let orgs = match state
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 .platform_client
.list_my_organisations(&tokens.access_token) .list_my_organisations(&tokens.access_token)
.await .await
{ {
user.orgs = orgs Ok(orgs) => orgs
.into_iter() .into_iter()
.map(|o| CachedOrg { .map(|o| CachedOrg {
organisation_id: o.organisation_id, organisation_id: o.organisation_id,
name: o.name, name: o.name,
role: o.role, role: o.role,
}) })
.collect(); .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,
})
}
Err(e) => {
tracing::warn!(error = %e, "failed to fetch user during login");
None
}
};
let now = Utc::now(); let now = Utc::now();
let session_data = SessionData { let session_data = SessionData {
@@ -495,7 +524,17 @@ async fn account_page(
.get_notification_preferences(&session.access_token) .get_notification_preferences(&session.access_token)
.await .await
.unwrap_or_default(); .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)] #[allow(clippy::result_large_err)]
@@ -504,6 +543,7 @@ fn render_account(
session: &Session, session: &Session,
error: Option<&str>, error: Option<&str>,
notification_prefs: &[forage_core::platform::NotificationPreference], notification_prefs: &[forage_core::platform::NotificationPreference],
slack_links: &[SlackUserLink],
) -> Result<Response, Response> { ) -> Result<Response, Response> {
let html = state let html = state
.templates .templates
@@ -529,6 +569,14 @@ fn render_account(
.filter(|p| p.enabled) .filter(|p| p.enabled)
.map(|p| format!("{}|{}", p.notification_type, p.channel)) .map(|p| format!("{}|{}", p.notification_type, p.channel))
.collect::<Vec<_>>(), .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| { .map_err(|e| {
@@ -559,7 +607,7 @@ async fn update_username_submit(
} }
if let Err(e) = validate_username(&form.username) { 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 match state
@@ -581,11 +629,11 @@ async fn update_username_submit(
Ok(Redirect::to("/settings/account").into_response()) Ok(Redirect::to("/settings/account").into_response())
} }
Err(forage_core::auth::AuthError::AlreadyExists(_)) => { 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) => { Err(e) => {
tracing::error!("failed to update username: {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 { 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) { 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 match state
@@ -632,11 +680,11 @@ async fn change_password_submit(
{ {
Ok(()) => Ok(Redirect::to("/settings/account").into_response()), Ok(()) => Ok(Redirect::to("/settings/account").into_response()),
Err(forage_core::auth::AuthError::InvalidCredentials) => { 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) => { Err(e) => {
tracing::error!("failed to change password: {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) { 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 match state
@@ -687,11 +735,11 @@ async fn add_email_submit(
Ok(Redirect::to("/settings/account").into_response()) Ok(Redirect::to("/settings/account").into_response())
} }
Err(forage_core::auth::AuthError::AlreadyExists(_)) => { 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) => { Err(e) => {
tracing::error!("failed to add email: {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) => { Err(e) => {
tracing::error!("failed to remove email: {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()) 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())
}

View File

@@ -61,6 +61,10 @@ pub fn router() -> Router<AppState> {
"/orgs/{org}/settings/integrations/slack", "/orgs/{org}/settings/integrations/slack",
post(create_slack), post(create_slack),
) )
.route(
"/orgs/{org}/settings/integrations/{id}/reinstall",
post(reinstall_slack),
)
.route( .route(
"/integrations/slack/callback", "/integrations/slack/callback",
get(slack_oauth_callback), get(slack_oauth_callback),
@@ -433,6 +437,7 @@ async fn integration_detail(
created_at => &integration.created_at, created_at => &integration.created_at,
}, },
config => config_display, config => config_display,
has_slack_oauth => state.slack_config.is_some(),
rules => rules_ctx, rules => rules_ctx,
deliveries => deliveries_ctx, deliveries => deliveries_ctx,
test_sent => query.test.is_some(), test_sent => query.test.is_some(),
@@ -575,17 +580,22 @@ async fn test_integration(
release: Some(ReleaseContext { release: Some(ReleaseContext {
slug: "test-release".into(), slug: "test-release".into(),
artifact_id: "art_test".into(), artifact_id: "art_test".into(),
release_intent_id: String::new(),
destination: "staging".into(), destination: "staging".into(),
environment: "staging".into(), environment: "staging".into(),
source_username: session.user.username.clone(), source_username: session.user.username.clone(),
source_user_id: session.user.user_id.clone(),
commit_sha: "abc1234".into(), commit_sha: "abc1234".into(),
commit_branch: "main".into(), commit_branch: "main".into(),
context_title: "Test notification from Forage".into(),
context_web: String::new(),
destination_count: 1,
error_message: None, error_message: None,
}), }),
}; };
let tasks = forage_core::integrations::router::route_notification(&test_event, &[integration]); 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 { for task in &tasks {
dispatcher.dispatch(task).await; dispatcher.dispatch(task).await;
} }
@@ -597,6 +607,409 @@ async fn test_integration(
.into_response()) .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 ──────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────
fn notification_type_label(nt: &str) -> &str { fn notification_type_label(nt: &str) -> &str {
@@ -608,3 +1021,14 @@ fn notification_type_label(nt: &str) -> &str {
other => other, 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())
}
}

View File

@@ -12,7 +12,7 @@ use forage_core::session::SessionStore;
pub struct SlackConfig { pub struct SlackConfig {
pub client_id: String, pub client_id: String,
pub client_secret: String, pub client_secret: String,
pub base_url: String, pub redirect_host: String,
} }
#[derive(Clone)] #[derive(Clone)]

View File

@@ -4,5 +4,6 @@ mod integration_tests;
mod nats_tests; mod nats_tests;
mod pages_tests; mod pages_tests;
mod platform_tests; mod platform_tests;
mod slack_tests;
mod token_tests; mod token_tests;
mod webhook_delivery_tests; mod webhook_delivery_tests;

View File

@@ -86,11 +86,16 @@ fn test_event(org: &str) -> NotificationEvent {
release: Some(ReleaseContext { release: Some(ReleaseContext {
slug: "v3.0".into(), slug: "v3.0".into(),
artifact_id: "art_nats".into(), artifact_id: "art_nats".into(),
release_intent_id: "ri_nats".into(),
destination: "prod".into(), destination: "prod".into(),
environment: "production".into(), environment: "production".into(),
source_username: "alice".into(), source_username: "alice".into(),
source_user_id: String::new(),
commit_sha: "aabbccdd".into(), commit_sha: "aabbccdd".into(),
commit_branch: "main".into(), commit_branch: "main".into(),
context_title: "Deploy v3.0 succeeded".into(),
context_web: String::new(),
destination_count: 1,
error_message: None, error_message: None,
}), }),
} }
@@ -108,11 +113,16 @@ fn failed_event(org: &str) -> NotificationEvent {
release: Some(ReleaseContext { release: Some(ReleaseContext {
slug: "v3.0".into(), slug: "v3.0".into(),
artifact_id: "art_nats".into(), artifact_id: "art_nats".into(),
release_intent_id: "ri_nats".into(),
destination: "prod".into(), destination: "prod".into(),
environment: "production".into(), environment: "production".into(),
source_username: "bob".into(), source_username: "bob".into(),
source_user_id: String::new(),
commit_sha: "deadbeef".into(), commit_sha: "deadbeef".into(),
commit_branch: "hotfix".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()), 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 envelope = NotificationEnvelope::from(&event);
let payload = serde_json::to_vec(&envelope).unwrap(); 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) NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
.await .await
.unwrap(); .unwrap();
@@ -170,7 +180,7 @@ async fn process_payload_skips_when_no_matching_integrations() {
let envelope = NotificationEnvelope::from(&event); let envelope = NotificationEnvelope::from(&event);
let payload = serde_json::to_vec(&envelope).unwrap(); 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; let result = NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher).await;
assert!(result.is_ok(), "should succeed with no matching integrations"); 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] #[tokio::test]
async fn process_payload_rejects_invalid_json() { async fn process_payload_rejects_invalid_json() {
let store = Arc::new(InMemoryIntegrationStore::new()); let store = Arc::new(InMemoryIntegrationStore::new());
let dispatcher = NotificationDispatcher::new(store.clone()); let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
let result = let result =
NotificationConsumer::process_payload(b"not-json", store.as_ref(), &dispatcher).await; 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 envelope = NotificationEnvelope::from(&event);
let payload = serde_json::to_vec(&envelope).unwrap(); 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) NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
.await .await
.unwrap(); .unwrap();
@@ -285,7 +295,7 @@ async fn process_payload_dispatches_to_multiple_integrations() {
let envelope = NotificationEnvelope::from(&event); let envelope = NotificationEnvelope::from(&event);
let payload = serde_json::to_vec(&envelope).unwrap(); 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) NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
.await .await
.unwrap(); .unwrap();
@@ -318,7 +328,7 @@ async fn process_payload_records_delivery_status() {
let envelope = NotificationEnvelope::from(&event); let envelope = NotificationEnvelope::from(&event);
let payload = serde_json::to_vec(&envelope).unwrap(); 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) NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
.await .await
.unwrap(); .unwrap();
@@ -354,7 +364,7 @@ async fn process_payload_records_failed_delivery() {
let envelope = NotificationEnvelope::from(&event); let envelope = NotificationEnvelope::from(&event);
let payload = serde_json::to_vec(&envelope).unwrap(); 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) NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
.await .await
.unwrap(); .unwrap();
@@ -463,7 +473,7 @@ async fn jetstream_publish_and_consume_delivers_webhook() {
.expect("message error"); .expect("message error");
// Process through the consumer logic // 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) NotificationConsumer::process_payload(&msg.payload, store.as_ref(), &dispatcher)
.await .await
.unwrap(); .unwrap();
@@ -542,7 +552,7 @@ async fn jetstream_multiple_messages_all_delivered() {
.unwrap(); .unwrap();
let mut messages = pull_consumer.messages().await.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 { for _ in 0..3 {
let msg = tokio::time::timeout(Duration::from_secs(5), messages.next()) 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()
.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) NotificationConsumer::process_payload(&msg.payload, store.as_ref(), &dispatcher)
.await .await
.unwrap(); .unwrap();

View 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);
}

View File

@@ -105,11 +105,16 @@ fn test_event(org: &str) -> NotificationEvent {
release: Some(ReleaseContext { release: Some(ReleaseContext {
slug: "my-api-v2".into(), slug: "my-api-v2".into(),
artifact_id: "art_abc".into(), artifact_id: "art_abc".into(),
release_intent_id: "ri_1".into(),
destination: "prod-eu".into(), destination: "prod-eu".into(),
environment: "production".into(), environment: "production".into(),
source_username: "alice".into(), source_username: "alice".into(),
source_user_id: String::new(),
commit_sha: "deadbeef1234567".into(), commit_sha: "deadbeef1234567".into(),
commit_branch: "main".into(), commit_branch: "main".into(),
context_title: "Deploy v2.0 succeeded".into(),
context_web: String::new(),
destination_count: 1,
error_message: None, error_message: None,
}), }),
} }
@@ -127,11 +132,16 @@ fn failed_event(org: &str) -> NotificationEvent {
release: Some(ReleaseContext { release: Some(ReleaseContext {
slug: "my-api-v2".into(), slug: "my-api-v2".into(),
artifact_id: "art_abc".into(), artifact_id: "art_abc".into(),
release_intent_id: "ri_2".into(),
destination: "prod-eu".into(), destination: "prod-eu".into(),
environment: "production".into(), environment: "production".into(),
source_username: "bob".into(), source_username: "bob".into(),
source_user_id: String::new(),
commit_sha: "cafebabe0000000".into(), commit_sha: "cafebabe0000000".into(),
commit_branch: "hotfix/fix-crash".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()), 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() { async fn dispatcher_delivers_webhook_to_http_server() {
let (url, receiver) = start_receiver().await; let (url, receiver) = start_receiver().await;
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new()); 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 event = test_event("testorg");
let integration = store let integration = store
@@ -195,7 +205,7 @@ async fn dispatcher_delivers_webhook_to_http_server() {
async fn dispatcher_signs_webhook_with_hmac() { async fn dispatcher_signs_webhook_with_hmac() {
let (url, receiver) = start_receiver().await; let (url, receiver) = start_receiver().await;
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new()); 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 secret = "webhook-secret-42";
let event = test_event("testorg"); 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() { async fn dispatcher_delivers_failed_event_with_error_message() {
let (url, receiver) = start_receiver().await; let (url, receiver) = start_receiver().await;
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new()); 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 event = failed_event("testorg");
let integration = store let integration = store
@@ -275,7 +285,7 @@ async fn dispatcher_delivers_failed_event_with_error_message() {
async fn dispatcher_records_successful_delivery() { async fn dispatcher_records_successful_delivery() {
let (url, _receiver) = start_receiver().await; let (url, _receiver) = start_receiver().await;
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new()); 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 event = test_event("testorg");
let integration = store let integration = store
@@ -313,7 +323,7 @@ async fn dispatcher_retries_on_server_error() {
*receiver.force_status.lock().unwrap() = Some(StatusCode::INTERNAL_SERVER_ERROR); *receiver.force_status.lock().unwrap() = Some(StatusCode::INTERNAL_SERVER_ERROR);
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new()); 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 event = test_event("testorg");
let integration = store let integration = store
@@ -351,7 +361,7 @@ async fn dispatcher_retries_on_server_error() {
async fn dispatcher_handles_unreachable_url() { async fn dispatcher_handles_unreachable_url() {
// Port 1 is almost certainly not listening // Port 1 is almost certainly not listening
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new()); 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 event = test_event("testorg");
let integration = store let integration = store
@@ -421,7 +431,7 @@ async fn full_flow_event_routes_and_delivers() {
// Should only match testorg's integration (not otherorg's) // Should only match testorg's integration (not otherorg's)
assert_eq!(tasks.len(), 1); assert_eq!(tasks.len(), 1);
let dispatcher = NotificationDispatcher::new(store.clone()); let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
for task in &tasks { for task in &tasks {
dispatcher.dispatch(task).await; 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"); 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; dispatcher.dispatch(&tasks[0]).await;
let deliveries = receiver.deliveries.lock().unwrap(); 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; forage_core::integrations::router::route_notification_for_org(store.as_ref(), &event).await;
assert_eq!(tasks.len(), 2); assert_eq!(tasks.len(), 2);
let dispatcher = NotificationDispatcher::new(store.clone()); let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
for task in &tasks { for task in &tasks {
dispatcher.dispatch(task).await; dispatcher.dispatch(task).await;
} }

View File

@@ -238,7 +238,7 @@ Notification rules:
1. Admin clicks "Add Slack" → `POST /orgs/{org}/settings/integrations/slack` with CSRF 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: 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 3. User authorizes in Slack
4. Slack redirects to `GET /orgs/{org}/settings/integrations/slack/callback?code=...&state=...` 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 5. Server validates state, exchanges code for access token via Slack API

File diff suppressed because one or more lines are too long

View File

@@ -84,6 +84,47 @@
</form> </form>
</div> </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 #} {# Notification preferences #}
<div class="mb-12"> <div class="mb-12">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Notification preferences</h2> <h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Notification preferences</h2>

View File

@@ -7,8 +7,8 @@
</div> </div>
<div class="flex items-start gap-4 mb-8"> <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"> <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" viewBox="0 0 24 24" fill="#4A154B"> <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"/> <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> </svg>
</div> </div>
@@ -50,7 +50,7 @@
<div class="mb-8"> <div class="mb-8">
<h3 class="text-sm font-semibold text-gray-700 mb-3">Connect with Slack</h3> <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> <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"> <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"/> <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> </svg>

View File

@@ -31,6 +31,14 @@
</div> </div>
</div> </div>
<div class="flex items-center gap-2 shrink-0"> <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"> <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="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="enabled" value="{{ 'false' if integration.enabled else 'true' }}"> <input type="hidden" name="enabled" value="{{ 'false' if integration.enabled else 'true' }}">

View File

@@ -82,22 +82,21 @@
</a> </a>
{# Slack #} {# 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="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"> <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-400" viewBox="0 0 24 24" fill="currentColor"> <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"/> <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> </svg>
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium text-gray-500">Slack</span> <span class="font-medium text-gray-900">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>
</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>
</div> </div>
<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> </div>
</a>
{# Discord #} {# Discord #}
<div class="border border-gray-200 rounded-lg p-5 opacity-60"> <div class="border border-gray-200 rounded-lg p-5 opacity-60">