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",
];
// ── Slack user links ─────────────────────────────────────────────────
/// Links a Forage user to their Slack identity in a workspace.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlackUserLink {
pub id: String,
pub user_id: String, // Forage/Forest user ID
pub team_id: String, // Slack workspace ID
pub team_name: String, // Slack workspace name (display)
pub slack_user_id: String, // Slack user ID (U-xxx)
pub slack_username: String, // Slack display name
pub created_at: String,
}
/// Per-destination deployment status within a release.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DestinationStatus {
pub environment: String,
pub status: String, // "started", "succeeded", "failed"
pub error: Option<String>,
}
/// Tracks a posted Slack message so we can update it in-place.
/// One ref per (integration, release_slug) — accumulates all destinations.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlackMessageRef {
pub id: String,
pub integration_id: String,
pub release_id: String, // release slug (shared across destinations)
pub channel_id: String, // Slack channel where posted
pub message_ts: String, // Slack message timestamp (for chat.update)
pub last_event_type: String, // Last event that updated this message
/// Accumulated per-destination statuses. Key = destination name.
#[serde(default)]
pub destinations: HashMap<String, DestinationStatus>,
/// Cached release title for message rebuilds.
#[serde(default)]
pub release_title: String,
pub created_at: String,
pub updated_at: String,
}
// ── Delivery log ─────────────────────────────────────────────────────
/// Record of a notification delivery attempt.
@@ -244,11 +286,60 @@ pub trait IntegrationStore: Send + Sync {
limit: usize,
) -> Result<Vec<NotificationDelivery>, IntegrationError>;
/// Update the configuration (and optionally the name) of an existing integration.
async fn update_integration_config(
&self,
organisation: &str,
id: &str,
name: &str,
config: &IntegrationConfig,
) -> Result<(), IntegrationError>;
/// Look up an integration by its API token hash. Used for API authentication.
async fn get_integration_by_token_hash(
&self,
token_hash: &str,
) -> Result<Integration, IntegrationError>;
// ── Slack user links ──────────────────────────────────────────────
/// Get the Slack user link for a Forage user in a given workspace, if any.
async fn get_slack_user_link(
&self,
user_id: &str,
team_id: &str,
) -> Result<Option<SlackUserLink>, IntegrationError>;
/// Create or update the Slack user link for a given (user_id, team_id) pair.
async fn upsert_slack_user_link(&self, link: &SlackUserLink) -> Result<(), IntegrationError>;
/// Remove the Slack user link for a given (user_id, team_id) pair.
async fn delete_slack_user_link(
&self,
user_id: &str,
team_id: &str,
) -> Result<(), IntegrationError>;
/// List all Slack user links for a Forage user (one per connected workspace).
async fn list_slack_user_links(
&self,
user_id: &str,
) -> Result<Vec<SlackUserLink>, IntegrationError>;
// ── Slack message refs ────────────────────────────────────────────
/// Get the Slack message ref for a release in a specific integration, if any.
async fn get_slack_message_ref(
&self,
integration_id: &str,
release_id: &str,
) -> Result<Option<SlackMessageRef>, IntegrationError>;
/// Create or update the Slack message ref for a given (integration_id, release_id) pair.
async fn upsert_slack_message_ref(
&self,
msg_ref: &SlackMessageRef,
) -> Result<(), IntegrationError>;
}
// ── Token generation ────────────────────────────────────────────────
@@ -319,6 +410,8 @@ pub struct InMemoryIntegrationStore {
deliveries: std::sync::Mutex<Vec<NotificationDelivery>>,
/// Stores token_hash -> integration_id for lookup.
token_hashes: std::sync::Mutex<HashMap<String, String>>,
slack_user_links: std::sync::Mutex<Vec<SlackUserLink>>,
slack_message_refs: std::sync::Mutex<Vec<SlackMessageRef>>,
}
impl InMemoryIntegrationStore {
@@ -328,6 +421,8 @@ impl InMemoryIntegrationStore {
rules: std::sync::Mutex::new(Vec::new()),
deliveries: std::sync::Mutex::new(Vec::new()),
token_hashes: std::sync::Mutex::new(HashMap::new()),
slack_user_links: std::sync::Mutex::new(Vec::new()),
slack_message_refs: std::sync::Mutex::new(Vec::new()),
}
}
}
@@ -453,6 +548,23 @@ impl IntegrationStore for InMemoryIntegrationStore {
Ok(())
}
async fn update_integration_config(
&self,
organisation: &str,
id: &str,
name: &str,
config: &IntegrationConfig,
) -> Result<(), IntegrationError> {
let mut store = self.integrations.lock().unwrap();
let integ = store
.iter_mut()
.find(|i| i.id == id && i.organisation == organisation)
.ok_or_else(|| IntegrationError::NotFound(id.to_string()))?;
integ.name = name.to_string();
integ.config = config.clone();
Ok(())
}
async fn list_rules(
&self,
integration_id: &str,
@@ -564,6 +676,80 @@ impl IntegrationStore for InMemoryIntegrationStore {
.cloned()
.ok_or(IntegrationError::NotFound(id))
}
async fn get_slack_user_link(
&self,
user_id: &str,
team_id: &str,
) -> Result<Option<SlackUserLink>, IntegrationError> {
let links = self.slack_user_links.lock().unwrap();
Ok(links
.iter()
.find(|l| l.user_id == user_id && l.team_id == team_id)
.cloned())
}
async fn upsert_slack_user_link(&self, link: &SlackUserLink) -> Result<(), IntegrationError> {
let mut links = self.slack_user_links.lock().unwrap();
if let Some(existing) = links
.iter_mut()
.find(|l| l.user_id == link.user_id && l.team_id == link.team_id)
{
*existing = link.clone();
} else {
links.push(link.clone());
}
Ok(())
}
async fn delete_slack_user_link(
&self,
user_id: &str,
team_id: &str,
) -> Result<(), IntegrationError> {
let mut links = self.slack_user_links.lock().unwrap();
links.retain(|l| !(l.user_id == user_id && l.team_id == team_id));
Ok(())
}
async fn list_slack_user_links(
&self,
user_id: &str,
) -> Result<Vec<SlackUserLink>, IntegrationError> {
let links = self.slack_user_links.lock().unwrap();
Ok(links
.iter()
.filter(|l| l.user_id == user_id)
.cloned()
.collect())
}
async fn get_slack_message_ref(
&self,
integration_id: &str,
release_id: &str,
) -> Result<Option<SlackMessageRef>, IntegrationError> {
let refs = self.slack_message_refs.lock().unwrap();
Ok(refs
.iter()
.find(|r| r.integration_id == integration_id && r.release_id == release_id)
.cloned())
}
async fn upsert_slack_message_ref(
&self,
msg_ref: &SlackMessageRef,
) -> Result<(), IntegrationError> {
let mut refs = self.slack_message_refs.lock().unwrap();
if let Some(existing) = refs.iter_mut().find(|r| {
r.integration_id == msg_ref.integration_id && r.release_id == msg_ref.release_id
}) {
*existing = msg_ref.clone();
} else {
refs.push(msg_ref.clone());
}
Ok(())
}
}
#[cfg(test)]

View File

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

View File

@@ -19,11 +19,16 @@ pub struct NotificationEvent {
pub struct ReleaseContext {
pub slug: String,
pub artifact_id: String,
pub release_intent_id: String,
pub destination: String,
pub environment: String,
pub source_username: String,
pub source_user_id: String,
pub commit_sha: String,
pub commit_branch: String,
pub context_title: String,
pub context_web: String,
pub destination_count: i32,
pub error_message: Option<String>,
}
@@ -38,9 +43,29 @@ pub enum DispatchTask {
headers: std::collections::HashMap<String, String>,
payload: WebhookPayload,
},
/// Slack channel message via bot token (supports update-in-place).
/// Falls back to webhook_url if access_token is empty.
Slack {
integration_id: String,
webhook_url: String,
access_token: String,
channel_id: String,
release_id: String,
notification_id: String,
event_type: String,
/// The full event, needed to rebuild the message after merging destination state.
event: NotificationEvent,
message: SlackMessage,
},
/// Personal DM to a user who linked their Slack account.
SlackDm {
integration_id: String,
access_token: String,
slack_user_id: String,
release_id: String,
notification_id: String,
event_type: String,
event: NotificationEvent,
message: SlackMessage,
},
}
@@ -74,11 +99,28 @@ pub fn route_notification(
headers: headers.clone(),
payload: payload.clone(),
},
IntegrationConfig::Slack { webhook_url, .. } => {
let message = format_slack_message(event);
IntegrationConfig::Slack {
webhook_url,
access_token,
channel_id,
..
} => {
let message = format_slack_message(event, &std::collections::HashMap::new(), "");
// Group by release slug (shared across all destinations in a release)
let release_id = event
.release
.as_ref()
.map(|r| r.slug.clone())
.unwrap_or_default();
DispatchTask::Slack {
integration_id: integration.id.clone(),
webhook_url: webhook_url.clone(),
access_token: access_token.clone(),
channel_id: channel_id.clone(),
release_id,
notification_id: event.id.clone(),
event_type: event.notification_type.clone(),
event: event.clone(),
message,
}
}
@@ -86,21 +128,105 @@ pub fn route_notification(
.collect()
}
/// Find matching integrations and produce dispatch tasks.
/// Find matching integrations and produce dispatch tasks (channel + DM).
pub async fn route_notification_for_org(
store: &dyn IntegrationStore,
event: &NotificationEvent,
) -> Vec<DispatchTask> {
match store
let integrations = match store
.list_matching_integrations(&event.organisation, &event.notification_type)
.await
{
Ok(integrations) => route_notification(event, &integrations),
Ok(i) => i,
Err(e) => {
tracing::error!(org = %event.organisation, error = %e, "failed to list matching integrations");
vec![]
return vec![];
}
};
let mut tasks = route_notification(event, &integrations);
// Produce personal DM tasks for the release owner (if they linked Slack)
if let Some(release) = &event.release {
tracing::debug!(
source_user_id = %release.source_user_id,
source_username = %release.source_username,
"DM routing: checking release owner"
);
}
// Only DM on actual deploy events, not bare annotations
let dm_event_types = ["release_started", "release_succeeded", "release_failed"];
if let Some(release) = event.release.as_ref().filter(|r| {
!r.source_user_id.is_empty() && dm_event_types.contains(&event.notification_type.as_str())
}) {
let slack_count = integrations.iter().filter(|i| matches!(&i.config, IntegrationConfig::Slack { .. })).count();
tracing::debug!(
total_integrations = integrations.len(),
slack_integrations = slack_count,
"DM routing: iterating integrations for DM lookup"
);
// For each Slack integration with a bot token, check if the author linked that workspace
for integration in &integrations {
if let IntegrationConfig::Slack {
team_id,
access_token,
..
} = &integration.config
{
tracing::debug!(
integration_id = %integration.id,
team_id = %team_id,
has_token = !access_token.is_empty(),
"DM routing: checking slack integration"
);
if access_token.is_empty() || team_id.is_empty() {
continue; // manual webhook, no bot token
}
// Look up the release author's Slack link for this workspace
match store
.get_slack_user_link(&release.source_user_id, team_id)
.await
{
Ok(Some(link)) => {
tracing::info!(
user_id = %release.source_user_id,
team_id = %team_id,
slack_user_id = %link.slack_user_id,
"DM routing: found slack link, creating DM task"
);
let message = format_slack_message(event, &std::collections::HashMap::new(), "");
tasks.push(DispatchTask::SlackDm {
integration_id: integration.id.clone(),
access_token: access_token.clone(),
slack_user_id: link.slack_user_id,
release_id: release.slug.clone(),
notification_id: event.id.clone(),
event_type: event.notification_type.clone(),
event: event.clone(),
message,
});
}
Ok(None) => {
tracing::debug!(
user_id = %release.source_user_id,
team_id = %team_id,
"DM routing: no slack link found for user in this workspace"
);
}
Err(e) => {
tracing::warn!(
user = %release.source_user_id,
team_id = %team_id,
error = %e,
"failed to look up slack user link for DM"
);
}
}
}
}
}
tasks
}
fn build_webhook_payload(event: &NotificationEvent) -> WebhookPayload {
@@ -125,125 +251,100 @@ fn build_webhook_payload(event: &NotificationEvent) -> WebhookPayload {
}
}
fn format_slack_message(event: &NotificationEvent) -> SlackMessage {
let color = match event.notification_type.as_str() {
"release_succeeded" => "#36a64f",
"release_failed" => "#dc3545",
"release_started" => "#0d6efd",
"release_annotated" => "#6c757d",
_ => "#6c757d",
/// Build a compact Slack message showing release progress across destinations.
///
/// `forage_url` is the base URL for deep links (e.g. "https://client.dev.forage.sh").
/// When `accumulated` is non-empty, renders all known destination statuses.
/// When empty (first message or webhook fallback), shows just the current event's destination.
pub fn format_slack_message(
event: &NotificationEvent,
accumulated: &std::collections::HashMap<String, super::DestinationStatus>,
forage_url: &str,
) -> SlackMessage {
let release = event.release.as_ref();
// Determine aggregate color from accumulated destinations
let color = if accumulated.is_empty() {
match event.notification_type.as_str() {
"release_succeeded" => "#36a64f",
"release_failed" => "#dc3545",
"release_started" => "#0d6efd",
"release_annotated" => "#6c757d",
_ => "#6c757d",
}
} else {
aggregate_color(accumulated)
};
let status_emoji = match event.notification_type.as_str() {
"release_succeeded" => ":white_check_mark:",
"release_failed" => ":x:",
"release_started" => ":rocket:",
"release_annotated" => ":memo:",
_ => ":bell:",
};
let title = &event.title;
let status_emoji = aggregate_emoji(if accumulated.is_empty() {
&event.notification_type
} else {
return format_accumulated_message(event, color, accumulated, forage_url);
});
// Fallback text (shown in notifications/previews)
let text = format!("{} {}", status_emoji, event.title);
let text = format!("{status_emoji} {title}");
// Build Block Kit blocks
let mut blocks: Vec<serde_json::Value> = Vec::new();
// Header
blocks.push(serde_json::json!({
"type": "header",
"text": {
"type": "plain_text",
"text": event.title,
"emoji": true
}
}));
// Header: emoji + title, with "View Release" button
let release_url = release
.map(|r| build_release_url(event, r, forage_url))
.unwrap_or_default();
// Body section (if present)
if !event.body.is_empty() {
let mut header_block = serde_json::json!({
"type": "section",
"text": {
"type": "mrkdwn",
"text": format!("{status_emoji} *{title}*")
}
});
if !release_url.is_empty() {
header_block["accessory"] = build_view_button(&release_url);
}
blocks.push(header_block);
// Commit/change title
if let Some(r) = release.filter(|r| !r.context_title.is_empty()) {
blocks.push(serde_json::json!({
"type": "section",
"text": {
"type": "mrkdwn",
"text": event.body
}
"text": { "type": "mrkdwn", "text": format!(":memo: {}", r.context_title) }
}));
}
// Release metadata fields
if let Some(ref r) = event.release {
let mut fields = vec![
serde_json::json!({
"type": "mrkdwn",
"text": format!("*Organisation*\n{}", event.organisation)
}),
serde_json::json!({
"type": "mrkdwn",
"text": format!("*Project*\n{}", event.project)
}),
];
// Metadata line
if let Some(r) = release {
blocks.push(build_metadata_context(event, r, forage_url));
blocks.push(serde_json::json!({ "type": "divider" }));
// Single destination status
if !r.destination.is_empty() {
fields.push(serde_json::json!({
"type": "mrkdwn",
"text": format!("*Destination*\n`{}`", r.destination)
}));
}
if !r.environment.is_empty() {
fields.push(serde_json::json!({
"type": "mrkdwn",
"text": format!("*Environment*\n{}", r.environment)
}));
}
if !r.commit_sha.is_empty() {
let short_sha = &r.commit_sha[..r.commit_sha.len().min(7)];
fields.push(serde_json::json!({
"type": "mrkdwn",
"text": format!("*Commit*\n`{}`", short_sha)
}));
}
if !r.commit_branch.is_empty() {
fields.push(serde_json::json!({
"type": "mrkdwn",
"text": format!("*Branch*\n`{}`", r.commit_branch)
}));
}
if !r.source_username.is_empty() {
fields.push(serde_json::json!({
"type": "mrkdwn",
"text": format!("*Author*\n{}", r.source_username)
}));
}
blocks.push(serde_json::json!({
"type": "section",
"fields": fields
}));
// Error message (if any)
if let Some(ref err) = r.error_message {
let dest_emoji = match event.notification_type.as_str() {
"release_succeeded" => ":white_check_mark:",
"release_failed" => ":x:",
"release_started" => ":arrows_counterclockwise:",
_ => ":bell:",
};
let status_label = match event.notification_type.as_str() {
"release_succeeded" => "Deployed",
"release_failed" => "Failed",
"release_started" => "Deploying",
"release_annotated" => "Annotated",
_ => "Unknown",
};
let mut dest_line = format!("{dest_emoji} `{}` {status_label}", r.destination);
if let Some(ref err) = r.error_message {
dest_line.push_str(&format!(" — _{err}_"));
}
blocks.push(serde_json::json!({
"type": "section",
"text": {
"type": "mrkdwn",
"text": format!(":warning: *Error:* {}", err)
}
"text": { "type": "mrkdwn", "text": dest_line }
}));
}
}
// Context line with timestamp
blocks.push(serde_json::json!({
"type": "context",
"elements": [{
"type": "mrkdwn",
"text": format!("{} | {}", event.notification_type.replace('_', " "), event.timestamp)
}]
}));
SlackMessage {
text,
color: color.to_string(),
@@ -251,6 +352,340 @@ fn format_slack_message(event: &NotificationEvent) -> SlackMessage {
}
}
/// Render the full multi-destination message from accumulated state.
fn format_accumulated_message(
event: &NotificationEvent,
color: &str,
destinations: &std::collections::HashMap<String, super::DestinationStatus>,
forage_url: &str,
) -> SlackMessage {
let release = event.release.as_ref();
let title = &event.title;
let emoji = aggregate_emoji_from_destinations(destinations);
let text = format!("{emoji} {title}");
let mut blocks: Vec<serde_json::Value> = Vec::new();
// Header with "View Release" button
let release_url = release
.map(|r| build_release_url(event, r, forage_url))
.unwrap_or_default();
let mut header_block = serde_json::json!({
"type": "section",
"text": { "type": "mrkdwn", "text": format!("{emoji} *{title}*") }
});
if !release_url.is_empty() {
header_block["accessory"] = build_view_button(&release_url);
}
blocks.push(header_block);
// Commit/change title (e.g. "fix: correct timezone handling in cron scheduler (#80)")
if let Some(r) = release.filter(|r| !r.context_title.is_empty()) {
blocks.push(serde_json::json!({
"type": "section",
"text": { "type": "mrkdwn", "text": format!(":memo: {}", r.context_title) }
}));
}
// Metadata
if let Some(r) = release {
blocks.push(build_metadata_context(event, r, forage_url));
}
blocks.push(serde_json::json!({ "type": "divider" }));
// Destination progress line
let done = destinations
.values()
.filter(|d| d.status == "succeeded" || d.status == "failed")
.count();
let total = destinations.len();
// Compact destination line: ✅ dev ✅ staging 🔄 prod (2/3)
let mut dest_parts: Vec<String> = Vec::new();
let mut sorted_dests: Vec<_> = destinations.iter().collect();
sorted_dests.sort_by_key(|(name, _)| name.as_str());
for (name, status) in &sorted_dests {
let emoji = match status.status.as_str() {
"succeeded" => ":white_check_mark:",
"failed" => ":x:",
"started" => ":arrows_counterclockwise:",
_ => ":hourglass:",
};
dest_parts.push(format!("{emoji} {name}"));
}
let dest_count = format!(" ({done}/{total})");
blocks.push(serde_json::json!({
"type": "section",
"text": { "type": "mrkdwn", "text": format!("{}{dest_count}", dest_parts.join(" ")) }
}));
// Show errors inline if any failed
for (name, status) in &sorted_dests {
if let Some(ref err) = status.error {
blocks.push(serde_json::json!({
"type": "context",
"elements": [{ "type": "mrkdwn", "text": format!(":warning: *{name}:* {err}") }]
}));
}
}
SlackMessage {
text,
color: color.to_string(),
blocks,
}
}
/// Build the metadata context block shared by both message formats.
fn build_metadata_context(
event: &NotificationEvent,
r: &ReleaseContext,
_forage_url: &str,
) -> serde_json::Value {
let mut parts = Vec::new();
parts.push(format!("*{}* / {}", event.project, r.slug));
if !r.commit_branch.is_empty() {
parts.push(format!("`{}`", r.commit_branch));
}
if !r.commit_sha.is_empty() {
parts.push(format!("`{}`", &r.commit_sha[..r.commit_sha.len().min(7)]));
}
if !r.source_username.is_empty() {
parts.push(r.source_username.clone());
}
// Source link (e.g. GitHub commit/PR)
if !r.context_web.is_empty() {
parts.push(format!("<{}|source>", r.context_web));
}
serde_json::json!({
"type": "context",
"elements": [{ "type": "mrkdwn", "text": parts.join(" · ") }]
})
}
/// Build the release URL for deep linking.
fn build_release_url(
event: &NotificationEvent,
r: &ReleaseContext,
forage_url: &str,
) -> String {
if forage_url.is_empty() {
return String::new();
}
format!(
"{}/orgs/{}/projects/{}/releases/{}",
forage_url.trim_end_matches('/'),
event.organisation,
event.project,
r.slug,
)
}
/// Build a "View Release" button block for the header section accessory.
fn build_view_button(url: &str) -> serde_json::Value {
serde_json::json!({
"type": "button",
"text": { "type": "plain_text", "text": "View Release" },
"url": url,
})
}
/// Build Slack blocks for pipeline stage progress.
/// Renders each stage as a line: emoji + label (e.g. "✅ Deployed to `dev`", "⏳ Wait 5s").
/// Stages are topologically sorted by `depends_on` to match pipeline execution order.
pub fn format_pipeline_blocks(
stages: &[crate::platform::PipelineRunStageState],
) -> Vec<serde_json::Value> {
if stages.is_empty() {
return Vec::new();
}
// Topological sort by depends_on
let sorted = topo_sort_stages(stages);
let mut lines: Vec<String> = Vec::new();
for stage in &sorted {
let emoji = match stage.status.as_str() {
"SUCCEEDED" => ":white_check_mark:",
"RUNNING" => ":arrows_counterclockwise:",
"FAILED" => ":x:",
"CANCELLED" => ":no_entry_sign:",
_ => ":radio_button:", // PENDING
};
let label = match stage.stage_type.as_str() {
"deploy" => {
let env = stage.environment.as_deref().unwrap_or("unknown");
match stage.status.as_str() {
"SUCCEEDED" => format!("Deployed to `{env}`"),
"RUNNING" => format!("Deploying to `{env}`"),
"FAILED" => format!("Deploy to `{env}` failed"),
_ => format!("Deploy to `{env}`"),
}
}
"wait" => {
let duration = stage.duration_seconds.unwrap_or(0);
let dur_str = if duration >= 60 {
format!("{}m", duration / 60)
} else {
format!("{duration}s")
};
match stage.status.as_str() {
"SUCCEEDED" => format!("Waited {dur_str}"),
"RUNNING" => format!("Waiting {dur_str}"),
_ => format!("Wait {dur_str}"),
}
}
_ => format!("Stage {}", stage.stage_id),
};
let mut line = format!("{emoji} {label}");
if let Some(ref err) = stage.error_message {
line.push_str(&format!(" — _{err}_"));
}
lines.push(line);
}
let done = stages
.iter()
.filter(|s| s.status == "SUCCEEDED" || s.status == "FAILED" || s.status == "CANCELLED")
.count();
let total = stages.len();
let mut blocks = Vec::new();
// Pipeline header + stages
blocks.push(serde_json::json!({
"type": "section",
"text": { "type": "mrkdwn", "text": lines.join("\n") }
}));
// Progress count
blocks.push(serde_json::json!({
"type": "context",
"elements": [{ "type": "mrkdwn", "text": format!("{done}/{total} stages complete") }]
}));
// Divider before destinations
blocks.push(serde_json::json!({ "type": "divider" }));
blocks
}
/// Topological sort of pipeline stages by `depends_on`.
/// Falls back to input order if the graph has issues.
fn topo_sort_stages(
stages: &[crate::platform::PipelineRunStageState],
) -> Vec<crate::platform::PipelineRunStageState> {
use std::collections::{HashMap, VecDeque};
// Build in-degree map
let mut in_degree: HashMap<&str, usize> = HashMap::new();
let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new();
for s in stages {
in_degree.entry(s.stage_id.as_str()).or_insert(0);
for dep in &s.depends_on {
dependents
.entry(dep.as_str())
.or_default()
.push(s.stage_id.as_str());
*in_degree.entry(s.stage_id.as_str()).or_insert(0) += 1;
}
}
// Kahn's algorithm
let mut queue: VecDeque<&str> = in_degree
.iter()
.filter(|&(_, deg)| *deg == 0)
.map(|(&id, _)| id)
.collect();
let mut sorted_ids: Vec<String> = Vec::with_capacity(stages.len());
while let Some(id) = queue.pop_front() {
sorted_ids.push(id.to_string());
if let Some(deps) = dependents.get(id) {
for &dep in deps {
if let Some(deg) = in_degree.get_mut(dep) {
*deg -= 1;
if *deg == 0 {
queue.push_back(dep);
}
}
}
}
}
// Build result in sorted order, falling back to input order for missing
let id_to_idx: HashMap<&str, usize> = sorted_ids
.iter()
.enumerate()
.map(|(i, id)| (id.as_str(), i))
.collect();
let mut indexed: Vec<(usize, &crate::platform::PipelineRunStageState)> = stages
.iter()
.map(|s| {
let idx = id_to_idx
.get(s.stage_id.as_str())
.copied()
.unwrap_or(usize::MAX);
(idx, s)
})
.collect();
indexed.sort_by_key(|(idx, _)| *idx);
indexed.into_iter().map(|(_, s)| s.clone()).collect()
}
fn aggregate_color(
destinations: &std::collections::HashMap<String, super::DestinationStatus>,
) -> &'static str {
let has_failed = destinations.values().any(|d| d.status == "failed");
let has_started = destinations.values().any(|d| d.status == "started");
if has_failed {
"#dc3545" // red
} else if has_started {
"#0d6efd" // blue (still in progress)
} else {
"#36a64f" // green (all done)
}
}
fn aggregate_emoji(event_type: &str) -> &'static str {
match event_type {
"release_succeeded" => ":white_check_mark:",
"release_failed" => ":x:",
"release_started" => ":rocket:",
"release_annotated" => ":memo:",
_ => ":bell:",
}
}
fn aggregate_emoji_from_destinations(
destinations: &std::collections::HashMap<String, super::DestinationStatus>,
) -> &'static str {
let has_failed = destinations.values().any(|d| d.status == "failed");
let has_started = destinations.values().any(|d| d.status == "started");
if has_failed {
":x:"
} else if has_started {
":rocket:"
} else {
":white_check_mark:"
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -268,11 +703,16 @@ mod tests {
release: Some(ReleaseContext {
slug: "test-release".into(),
artifact_id: "art_123".into(),
release_intent_id: "ri_1".into(),
destination: "prod-eu".into(),
environment: "production".into(),
source_username: "alice".into(),
source_user_id: "alice_id".into(),
commit_sha: "abc1234def".into(),
commit_branch: "main".into(),
context_title: "Release failed".into(),
context_web: String::new(),
destination_count: 3,
error_message: Some("health check timeout".into()),
}),
}
@@ -384,16 +824,211 @@ mod tests {
fn slack_message_color_success() {
let mut event = test_event();
event.notification_type = "release_succeeded".into();
let msg = format_slack_message(&event);
let msg = format_slack_message(&event, &HashMap::new(), "");
assert_eq!(msg.color, "#36a64f");
}
#[test]
fn slack_message_includes_error() {
let event = test_event();
let msg = format_slack_message(&event);
let msg = format_slack_message(&event, &HashMap::new(), "");
// Error message is rendered in blocks, not the fallback text field
let blocks_str = serde_json::to_string(&msg.blocks).unwrap();
assert!(blocks_str.contains("health check timeout"));
}
#[test]
fn slack_message_accumulated_shows_all_destinations() {
let event = test_event();
let mut dests = HashMap::new();
dests.insert("prod-eu".into(), super::super::DestinationStatus {
environment: "production".into(),
status: "succeeded".into(),
error: None,
});
dests.insert("staging".into(), super::super::DestinationStatus {
environment: "staging".into(),
status: "started".into(),
error: None,
});
let msg = format_slack_message(&event, &dests, "");
// Should be blue (still deploying)
assert_eq!(msg.color, "#0d6efd");
let blocks_str = serde_json::to_string(&msg.blocks).unwrap();
assert!(blocks_str.contains("prod-eu"));
assert!(blocks_str.contains("staging"));
}
#[test]
fn slack_message_accumulated_all_succeeded() {
let mut event = test_event();
// Set destination_count to match the 2 destinations we provide
if let Some(ref mut r) = event.release {
r.destination_count = 2;
}
let mut dests = HashMap::new();
dests.insert("prod-eu".into(), super::super::DestinationStatus {
environment: "production".into(),
status: "succeeded".into(),
error: None,
});
dests.insert("staging".into(), super::super::DestinationStatus {
environment: "staging".into(),
status: "succeeded".into(),
error: None,
});
let msg = format_slack_message(&event, &dests, "");
assert_eq!(msg.color, "#36a64f"); // green — all done, no pending
}
#[test]
fn slack_message_in_progress_is_blue() {
let event = test_event();
let mut dests = HashMap::new();
dests.insert("prod-eu".into(), super::super::DestinationStatus {
environment: "production".into(),
status: "started".into(),
error: None,
});
// A destination still in progress → blue
let msg = format_slack_message(&event, &dests, "");
assert_eq!(msg.color, "#0d6efd"); // blue — in progress
}
#[test]
fn slack_message_accumulated_shows_errors() {
let event = test_event();
let mut dests = HashMap::new();
dests.insert("prod-eu".into(), super::super::DestinationStatus {
environment: "production".into(),
status: "failed".into(),
error: Some("OOM killed".into()),
});
let msg = format_slack_message(&event, &dests, "");
assert_eq!(msg.color, "#dc3545"); // red
let blocks_str = serde_json::to_string(&msg.blocks).unwrap();
assert!(blocks_str.contains("OOM killed"));
}
#[test]
fn pipeline_blocks_renders_stages() {
use crate::platform::PipelineRunStageState;
let stages = vec![
PipelineRunStageState {
stage_id: "s1".into(),
depends_on: vec![],
stage_type: "deploy".into(),
status: "SUCCEEDED".into(),
environment: Some("dev".into()),
duration_seconds: None,
queued_at: None,
started_at: None,
completed_at: None,
error_message: None,
wait_until: None,
release_ids: vec![],
},
PipelineRunStageState {
stage_id: "s2".into(),
depends_on: vec!["s1".into()],
stage_type: "wait".into(),
status: "SUCCEEDED".into(),
environment: None,
duration_seconds: Some(3),
queued_at: None,
started_at: None,
completed_at: None,
error_message: None,
wait_until: None,
release_ids: vec![],
},
PipelineRunStageState {
stage_id: "s3".into(),
depends_on: vec!["s2".into()],
stage_type: "deploy".into(),
status: "RUNNING".into(),
environment: Some("staging".into()),
duration_seconds: None,
queued_at: None,
started_at: None,
completed_at: None,
error_message: None,
wait_until: None,
release_ids: vec![],
},
PipelineRunStageState {
stage_id: "s4".into(),
depends_on: vec!["s3".into()],
stage_type: "wait".into(),
status: "PENDING".into(),
environment: None,
duration_seconds: Some(5),
queued_at: None,
started_at: None,
completed_at: None,
error_message: None,
wait_until: None,
release_ids: vec![],
},
PipelineRunStageState {
stage_id: "s5".into(),
depends_on: vec!["s4".into()],
stage_type: "deploy".into(),
status: "PENDING".into(),
environment: Some("prod".into()),
duration_seconds: None,
queued_at: None,
started_at: None,
completed_at: None,
error_message: None,
wait_until: None,
release_ids: vec![],
},
];
let blocks = format_pipeline_blocks(&stages);
assert_eq!(blocks.len(), 3); // stages block + progress context + divider
let text = blocks[0]["text"]["text"].as_str().unwrap();
assert!(text.contains("Deployed to `dev`"));
assert!(text.contains("Waited 3s"));
assert!(text.contains("Deploying to `staging`"));
assert!(text.contains("Wait 5s"));
assert!(text.contains("Deploy to `prod`"));
let progress = blocks[1]["elements"][0]["text"].as_str().unwrap();
assert_eq!(progress, "2/5 stages complete");
}
#[test]
fn pipeline_blocks_empty_stages_returns_nothing() {
let blocks = format_pipeline_blocks(&[]);
assert!(blocks.is_empty());
}
#[test]
fn pipeline_blocks_shows_errors() {
use crate::platform::PipelineRunStageState;
let stages = vec![PipelineRunStageState {
stage_id: "s1".into(),
depends_on: vec![],
stage_type: "deploy".into(),
status: "FAILED".into(),
environment: Some("prod".into()),
duration_seconds: None,
queued_at: None,
started_at: None,
completed_at: None,
error_message: Some("OOM killed".into()),
wait_until: None,
release_ids: vec![],
}];
let blocks = format_pipeline_blocks(&stages);
let text = blocks[0]["text"]["text"].as_str().unwrap();
assert!(text.contains("Deploy to `prod` failed"));
assert!(text.contains("OOM killed"));
}
}

View File

@@ -1,6 +1,7 @@
use forage_core::integrations::{
CreateIntegrationInput, DeliveryStatus, Integration, IntegrationConfig, IntegrationError,
IntegrationStore, IntegrationType, NotificationDelivery, NotificationRule, NOTIFICATION_TYPES,
IntegrationStore, IntegrationType, NotificationDelivery, NotificationRule, SlackMessageRef,
SlackUserLink, NOTIFICATION_TYPES,
};
use sqlx::PgPool;
use uuid::Uuid;
@@ -221,6 +222,36 @@ impl IntegrationStore for PgIntegrationStore {
Ok(())
}
async fn update_integration_config(
&self,
organisation: &str,
id: &str,
name: &str,
config: &IntegrationConfig,
) -> Result<(), IntegrationError> {
let uuid: Uuid = id
.parse()
.map_err(|_| IntegrationError::NotFound(id.to_string()))?;
let encrypted = self.encrypt_config(config)?;
let result = sqlx::query(
"UPDATE integrations SET name = $1, config_encrypted = $2, updated_at = NOW()
WHERE id = $3 AND organisation = $4",
)
.bind(name)
.bind(&encrypted)
.bind(uuid)
.bind(organisation)
.execute(&self.pool)
.await
.map_err(|e| IntegrationError::Store(e.to_string()))?;
if result.rows_affected() == 0 {
return Err(IntegrationError::NotFound(id.to_string()));
}
Ok(())
}
async fn list_rules(
&self,
integration_id: &str,
@@ -392,6 +423,189 @@ impl IntegrationStore for PgIntegrationStore {
self.row_to_integration(row)
}
// ── Slack user links ─────────────────────────────────────────
async fn get_slack_user_link(
&self,
user_id: &str,
team_id: &str,
) -> Result<Option<SlackUserLink>, IntegrationError> {
let row: Option<SlackUserLinkRow> = sqlx::query_as(
"SELECT id, user_id, team_id, team_name, slack_user_id, slack_username, created_at
FROM slack_user_links WHERE user_id = $1 AND team_id = $2",
)
.bind(user_id)
.bind(team_id)
.fetch_optional(&self.pool)
.await
.map_err(|e| IntegrationError::Store(e.to_string()))?;
Ok(row.map(|r| SlackUserLink {
id: r.id.to_string(),
user_id: r.user_id,
team_id: r.team_id,
team_name: r.team_name,
slack_user_id: r.slack_user_id,
slack_username: r.slack_username,
created_at: r.created_at.to_rfc3339(),
}))
}
async fn upsert_slack_user_link(
&self,
link: &SlackUserLink,
) -> Result<(), IntegrationError> {
sqlx::query(
"INSERT INTO slack_user_links (id, user_id, team_id, team_name, slack_user_id, slack_username, created_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (user_id, team_id) DO UPDATE SET
slack_user_id = EXCLUDED.slack_user_id,
slack_username = EXCLUDED.slack_username,
team_name = EXCLUDED.team_name",
)
.bind(Uuid::parse_str(&link.id).map_err(|e| IntegrationError::Store(e.to_string()))?)
.bind(&link.user_id)
.bind(&link.team_id)
.bind(&link.team_name)
.bind(&link.slack_user_id)
.bind(&link.slack_username)
.execute(&self.pool)
.await
.map_err(|e| IntegrationError::Store(e.to_string()))?;
Ok(())
}
async fn delete_slack_user_link(
&self,
user_id: &str,
team_id: &str,
) -> Result<(), IntegrationError> {
sqlx::query("DELETE FROM slack_user_links WHERE user_id = $1 AND team_id = $2")
.bind(user_id)
.bind(team_id)
.execute(&self.pool)
.await
.map_err(|e| IntegrationError::Store(e.to_string()))?;
Ok(())
}
async fn list_slack_user_links(
&self,
user_id: &str,
) -> Result<Vec<SlackUserLink>, IntegrationError> {
let rows: Vec<SlackUserLinkRow> = sqlx::query_as(
"SELECT id, user_id, team_id, team_name, slack_user_id, slack_username, created_at
FROM slack_user_links WHERE user_id = $1 ORDER BY created_at",
)
.bind(user_id)
.fetch_all(&self.pool)
.await
.map_err(|e| IntegrationError::Store(e.to_string()))?;
Ok(rows
.into_iter()
.map(|r| SlackUserLink {
id: r.id.to_string(),
user_id: r.user_id,
team_id: r.team_id,
team_name: r.team_name,
slack_user_id: r.slack_user_id,
slack_username: r.slack_username,
created_at: r.created_at.to_rfc3339(),
})
.collect())
}
// ── Slack message refs ───────────────────────────────────────
async fn get_slack_message_ref(
&self,
integration_id: &str,
release_id: &str,
) -> Result<Option<SlackMessageRef>, IntegrationError> {
let iid =
Uuid::parse_str(integration_id).map_err(|e| IntegrationError::Store(e.to_string()))?;
let row: Option<SlackMessageRefRow> = sqlx::query_as(
"SELECT id, integration_id, release_id, channel_id, message_ts, last_event_type, destinations, release_title, created_at, updated_at
FROM slack_message_refs WHERE integration_id = $1 AND release_id = $2",
)
.bind(iid)
.bind(release_id)
.fetch_optional(&self.pool)
.await
.map_err(|e| IntegrationError::Store(e.to_string()))?;
Ok(row.map(|r| SlackMessageRef {
id: r.id.to_string(),
integration_id: r.integration_id.to_string(),
release_id: r.release_id,
channel_id: r.channel_id,
message_ts: r.message_ts,
last_event_type: r.last_event_type,
destinations: serde_json::from_value(r.destinations).unwrap_or_default(),
release_title: r.release_title,
created_at: r.created_at.to_rfc3339(),
updated_at: r.updated_at.to_rfc3339(),
}))
}
async fn upsert_slack_message_ref(
&self,
msg_ref: &SlackMessageRef,
) -> Result<(), IntegrationError> {
let iid = Uuid::parse_str(&msg_ref.integration_id)
.map_err(|e| IntegrationError::Store(e.to_string()))?;
let destinations_json = serde_json::to_value(&msg_ref.destinations)
.map_err(|e| IntegrationError::Store(e.to_string()))?;
sqlx::query(
"INSERT INTO slack_message_refs (id, integration_id, release_id, channel_id, message_ts, last_event_type, destinations, release_title, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
ON CONFLICT (integration_id, release_id) DO UPDATE SET
message_ts = EXCLUDED.message_ts,
last_event_type = EXCLUDED.last_event_type,
destinations = EXCLUDED.destinations,
release_title = EXCLUDED.release_title,
updated_at = NOW()",
)
.bind(Uuid::parse_str(&msg_ref.id).map_err(|e| IntegrationError::Store(e.to_string()))?)
.bind(iid)
.bind(&msg_ref.release_id)
.bind(&msg_ref.channel_id)
.bind(&msg_ref.message_ts)
.bind(&msg_ref.last_event_type)
.bind(destinations_json)
.bind(&msg_ref.release_title)
.execute(&self.pool)
.await
.map_err(|e| IntegrationError::Store(e.to_string()))?;
Ok(())
}
}
#[derive(sqlx::FromRow)]
struct SlackUserLinkRow {
id: Uuid,
user_id: String,
team_id: String,
team_name: String,
slack_user_id: String,
slack_username: String,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(sqlx::FromRow)]
struct SlackMessageRefRow {
id: Uuid,
integration_id: Uuid,
release_id: String,
channel_id: String,
message_ts: String,
last_event_type: String,
destinations: serde_json::Value,
release_title: String,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(sqlx::FromRow)]

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
#[prost(int32, tag="16")]
pub destination_count: i32,
#[prost(string, tag="17")]
pub source_user_id: ::prost::alloc::string::String,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct Notification {

View File

@@ -69,19 +69,28 @@ impl FromRequestParts<AppState> for Session {
.get_user(&session_data.access_token)
.await
{
let orgs = state
// Preserve existing orgs on failure — a transient gRPC error
// should not wipe the cached org list.
let previous_orgs = session_data
.user
.as_ref()
.map(|u| u.orgs.clone())
.unwrap_or_default();
let orgs = match state
.platform_client
.list_my_organisations(&session_data.access_token)
.await
.ok()
.unwrap_or_default()
.into_iter()
.map(|o| CachedOrg {
organisation_id: o.organisation_id,
name: o.name,
role: o.role,
})
.collect();
{
Ok(fresh) => fresh
.into_iter()
.map(|o| CachedOrg {
organisation_id: o.organisation_id,
name: o.name,
role: o.role,
})
.collect(),
Err(_) => previous_orgs,
};
session_data.user = Some(CachedUser {
user_id: user.user_id.clone(),
username: user.username.clone(),
@@ -99,11 +108,46 @@ impl FromRequestParts<AppState> for Session {
}
}
} else {
// Throttle last_seen_at writes: only update if older than 5 minutes
let now = chrono::Utc::now();
if now - session_data.last_seen_at > chrono::Duration::minutes(5) {
session_data.last_seen_at = now;
let _ = state.sessions.update(&session_id, session_data.clone()).await;
// Backfill: if we have a user but empty orgs, try to fetch them.
// This handles the case where list_my_organisations failed during login.
let needs_org_backfill = session_data
.user
.as_ref()
.is_some_and(|u| u.orgs.is_empty());
if needs_org_backfill {
if let Ok(orgs) = state
.platform_client
.list_my_organisations(&session_data.access_token)
.await
{
if !orgs.is_empty() {
if let Some(ref mut user) = session_data.user {
tracing::info!(
user_id = %user.user_id,
org_count = orgs.len(),
"backfilled empty org list"
);
user.orgs = orgs
.into_iter()
.map(|o| CachedOrg {
organisation_id: o.organisation_id,
name: o.name,
role: o.role,
})
.collect();
}
session_data.last_seen_at = chrono::Utc::now();
let _ = state.sessions.update(&session_id, session_data.clone()).await;
}
}
} else {
// Throttle last_seen_at writes: only update if older than 5 minutes
let now = chrono::Utc::now();
if now - session_data.last_seen_at > chrono::Duration::minutes(5) {
session_data.last_seen_at = now;
let _ = state.sessions.update(&session_id, session_data.clone()).await;
}
}
}

View File

@@ -98,6 +98,45 @@ impl GrpcForestClient {
fn authed_request<T>(access_token: &str, msg: T) -> Result<Request<T>, AuthError> {
bearer_request(access_token, msg).map_err(AuthError::Other)
}
/// Fetch release intent states using a service token (for background workers).
pub async fn get_release_intent_states_with_token(
&self,
service_token: &str,
organisation: &str,
project: Option<&str>,
include_completed: bool,
) -> Result<Vec<forage_core::platform::ReleaseIntentState>, String> {
let req = bearer_request(
service_token,
forage_grpc::GetReleaseIntentStatesRequest {
organisation: organisation.into(),
project: project.map(|p| p.into()),
include_completed,
},
)
.map_err(|e| format!("invalid token: {e}"))?;
let resp = self
.release_client()
.get_release_intent_states(req)
.await
.map_err(|e| format!("gRPC: {e}"))?;
Ok(resp
.into_inner()
.release_intents
.into_iter()
.map(|ri| forage_core::platform::ReleaseIntentState {
release_intent_id: ri.release_intent_id,
artifact_id: ri.artifact_id,
project: ri.project,
created_at: ri.created_at,
stages: ri.stages.into_iter().map(convert_pipeline_stage_state).collect(),
steps: ri.steps.into_iter().map(convert_release_step_state).collect(),
})
.collect())
}
}
fn map_status(status: tonic::Status) -> AuthError {

View File

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

View File

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

View File

@@ -15,15 +15,26 @@ use crate::forest_client::GrpcForestClient;
pub struct NotificationDispatcher {
http: reqwest::Client,
store: Arc<dyn IntegrationStore>,
forage_url: String,
/// gRPC client for querying pipeline state (optional — absent in tests).
grpc: Option<Arc<GrpcForestClient>>,
/// Service token for authenticating gRPC calls to fetch pipeline state.
service_token: String,
}
impl NotificationDispatcher {
pub fn new(store: Arc<dyn IntegrationStore>) -> Self {
pub fn new(store: Arc<dyn IntegrationStore>, forage_url: String) -> Self {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.expect("failed to build reqwest client");
Self { http, store }
Self { http, store, forage_url, grpc: None, service_token: String::new() }
}
pub fn with_grpc(mut self, grpc: Arc<GrpcForestClient>, service_token: String) -> Self {
self.grpc = Some(grpc);
self.service_token = service_token;
self
}
/// Execute a dispatch task with retry (3 attempts, exponential backoff).
@@ -35,8 +46,15 @@ impl NotificationDispatcher {
..
} => (integration_id.clone(), payload.notification_id.clone()),
DispatchTask::Slack {
integration_id, ..
} => (integration_id.clone(), String::new()),
integration_id,
notification_id,
..
} => (integration_id.clone(), notification_id.clone()),
DispatchTask::SlackDm {
integration_id,
notification_id,
..
} => (integration_id.clone(), notification_id.clone()),
};
let delays = [1, 5, 25]; // seconds
@@ -55,6 +73,26 @@ impl NotificationDispatcher {
return;
}
Err(e) => {
// Don't retry errors that will never succeed
let non_retryable = is_non_retryable_error(&e);
if non_retryable {
tracing::error!(
integration_id = %integration_id,
error = %e,
"non-retryable delivery error"
);
let _ = self
.store
.record_delivery(
&integration_id,
&notification_id,
DeliveryStatus::Failed,
Some(&e),
)
.await;
return;
}
tracing::warn!(
integration_id = %integration_id,
attempt = attempt + 1,
@@ -125,38 +163,371 @@ impl NotificationDispatcher {
}
}
DispatchTask::Slack {
integration_id,
webhook_url,
access_token,
channel_id,
release_id,
event_type,
event,
message,
..
} => {
// Use Block Kit attachments for rich formatting
let payload = serde_json::json!({
"text": message.text,
"attachments": [{
"color": message.color,
"blocks": message.blocks,
}]
});
let resp = self
.http
.post(webhook_url)
.header("Content-Type", "application/json")
.json(&payload)
.send()
// If we have a bot token, use chat.postMessage/chat.update for update-in-place
if !access_token.is_empty() && !channel_id.is_empty() && !release_id.is_empty() {
self.dispatch_slack_bot(
integration_id,
access_token,
channel_id,
release_id,
event_type,
event,
)
.await
.map_err(|e| format!("slack http: {e}"))?;
let status = resp.status();
if status.is_success() {
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
Err(format!("Slack HTTP {status}: {body}"))
// Fallback: webhook URL (no update-in-place possible)
self.dispatch_slack_webhook(webhook_url, message).await
}
}
DispatchTask::SlackDm {
integration_id,
access_token,
slack_user_id,
release_id,
event_type,
event,
message: _,
..
} => {
// DM uses the same bot token post/update pattern, but channel = user ID.
// Prefix release_id so the message ref is distinct from channel messages.
let dm_release_id = format!("dm:{slack_user_id}:{release_id}");
self.dispatch_slack_bot(
integration_id,
access_token,
slack_user_id, // Slack accepts user ID as channel for DMs
&dm_release_id,
event_type,
event,
)
.await
}
}
}
/// Post or update a Slack message via the bot token API.
/// Merges per-destination status into the message ref and rebuilds the message.
async fn dispatch_slack_bot(
&self,
integration_id: &str,
access_token: &str,
channel: &str,
release_id: &str,
event_type: &str,
event: &forage_core::integrations::router::NotificationEvent,
) -> Result<(), String> {
use forage_core::integrations::{DestinationStatus, SlackMessageRef};
use forage_core::integrations::router::format_slack_message;
// Get existing ref (with accumulated destinations) if we already posted
let existing_ref = self
.store
.get_slack_message_ref(integration_id, release_id)
.await
.unwrap_or(None);
// Merge this notification's destination into the accumulated map
let mut destinations = existing_ref
.as_ref()
.map(|r| r.destinations.clone())
.unwrap_or_default();
if let Some(ref r) = event.release {
if !r.destination.is_empty() {
let status = match event_type {
"release_started" => "started",
"release_succeeded" => "succeeded",
"release_failed" => "failed",
_ => "started",
};
destinations.insert(
r.destination.clone(),
DestinationStatus {
environment: r.environment.clone(),
status: status.to_string(),
error: r.error_message.clone(),
},
);
}
}
// Build the message with the full accumulated state
let mut message = format_slack_message(event, &destinations, &self.forage_url);
// Query pipeline stages and insert before destinations
if let Some(ref r) = event.release {
if !r.release_intent_id.is_empty() {
if let Some(stages) = self
.fetch_pipeline_stages(&event.organisation, &event.project, &r.release_intent_id)
.await
{
let pipeline_blocks =
forage_core::integrations::router::format_pipeline_blocks(&stages);
if !pipeline_blocks.is_empty() {
// Insert pipeline before the destination section.
// Find the last "context" block (metadata); pipeline goes right after it,
// pushing destinations and errors down.
let insert_at = message
.blocks
.iter()
.rposition(|b| b["type"] == "context")
.map(|i| i + 1)
.unwrap_or(message.blocks.len());
for (i, block) in pipeline_blocks.into_iter().enumerate() {
message.blocks.insert(insert_at + i, block);
}
}
}
}
}
let release_title = event
.release
.as_ref()
.filter(|r| !r.context_title.is_empty())
.map(|r| r.context_title.clone())
.or_else(|| existing_ref.as_ref().map(|r| r.release_title.clone()))
.unwrap_or_else(|| event.title.clone());
let blocks_payload = serde_json::json!([{
"color": message.color,
"blocks": message.blocks,
}]);
// The `text` field is a fallback for notifications/accessibility only.
// Slack renders it above attachments in some clients, causing duplication.
// Use a minimal fallback; the attachment blocks carry the rich content.
let fallback_text = format!("Release update: {}/{}", event.organisation, event.project);
if let Some(ref msg_ref) = existing_ref {
// Update existing message
let payload = serde_json::json!({
"channel": msg_ref.channel_id,
"ts": msg_ref.message_ts,
"text": fallback_text,
"attachments": blocks_payload,
});
let resp = self
.http
.post("https://slack.com/api/chat.update")
.bearer_auth(access_token)
.json(&payload)
.send()
.await
.map_err(|e| format!("slack chat.update http: {e}"))?;
let body: serde_json::Value =
resp.json().await.map_err(|e| format!("slack chat.update parse: {e}"))?;
if body["ok"].as_bool() != Some(true) {
let err = body["error"].as_str().unwrap_or("unknown");
if err == "message_not_found" {
tracing::warn!(
integration_id = %integration_id,
release_id = %release_id,
"slack message not found, posting new one"
);
// Fall through to post a new one
} else {
return Err(format!("slack chat.update: {err}"));
}
} else {
// Update the ref with merged destinations
let updated = SlackMessageRef {
id: msg_ref.id.clone(),
integration_id: integration_id.to_string(),
release_id: release_id.to_string(),
channel_id: msg_ref.channel_id.clone(),
message_ts: msg_ref.message_ts.clone(),
last_event_type: event_type.to_string(),
destinations,
release_title,
created_at: msg_ref.created_at.clone(),
updated_at: chrono::Utc::now().to_rfc3339(),
};
let _ = self.store.upsert_slack_message_ref(&updated).await;
return Ok(());
}
}
// Try to join the channel first
let _ = self.slack_join_channel(access_token, channel).await;
// Post new message
let payload = serde_json::json!({
"channel": channel,
"text": fallback_text,
"attachments": blocks_payload,
});
let resp = self
.http
.post("https://slack.com/api/chat.postMessage")
.bearer_auth(access_token)
.json(&payload)
.send()
.await
.map_err(|e| format!("slack chat.postMessage http: {e}"))?;
let body: serde_json::Value =
resp.json().await.map_err(|e| format!("slack chat.postMessage parse: {e}"))?;
if body["ok"].as_bool() != Some(true) {
let err = body["error"].as_str().unwrap_or("unknown");
return Err(format!("slack chat.postMessage: {err}"));
}
// Store the message ref with initial destinations
let ts = body["ts"].as_str().unwrap_or_default();
let posted_channel = body["channel"].as_str().unwrap_or(channel);
if !ts.is_empty() && !release_id.is_empty() {
let msg_ref = SlackMessageRef {
id: uuid::Uuid::new_v4().to_string(),
integration_id: integration_id.to_string(),
release_id: release_id.to_string(),
channel_id: posted_channel.to_string(),
message_ts: ts.to_string(),
last_event_type: event_type.to_string(),
destinations,
release_title,
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: chrono::Utc::now().to_rfc3339(),
};
let _ = self.store.upsert_slack_message_ref(&msg_ref).await;
}
Ok(())
}
/// Try to join a Slack channel. Silently succeeds if already a member or channel is private.
async fn slack_join_channel(&self, access_token: &str, channel: &str) -> Result<(), String> {
let payload = serde_json::json!({ "channel": channel });
let resp = self
.http
.post("https://slack.com/api/conversations.join")
.bearer_auth(access_token)
.json(&payload)
.send()
.await
.map_err(|e| format!("slack conversations.join http: {e}"))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("slack conversations.join parse: {e}"))?;
if body["ok"].as_bool() == Some(true) {
tracing::info!(channel = %channel, "bot joined slack channel");
} else {
let err = body["error"].as_str().unwrap_or("unknown");
// channel_not_found, method_not_supported_for_channel_type (private), already_in_channel
// These are all acceptable — we tried our best
tracing::debug!(channel = %channel, error = %err, "conversations.join failed (may be private channel)");
}
Ok(())
}
/// Fetch pipeline stages for a release intent via gRPC.
/// Returns None if gRPC is not configured or the call fails.
async fn fetch_pipeline_stages(
&self,
organisation: &str,
project: &str,
release_intent_id: &str,
) -> Option<Vec<forage_core::platform::PipelineRunStageState>> {
let grpc = self.grpc.as_ref()?;
if self.service_token.is_empty() {
return None;
}
match grpc
.get_release_intent_states_with_token(
&self.service_token,
organisation,
Some(project),
true, // include_completed so we get the current intent
)
.await
{
Ok(intents) => {
// Find the matching release intent
intents
.into_iter()
.find(|i| i.release_intent_id == release_intent_id)
.map(|i| i.stages)
}
Err(e) => {
tracing::warn!(
release_intent_id = %release_intent_id,
error = %e,
"failed to fetch pipeline stages"
);
None
}
}
}
/// Fallback: post via incoming webhook URL (no update-in-place).
async fn dispatch_slack_webhook(
&self,
webhook_url: &str,
message: &forage_core::integrations::router::SlackMessage,
) -> Result<(), String> {
let payload = serde_json::json!({
"text": message.text,
"attachments": [{
"color": message.color,
"blocks": message.blocks,
}]
});
let resp = self
.http
.post(webhook_url)
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await
.map_err(|e| format!("slack http: {e}"))?;
let status = resp.status();
if status.is_success() {
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
Err(format!("Slack HTTP {status}: {body}"))
}
}
}
/// Slack API errors that will never succeed on retry.
fn is_non_retryable_error(err: &str) -> bool {
const NON_RETRYABLE: &[&str] = &[
"channel_not_found",
"not_in_channel",
"is_archived",
"invalid_auth",
"token_revoked",
"account_inactive",
"no_permission",
"missing_scope",
"not_authed",
"invalid_arguments",
];
NON_RETRYABLE.iter().any(|code| err.contains(code))
}
// ── Proto conversion ────────────────────────────────────────────────
@@ -174,11 +545,16 @@ pub fn proto_to_event(n: &forage_grpc::Notification) -> NotificationEvent {
let release = n.release_context.as_ref().map(|r| ReleaseContext {
slug: r.slug.clone(),
artifact_id: r.artifact_id.clone(),
release_intent_id: r.release_intent_id.clone(),
destination: r.destination.clone(),
environment: r.environment.clone(),
source_username: r.source_username.clone(),
source_user_id: r.source_user_id.clone(),
commit_sha: r.commit_sha.clone(),
commit_branch: r.commit_branch.clone(),
context_title: r.context_title.clone(),
context_web: r.context_web.clone(),
destination_count: r.destination_count,
error_message: if r.error_message.is_empty() {
None
} else {
@@ -207,6 +583,8 @@ pub struct NotificationListener {
pub store: Arc<dyn IntegrationStore>,
/// Service token (PAT) for authenticating with forest-server's NotificationService.
pub service_token: String,
/// Base URL of the Forage web UI for deep links (e.g. "https://forage.example.com").
pub forage_url: String,
}
impl Component for NotificationListener {
@@ -215,7 +593,10 @@ impl Component for NotificationListener {
}
async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> {
let dispatcher = Arc::new(NotificationDispatcher::new(self.store.clone()));
let dispatcher = Arc::new(
NotificationDispatcher::new(self.store.clone(), self.forage_url.clone())
.with_grpc(self.grpc.clone(), self.service_token.clone()),
);
// For now, listen on the global stream (no org filter).
// Forest's ListenNotifications with no org filter returns all notifications

View File

@@ -1,9 +1,10 @@
use axum::extract::State;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use axum::{Form, Router};
use chrono::Utc;
use forage_core::integrations::SlackUserLink;
use minijinja::context;
use serde::Deserialize;
@@ -35,6 +36,18 @@ pub fn router() -> Router<AppState> {
"/settings/account/notifications",
post(update_notification_preference),
)
.route(
"/settings/account/slack/connect",
get(slack_connect),
)
.route(
"/settings/account/slack/callback",
get(slack_user_callback),
)
.route(
"/settings/account/slack/disconnect",
post(slack_disconnect),
)
}
// ─── Signup ─────────────────────────────────────────────────────────
@@ -95,34 +108,42 @@ async fn signup_submit(
{
Ok(tokens) => {
// Fetch user info for the session cache
let mut user_cache = state
let user_cache = match state
.forest_client
.get_user(&tokens.access_token)
.await
.ok()
.map(|u| CachedUser {
user_id: u.user_id,
username: u.username,
emails: u.emails,
orgs: vec![],
});
// Cache org memberships in the session
if let Some(ref mut user) = user_cache
&& let Ok(orgs) = state
.platform_client
.list_my_organisations(&tokens.access_token)
.await
{
user.orgs = orgs
.into_iter()
.map(|o| CachedOrg {
organisation_id: o.organisation_id,
name: o.name,
role: o.role,
Ok(u) => {
let orgs = match state
.platform_client
.list_my_organisations(&tokens.access_token)
.await
{
Ok(orgs) => orgs
.into_iter()
.map(|o| CachedOrg {
organisation_id: o.organisation_id,
name: o.name,
role: o.role,
})
.collect(),
Err(e) => {
tracing::warn!(error = %e, "failed to fetch orgs during signup");
vec![]
}
};
Some(CachedUser {
user_id: u.user_id,
username: u.username,
emails: u.emails,
orgs,
})
.collect();
}
}
Err(e) => {
tracing::warn!(error = %e, "failed to fetch user during signup");
None
}
};
let now = Utc::now();
let session_data = SessionData {
@@ -247,34 +268,42 @@ async fn login_submit(
.await
{
Ok(tokens) => {
let mut user_cache = state
let user_cache = match state
.forest_client
.get_user(&tokens.access_token)
.await
.ok()
.map(|u| CachedUser {
user_id: u.user_id,
username: u.username,
emails: u.emails,
orgs: vec![],
});
// Cache org memberships in the session
if let Some(ref mut user) = user_cache
&& let Ok(orgs) = state
.platform_client
.list_my_organisations(&tokens.access_token)
.await
{
user.orgs = orgs
.into_iter()
.map(|o| CachedOrg {
organisation_id: o.organisation_id,
name: o.name,
role: o.role,
Ok(u) => {
let orgs = match state
.platform_client
.list_my_organisations(&tokens.access_token)
.await
{
Ok(orgs) => orgs
.into_iter()
.map(|o| CachedOrg {
organisation_id: o.organisation_id,
name: o.name,
role: o.role,
})
.collect(),
Err(e) => {
tracing::warn!(error = %e, "failed to fetch orgs during login");
vec![]
}
};
Some(CachedUser {
user_id: u.user_id,
username: u.username,
emails: u.emails,
orgs,
})
.collect();
}
}
Err(e) => {
tracing::warn!(error = %e, "failed to fetch user during login");
None
}
};
let now = Utc::now();
let session_data = SessionData {
@@ -495,7 +524,17 @@ async fn account_page(
.get_notification_preferences(&session.access_token)
.await
.unwrap_or_default();
render_account(&state, &session, None, &prefs)
let slack_links = if let Some(store) = state.integration_store.as_ref() {
store
.list_slack_user_links(&session.user.user_id)
.await
.unwrap_or_default()
} else {
vec![]
};
render_account(&state, &session, None, &prefs, &slack_links)
}
#[allow(clippy::result_large_err)]
@@ -504,6 +543,7 @@ fn render_account(
session: &Session,
error: Option<&str>,
notification_prefs: &[forage_core::platform::NotificationPreference],
slack_links: &[SlackUserLink],
) -> Result<Response, Response> {
let html = state
.templates
@@ -529,6 +569,14 @@ fn render_account(
.filter(|p| p.enabled)
.map(|p| format!("{}|{}", p.notification_type, p.channel))
.collect::<Vec<_>>(),
has_slack_oauth => state.slack_config.is_some(),
slack_links => slack_links.iter().map(|l| context! {
id => &l.id,
team_id => &l.team_id,
team_name => &l.team_name,
slack_user_id => &l.slack_user_id,
slack_username => &l.slack_username,
}).collect::<Vec<_>>(),
},
)
.map_err(|e| {
@@ -559,7 +607,7 @@ async fn update_username_submit(
}
if let Err(e) = validate_username(&form.username) {
return render_account(&state, &session, Some(&e.0), &[]);
return render_account(&state, &session, Some(&e.0), &[], &[]);
}
match state
@@ -581,11 +629,11 @@ async fn update_username_submit(
Ok(Redirect::to("/settings/account").into_response())
}
Err(forage_core::auth::AuthError::AlreadyExists(_)) => {
render_account(&state, &session, Some("Username is already taken."), &[])
render_account(&state, &session, Some("Username is already taken."), &[], &[])
}
Err(e) => {
tracing::error!("failed to update username: {e}");
render_account(&state, &session, Some("Could not update username. Please try again."), &[])
render_account(&state, &session, Some("Could not update username. Please try again."), &[], &[])
}
}
}
@@ -613,11 +661,11 @@ async fn change_password_submit(
}
if form.new_password != form.new_password_confirm {
return render_account(&state, &session, Some("New passwords do not match."), &[]);
return render_account(&state, &session, Some("New passwords do not match."), &[], &[]);
}
if let Err(e) = validate_password(&form.new_password) {
return render_account(&state, &session, Some(&e.0), &[]);
return render_account(&state, &session, Some(&e.0), &[], &[]);
}
match state
@@ -632,11 +680,11 @@ async fn change_password_submit(
{
Ok(()) => Ok(Redirect::to("/settings/account").into_response()),
Err(forage_core::auth::AuthError::InvalidCredentials) => {
render_account(&state, &session, Some("Current password is incorrect."), &[])
render_account(&state, &session, Some("Current password is incorrect."), &[], &[])
}
Err(e) => {
tracing::error!("failed to change password: {e}");
render_account(&state, &session, Some("Could not change password. Please try again."), &[])
render_account(&state, &session, Some("Could not change password. Please try again."), &[], &[])
}
}
}
@@ -662,7 +710,7 @@ async fn add_email_submit(
}
if let Err(e) = validate_email(&form.email) {
return render_account(&state, &session, Some(&e.0), &[]);
return render_account(&state, &session, Some(&e.0), &[], &[]);
}
match state
@@ -687,11 +735,11 @@ async fn add_email_submit(
Ok(Redirect::to("/settings/account").into_response())
}
Err(forage_core::auth::AuthError::AlreadyExists(_)) => {
render_account(&state, &session, Some("Email is already registered."), &[])
render_account(&state, &session, Some("Email is already registered."), &[], &[])
}
Err(e) => {
tracing::error!("failed to add email: {e}");
render_account(&state, &session, Some("Could not add email. Please try again."), &[])
render_account(&state, &session, Some("Could not add email. Please try again."), &[], &[])
}
}
}
@@ -736,7 +784,7 @@ async fn remove_email_submit(
}
Err(e) => {
tracing::error!("failed to remove email: {e}");
render_account(&state, &session, Some("Could not remove email. Please try again."), &[])
render_account(&state, &session, Some("Could not remove email. Please try again."), &[], &[])
}
}
}
@@ -780,3 +828,225 @@ async fn update_notification_preference(
Ok(Redirect::to("/settings/account").into_response())
}
// ─── Slack user enrollment ────────────────────────────────────────────
async fn slack_connect(
State(state): State<AppState>,
session: Session,
) -> Result<impl IntoResponse, Response> {
let slack_config = state.slack_config.as_ref().ok_or_else(|| {
error_page(
&state,
StatusCode::SERVICE_UNAVAILABLE,
"Slack not configured",
"Slack OAuth is not configured on this server.",
)
})?;
let redirect_uri = format!(
"{}/settings/account/slack/callback",
slack_config.redirect_host
);
let url = format!(
"https://slack.com/oauth/v2/authorize?client_id={}&user_scope=identity.basic&redirect_uri={}&state={}",
urlencoding::encode(&slack_config.client_id),
urlencoding::encode(&redirect_uri),
urlencoding::encode(&session.user.user_id),
);
Ok(Redirect::to(&url))
}
#[derive(Deserialize)]
struct SlackUserCallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
async fn slack_user_callback(
State(state): State<AppState>,
session: Session,
Query(query): Query<SlackUserCallbackQuery>,
) -> Result<Response, Response> {
// Handle user-denied case
if let Some(err) = query.error {
tracing::warn!("Slack user OAuth denied: {err}");
return Ok(Redirect::to("/settings/account").into_response());
}
let code = query.code.ok_or_else(|| {
error_page(
&state,
StatusCode::BAD_REQUEST,
"Invalid request",
"Missing authorization code from Slack.",
)
})?;
// Verify state matches our user_id to prevent CSRF
let state_param = query.state.unwrap_or_default();
if state_param != session.user.user_id {
return Err(error_page(
&state,
StatusCode::FORBIDDEN,
"Invalid request",
"State parameter mismatch. Please try connecting again.",
));
}
let slack_config = state.slack_config.as_ref().ok_or_else(|| {
error_page(
&state,
StatusCode::SERVICE_UNAVAILABLE,
"Not configured",
"Slack OAuth is not configured.",
)
})?;
let integration_store = state.integration_store.as_ref().ok_or_else(|| {
error_page(
&state,
StatusCode::SERVICE_UNAVAILABLE,
"Not available",
"Slack account linking requires a database. Set DATABASE_URL to enable.",
)
})?;
let redirect_uri = format!(
"{}/settings/account/slack/callback",
slack_config.redirect_host
);
// Exchange the authorization code for a user access token
let http = reqwest::Client::new();
let token_resp = http
.post("https://slack.com/api/oauth.v2.access")
.form(&[
("client_id", slack_config.client_id.as_str()),
("client_secret", slack_config.client_secret.as_str()),
("code", &code),
("redirect_uri", &redirect_uri),
])
.send()
.await
.map_err(|e| {
internal_error(
&state,
"slack user oauth",
&format!("Failed to contact Slack: {e}"),
)
})?;
let resp_body: serde_json::Value = token_resp.json().await.map_err(|e| {
internal_error(
&state,
"slack user oauth",
&format!("Failed to parse Slack response: {e}"),
)
})?;
if resp_body.get("ok").and_then(|v| v.as_bool()) != Some(true) {
let err_msg = resp_body
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
tracing::error!("Slack user OAuth error: {err_msg}");
return Err(error_page(
&state,
StatusCode::BAD_GATEWAY,
"Slack error",
&format!("Slack returned an error: {err_msg}"),
));
}
// For user-scoped OAuth, the user token is nested under authed_user
let authed_user = resp_body.get("authed_user").ok_or_else(|| {
internal_error(
&state,
"slack user oauth",
&"Missing authed_user in Slack response",
)
})?;
let slack_user_id = authed_user["id"].as_str().unwrap_or("").to_string();
let user_access_token = authed_user["access_token"].as_str().unwrap_or("").to_string();
let team_id = resp_body["team"]["id"].as_str().unwrap_or("").to_string();
let team_name = resp_body["team"]["name"].as_str().unwrap_or("").to_string();
// Fetch display name via users.identity (requires identity.basic user scope)
let slack_username = if user_access_token.is_empty() {
slack_user_id.clone()
} else {
let identity_name: Option<String> = async {
let r = http
.get("https://slack.com/api/users.identity")
.bearer_auth(&user_access_token)
.send()
.await
.ok()?;
let body: serde_json::Value = r.json().await.ok()?;
let name = body["user"]["name"].as_str()?.to_string();
if name.is_empty() { None } else { Some(name) }
}
.await;
identity_name.unwrap_or_else(|| slack_user_id.clone())
};
let now = chrono::Utc::now().to_rfc3339();
let link = SlackUserLink {
id: uuid::Uuid::new_v4().to_string(),
user_id: session.user.user_id.clone(),
team_id,
team_name,
slack_user_id,
slack_username,
created_at: now,
};
integration_store
.upsert_slack_user_link(&link)
.await
.map_err(|e| internal_error(&state, "upsert slack user link", &e))?;
Ok(Redirect::to("/settings/account").into_response())
}
#[derive(Deserialize)]
struct SlackDisconnectForm {
team_id: String,
_csrf: String,
}
async fn slack_disconnect(
State(state): State<AppState>,
session: Session,
Form(form): Form<SlackDisconnectForm>,
) -> Result<Response, Response> {
if !auth::validate_csrf(&session, &form._csrf) {
return Err(error_page(
&state,
StatusCode::FORBIDDEN,
"Invalid request",
"CSRF validation failed.",
));
}
let integration_store = state.integration_store.as_ref().ok_or_else(|| {
error_page(
&state,
StatusCode::SERVICE_UNAVAILABLE,
"Not available",
"Slack account linking requires a database.",
)
})?;
integration_store
.delete_slack_user_link(&session.user.user_id, &form.team_id)
.await
.map_err(|e| internal_error(&state, "delete slack user link", &e))?;
Ok(Redirect::to("/settings/account").into_response())
}

View File

@@ -61,6 +61,10 @@ pub fn router() -> Router<AppState> {
"/orgs/{org}/settings/integrations/slack",
post(create_slack),
)
.route(
"/orgs/{org}/settings/integrations/{id}/reinstall",
post(reinstall_slack),
)
.route(
"/integrations/slack/callback",
get(slack_oauth_callback),
@@ -433,6 +437,7 @@ async fn integration_detail(
created_at => &integration.created_at,
},
config => config_display,
has_slack_oauth => state.slack_config.is_some(),
rules => rules_ctx,
deliveries => deliveries_ctx,
test_sent => query.test.is_some(),
@@ -575,17 +580,22 @@ async fn test_integration(
release: Some(ReleaseContext {
slug: "test-release".into(),
artifact_id: "art_test".into(),
release_intent_id: String::new(),
destination: "staging".into(),
environment: "staging".into(),
source_username: session.user.username.clone(),
source_user_id: session.user.user_id.clone(),
commit_sha: "abc1234".into(),
commit_branch: "main".into(),
context_title: "Test notification from Forage".into(),
context_web: String::new(),
destination_count: 1,
error_message: None,
}),
};
let tasks = forage_core::integrations::router::route_notification(&test_event, &[integration]);
let dispatcher = NotificationDispatcher::new(Arc::clone(store));
let dispatcher = NotificationDispatcher::new(Arc::clone(store), String::new());
for task in &tasks {
dispatcher.dispatch(task).await;
}
@@ -597,6 +607,409 @@ async fn test_integration(
.into_response())
}
// ─── Install Slack page ─────────────────────────────────────────────
async fn install_slack_page(
State(state): State<AppState>,
session: Session,
Path(org): Path<String>,
Query(query): Query<ListQuery>,
) -> Result<Response, Response> {
let cached_org = require_org_membership(&state, &session.user.orgs, &org)?;
require_admin(&state, cached_org)?;
require_integration_store(&state)?;
let slack_oauth_url = state.slack_config.as_ref().map(|sc| {
format!(
"https://slack.com/oauth/v2/authorize?client_id={}&scope=assistant:write,channels:join,chat:write,chat:write.public,im:history,im:read,im:write,incoming-webhook,links:read,links:write,reactions:write,users:read,users:read.email&redirect_uri={}/integrations/slack/callback&state={}",
urlencoding::encode(&sc.client_id),
urlencoding::encode(&sc.redirect_host),
urlencoding::encode(&org),
)
});
let html = state
.templates
.render(
"pages/install_slack.html.jinja",
context! {
title => format!("Install Slack - {} - Forage", org),
description => "Set up a Slack integration",
user => context! {
username => &session.user.username,
user_id => &session.user.user_id,
},
current_org => &org,
orgs => session.user.orgs.iter().map(|o| context! { name => &o.name, role => &o.role }).collect::<Vec<_>>(),
csrf_token => &session.csrf_token,
active_tab => "integrations",
error => query.error,
slack_oauth_url => slack_oauth_url,
has_slack_oauth => state.slack_config.is_some(),
},
)
.map_err(|e| internal_error(&state, "template error", &e))?;
Ok(Html(html).into_response())
}
// ─── Create Slack (manual webhook URL fallback) ──────────────────────
#[derive(Deserialize)]
struct CreateSlackForm {
_csrf: String,
name: String,
webhook_url: String,
#[serde(default)]
channel_name: String,
}
async fn create_slack(
State(state): State<AppState>,
session: Session,
Path(org): Path<String>,
Form(form): Form<CreateSlackForm>,
) -> Result<Response, Response> {
let cached_org = require_org_membership(&state, &session.user.orgs, &org)?;
require_admin(&state, cached_org)?;
require_integration_store(&state)?;
validate_csrf(&session, &form._csrf)?;
if let Err(e) = validate_integration_name(&form.name) {
return Ok(Redirect::to(&format!(
"/orgs/{}/settings/integrations/install/slack?error={}",
org,
urlencoding::encode(&e.to_string())
))
.into_response());
}
if let Err(e) = validate_slack_webhook_url(&form.webhook_url) {
return Ok(Redirect::to(&format!(
"/orgs/{}/settings/integrations/install/slack?error={}",
org,
urlencoding::encode(&e)
))
.into_response());
}
let channel = if form.channel_name.is_empty() {
"#general".to_string()
} else {
form.channel_name
};
let config = IntegrationConfig::Slack {
team_id: String::new(),
team_name: String::new(),
channel_id: String::new(),
channel_name: channel,
access_token: String::new(),
webhook_url: form.webhook_url,
};
let store = state.integration_store.as_ref().unwrap();
let created = store
.create_integration(&CreateIntegrationInput {
organisation: org.clone(),
integration_type: IntegrationType::Slack,
name: form.name,
config,
created_by: session.user.user_id.clone(),
})
.await
.map_err(|e| internal_error(&state, "create slack", &e))?;
let html = state
.templates
.render(
"pages/integration_installed.html.jinja",
context! {
title => format!("{} installed - Forage", created.name),
description => "Integration installed successfully",
user => context! {
username => &session.user.username,
user_id => &session.user.user_id,
},
current_org => &org,
orgs => session.user.orgs.iter().map(|o| context! { name => &o.name, role => &o.role }).collect::<Vec<_>>(),
csrf_token => &session.csrf_token,
active_tab => "integrations",
integration => context! {
id => &created.id,
name => &created.name,
type_display => created.integration_type.display_name(),
},
api_token => created.api_token,
},
)
.map_err(|e| internal_error(&state, "template error", &e))?;
Ok(Html(html).into_response())
}
// ─── Reinstall Slack ─────────────────────────────────────────────────
#[derive(Deserialize)]
struct ReinstallForm {
_csrf: String,
}
async fn reinstall_slack(
State(state): State<AppState>,
session: Session,
Path((org, id)): Path<(String, String)>,
Form(form): Form<ReinstallForm>,
) -> Result<Response, Response> {
let cached_org = require_org_membership(&state, &session.user.orgs, &org)?;
require_admin(&state, cached_org)?;
require_integration_store(&state)?;
validate_csrf(&session, &form._csrf)?;
// Verify the integration exists and is a Slack integration
let store = state.integration_store.as_ref().unwrap();
let integration = store.get_integration(&org, &id).await.map_err(|e| {
error_page(
&state,
axum::http::StatusCode::NOT_FOUND,
"Not found",
&format!("Integration not found: {e}"),
)
})?;
if integration.integration_type != IntegrationType::Slack {
return Err(error_page(
&state,
axum::http::StatusCode::BAD_REQUEST,
"Invalid request",
"Only Slack integrations can be reinstalled via OAuth.",
));
}
let slack_config = state.slack_config.as_ref().ok_or_else(|| {
error_page(
&state,
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"Not configured",
"Slack OAuth is not configured. Set SLACK_CLIENT_ID and SLACK_CLIENT_SECRET.",
)
})?;
// Encode org + integration ID in state so callback can update instead of create
let oauth_state = format!("{}:reinstall:{}", org, id);
let redirect_uri = format!(
"{}/integrations/slack/callback",
slack_config.redirect_host
);
let url = format!(
"https://slack.com/oauth/v2/authorize?client_id={}&scope=assistant:write,channels:join,chat:write,chat:write.public,im:history,im:read,im:write,incoming-webhook,links:read,links:write,reactions:write,users:read,users:read.email&redirect_uri={}&state={}",
urlencoding::encode(&slack_config.client_id),
urlencoding::encode(&redirect_uri),
urlencoding::encode(&oauth_state),
);
Ok(Redirect::to(&url).into_response())
}
// ─── Slack OAuth callback ────────────────────────────────────────────
#[derive(Deserialize)]
struct SlackCallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
async fn slack_oauth_callback(
State(state): State<AppState>,
session: Session,
Query(query): Query<SlackCallbackQuery>,
) -> Result<Response, Response> {
let raw_state = query.state.ok_or_else(|| {
error_page(
&state,
axum::http::StatusCode::BAD_REQUEST,
"Invalid request",
"Missing state parameter from Slack callback.",
)
})?;
// Parse state: either "{org}" (new install) or "{org}:reinstall:{id}" (reinstall)
let (org, reinstall_id) = if raw_state.contains(":reinstall:") {
let parts: Vec<&str> = raw_state.splitn(2, ":reinstall:").collect();
(parts[0].to_string(), Some(parts[1].to_string()))
} else {
(raw_state, None)
};
// If Slack returned an error (user denied)
if let Some(err) = query.error {
let redirect_to = if let Some(ref rid) = reinstall_id {
format!("/orgs/{}/settings/integrations/{}?error={}", org, rid, urlencoding::encode(&format!("Slack authorization denied: {err}")))
} else {
format!("/orgs/{}/settings/integrations/install/slack?error={}", org, urlencoding::encode(&format!("Slack authorization denied: {err}")))
};
return Ok(Redirect::to(&redirect_to).into_response());
}
let code = query.code.ok_or_else(|| {
error_page(
&state,
axum::http::StatusCode::BAD_REQUEST,
"Invalid request",
"Missing authorization code from Slack.",
)
})?;
let cached_org = require_org_membership(&state, &session.user.orgs, &org)?;
require_admin(&state, cached_org)?;
require_integration_store(&state)?;
let slack_config = state.slack_config.as_ref().ok_or_else(|| {
error_page(
&state,
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"Not configured",
"Slack OAuth is not configured. Set SLACK_CLIENT_ID and SLACK_CLIENT_SECRET.",
)
})?;
// Exchange code for token
let http = reqwest::Client::new();
let token_resp = http
.post("https://slack.com/api/oauth.v2.access")
.form(&[
("client_id", slack_config.client_id.as_str()),
("client_secret", slack_config.client_secret.as_str()),
("code", &code),
(
"redirect_uri",
&format!("{}/integrations/slack/callback", slack_config.redirect_host),
),
])
.send()
.await
.map_err(|e| {
internal_error(&state, "slack oauth", &format!("Failed to contact Slack: {e}"))
})?;
let resp_body: serde_json::Value = token_resp.json().await.map_err(|e| {
internal_error(
&state,
"slack oauth",
&format!("Failed to parse Slack response: {e}"),
)
})?;
if resp_body.get("ok").and_then(|v| v.as_bool()) != Some(true) {
let err_msg = resp_body
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
return Ok(Redirect::to(&format!(
"/orgs/{}/settings/integrations/install/slack?error={}",
org,
urlencoding::encode(&format!("Slack error: {err_msg}"))
))
.into_response());
}
// Extract fields from Slack response
let team_id = resp_body["team"]["id"]
.as_str()
.unwrap_or("")
.to_string();
let team_name = resp_body["team"]["name"]
.as_str()
.unwrap_or("")
.to_string();
let access_token = resp_body["access_token"]
.as_str()
.unwrap_or("")
.to_string();
let (channel_id, channel_name, webhook_url) =
if let Some(wh) = resp_body.get("incoming_webhook") {
(
wh["channel_id"].as_str().unwrap_or("").to_string(),
wh["channel"].as_str().unwrap_or("").to_string(),
wh["url"].as_str().unwrap_or("").to_string(),
)
} else {
(String::new(), String::new(), String::new())
};
let integration_name = if channel_name.is_empty() {
format!("Slack - {team_name}")
} else {
format!("Slack - {channel_name}")
};
let config = IntegrationConfig::Slack {
team_id,
team_name,
channel_id,
channel_name,
access_token,
webhook_url,
};
let store = state.integration_store.as_ref().unwrap();
if let Some(ref existing_id) = reinstall_id {
// Reinstall: update existing integration's config
store
.update_integration_config(&org, existing_id, &integration_name, &config)
.await
.map_err(|e| internal_error(&state, "reinstall slack", &e))?;
Ok(Redirect::to(&format!(
"/orgs/{}/settings/integrations/{}",
org, existing_id
))
.into_response())
} else {
// New install: create integration
let created = store
.create_integration(&CreateIntegrationInput {
organisation: org.clone(),
integration_type: IntegrationType::Slack,
name: integration_name,
config,
created_by: session.user.user_id.clone(),
})
.await
.map_err(|e| internal_error(&state, "create slack", &e))?;
let html = state
.templates
.render(
"pages/integration_installed.html.jinja",
context! {
title => format!("{} installed - Forage", created.name),
description => "Integration installed successfully",
user => context! {
username => &session.user.username,
user_id => &session.user.user_id,
},
current_org => &org,
orgs => session.user.orgs.iter().map(|o| context! { name => &o.name, role => &o.role }).collect::<Vec<_>>(),
csrf_token => &session.csrf_token,
active_tab => "integrations",
integration => context! {
id => &created.id,
name => &created.name,
type_display => created.integration_type.display_name(),
},
api_token => created.api_token,
},
)
.map_err(|e| internal_error(&state, "template error", &e))?;
Ok(Html(html).into_response())
}
}
// ─── Helpers ────────────────────────────────────────────────────────
fn notification_type_label(nt: &str) -> &str {
@@ -608,3 +1021,14 @@ fn notification_type_label(nt: &str) -> &str {
other => other,
}
}
fn validate_slack_webhook_url(url: &str) -> Result<(), String> {
if url.starts_with("https://hooks.slack.com/")
|| url.starts_with("http://localhost")
|| url.starts_with("http://127.0.0.1")
{
Ok(())
} else {
Err("Slack webhook URL must start with https://hooks.slack.com/".to_string())
}
}

View File

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

View File

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

View File

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

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

View File

@@ -238,7 +238,7 @@ Notification rules:
1. Admin clicks "Add Slack" → `POST /orgs/{org}/settings/integrations/slack` with CSRF
2. Server generates OAuth state (CSRF + org), stores in session, redirects to:
`https://slack.com/oauth/v2/authorize?client_id=...&scope=incoming-webhook,chat:write&redirect_uri=...&state=...`
`https://slack.com/oauth/v2/authorize?client_id=...&scope=assistant:write,channels:join,chat:write,chat:write.public,im:history,im:read,im:write,incoming-webhook,links:read,links:write,reactions:write,users:read,users:read.email&redirect_uri=...&state=...`
3. User authorizes in Slack
4. Slack redirects to `GET /orgs/{org}/settings/integrations/slack/callback?code=...&state=...`
5. Server validates state, exchanges code for access token via Slack API

File diff suppressed because one or more lines are too long

View File

@@ -84,6 +84,47 @@
</form>
</div>
{# Linked Slack accounts #}
{% if has_slack_oauth %}
<div class="mb-12">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Linked Slack accounts</h2>
<p class="text-sm text-gray-500 mb-4">Link your Slack identity to receive personal DMs about your releases.</p>
{% if slack_links %}
<div class="space-y-2 mb-4">
{% for link in slack_links %}
<div class="flex items-center justify-between px-4 py-3 border border-gray-200 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-[#4A154B] rounded flex items-center justify-center flex-shrink-0">
<svg class="w-4 h-4 text-white" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-gray-900">{{ link.team_name }}</p>
<p class="text-xs text-gray-500">@{{ link.slack_username }}</p>
</div>
</div>
<form method="POST" action="/settings/account/slack/disconnect">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="team_id" value="{{ link.team_id }}">
<button type="submit" class="text-xs text-red-600 hover:text-red-800">Disconnect</button>
</form>
</div>
{% endfor %}
</div>
{% endif %}
<a href="/settings/account/slack/connect"
class="inline-flex items-center gap-2 px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50 text-gray-700">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="#4A154B" aria-hidden="true">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
</svg>
Add to Slack
</a>
</div>
{% endif %}
{# Notification preferences #}
<div class="mb-12">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Notification preferences</h2>

View File

@@ -7,8 +7,8 @@
</div>
<div class="flex items-start gap-4 mb-8">
<div class="w-14 h-14 rounded-lg border border-gray-200 flex items-center justify-center bg-white shrink-0">
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="#4A154B">
<div class="w-14 h-14 rounded-lg border border-gray-200 flex items-center justify-center shrink-0">
<svg class="w-7 h-7 text-gray-600" viewBox="0 0 24 24" fill="currentColor">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
</svg>
</div>
@@ -50,7 +50,7 @@
<div class="mb-8">
<h3 class="text-sm font-semibold text-gray-700 mb-3">Connect with Slack</h3>
<p class="text-sm text-gray-500 mb-4">Click the button below to authorize Forage to post to a Slack channel. You'll choose which channel during the Slack authorization flow.</p>
<a href="{{ slack_oauth_url }}" class="inline-flex items-center gap-3 px-5 py-3 bg-[#4A154B] text-white rounded-lg hover:bg-[#3e1240] transition-colors font-medium text-sm">
<a href="{{ slack_oauth_url }}" class="inline-flex items-center gap-3 px-5 py-3 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors font-medium text-sm">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
</svg>

View File

@@ -31,6 +31,14 @@
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
{% if integration.integration_type == "slack" and has_slack_oauth is defined and has_slack_oauth %}
<form method="POST" action="/orgs/{{ current_org }}/settings/integrations/{{ integration.id }}/reinstall" class="inline">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<button type="submit" class="px-3 py-1.5 text-sm border border-gray-300 rounded-md hover:bg-gray-50 transition-colors">
Reinstall
</button>
</form>
{% endif %}
<form method="POST" action="/orgs/{{ current_org }}/settings/integrations/{{ integration.id }}/toggle" class="inline">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="enabled" value="{{ 'false' if integration.enabled else 'true' }}">

View File

@@ -82,22 +82,21 @@
</a>
{# Slack #}
<div class="border border-gray-200 rounded-lg p-5 opacity-60">
<a href="/orgs/{{ current_org }}/settings/integrations/install/slack" class="group border border-gray-200 rounded-lg p-5 hover:border-gray-300 hover:shadow-sm transition-all">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-lg border border-gray-200 flex items-center justify-center bg-white shrink-0">
<svg class="w-6 h-6 text-gray-400" viewBox="0 0 24 24" fill="currentColor">
<div class="w-12 h-12 rounded-lg border border-gray-200 flex items-center justify-center shrink-0 group-hover:border-gray-300">
<svg class="w-6 h-6 text-gray-600" viewBox="0 0 24 24" fill="currentColor">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
</svg>
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-500">Slack</span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">Coming soon</span>
<span class="font-medium text-gray-900">Slack</span>
</div>
<p class="text-sm text-gray-400 mt-1">Post deployment notifications directly to Slack channels. Rich formatting with release details, status, and quick links.</p>
<p class="text-sm text-gray-500 mt-1">Post deployment notifications directly to Slack channels. Rich formatting with release details, status, and quick links.</p>
</div>
</div>
</div>
</a>
{# Discord #}
<div class="border border-gray-200 rounded-lg p-5 opacity-60">