From 38e621c76304e8ed64553d93dce825a947172766 Mon Sep 17 00:00:00 2001 From: Amit kumar Date: Thu, 30 Jul 2026 03:28:05 +0530 Subject: [PATCH 1/6] feat: complete Slack integration UI and models --- apps/api/cmd/api/main.go | 4 +- apps/api/internal/handler/integration.go | 271 +++++++++++++++++- apps/api/internal/model/notification.go | 1 + apps/api/internal/model/slack.go | 46 +++ apps/api/internal/oauth/slack.go | 58 ++++ apps/api/internal/queue/consumer.go | 31 ++ apps/api/internal/queue/queue.go | 25 +- apps/api/internal/router/router.go | 18 +- apps/api/internal/service/integration.go | 208 +++++++++++++- apps/api/internal/service/issue.go | 3 + apps/api/internal/service/notification.go | 126 +++++++- apps/api/internal/slack/client.go | 97 +++++++ apps/api/internal/slack/notification.go | 21 ++ apps/api/internal/store/integration.go | 20 ++ apps/api/internal/store/slack.go | 67 +++++ .../000013_slack_integration.down.sql | 2 + .../000013_slack_integration.up.sql | 29 ++ apps/web/src/api/types.ts | 27 ++ .../integrations/IntegrationsSection.tsx | 243 +++++++++++++++- .../SlackChannelSettingsModal.tsx | 242 ++++++++++++++++ .../InstanceAdminIntegrationsPage.tsx | 28 +- apps/web/src/services/integrationService.ts | 68 +++++ 22 files changed, 1614 insertions(+), 21 deletions(-) create mode 100644 apps/api/internal/model/slack.go create mode 100644 apps/api/internal/oauth/slack.go create mode 100644 apps/api/internal/slack/client.go create mode 100644 apps/api/internal/slack/notification.go create mode 100644 apps/api/internal/store/slack.go create mode 100644 apps/api/migrations/000013_slack_integration.down.sql create mode 100644 apps/api/migrations/000013_slack_integration.up.sql create mode 100644 apps/web/src/components/integrations/SlackChannelSettingsModal.tsx diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index e42f36ba..b174eaa3 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -19,6 +19,7 @@ import ( "github.com/Devlaner/devlane/api/internal/redis" "github.com/Devlaner/devlane/api/internal/router" "github.com/Devlaner/devlane/api/internal/service" + "github.com/Devlaner/devlane/api/internal/slack" "github.com/Devlaner/devlane/api/internal/store" ) @@ -125,7 +126,8 @@ func main() { webhookDeliverer := service.NewWebhookDeliverer(store.NewWebhookStore(db), log) consumer.Register(queue.QueueWebhooks, queue.HandleWebhook(webhookDeliverer)) consumer.Register(queue.QueueImports, queue.HandleImport(importerSvc.Run)) - if err := consumer.Run(consumerCtx, []string{queue.QueueEmails, queue.QueueWebhooks, queue.QueueImports}); err != nil { + consumer.Register(queue.QueueSlack, queue.HandleSlackPost(log, slack.PostMessage)) + if err := consumer.Run(consumerCtx, []string{queue.QueueEmails, queue.QueueWebhooks, queue.QueueImports, queue.QueueSlack}); err != nil { log.Warn("queue consumer", "error", err) } } diff --git a/apps/api/internal/handler/integration.go b/apps/api/internal/handler/integration.go index 1bc6445b..1f470f10 100644 --- a/apps/api/internal/handler/integration.go +++ b/apps/api/internal/handler/integration.go @@ -11,12 +11,16 @@ import ( "strconv" "strings" + "github.com/Devlaner/devlane/api/internal/crypto" gh "github.com/Devlaner/devlane/api/internal/github" "github.com/Devlaner/devlane/api/internal/middleware" + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/oauth" "github.com/Devlaner/devlane/api/internal/service" "github.com/Devlaner/devlane/api/internal/store" "github.com/gin-gonic/gin" "github.com/google/uuid" + "gorm.io/gorm" ) // IntegrationHandler exposes generic integration endpoints. Provider-specific @@ -81,6 +85,134 @@ func (h *IntegrationHandler) Uninstall(c *gin.Context) { c.JSON(http.StatusNoContent, nil) } +// --------------------------------------------------------------------------- +// Slack App install flow (browser → slack.com → callback → workspace settings) +// --------------------------------------------------------------------------- + +// SlackInstallStart redirects the user to slack.com to install the App. +// We carry the workspace slug in the OAuth state cookie so the callback can +// link the resulting installation to the right workspace. +// GET /auth/slack/install?workspace=:slug +func (h *IntegrationHandler) SlackInstallStart(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + workspaceSlug := strings.TrimSpace(c.Query("workspace")) + if workspaceSlug == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "workspace query param is required"}) + return + } + + stateBytes := make([]byte, 16) + rand.Read(stateBytes) + // Encode workspace slug into the state so we can recover it in the callback. + // Use the same format as GitHub: hex:slug. The full string is both the cookie + // value and the OAuth state param, so they match on the round-trip. + state := hex.EncodeToString(stateBytes) + ":" + workspaceSlug + + http.SetCookie(c.Writer, &http.Cookie{ + Name: "slack_app_state", + Value: state, + Path: "/", + MaxAge: 600, + HttpOnly: true, + Secure: isSecureRequest(c), + SameSite: http.SameSiteLaxMode, + }) + + // Let's fetch the slack settings from the database + settings, _ := h.Settings.Get(c.Request.Context(), "slack_app") + clientID := "" + if settings != nil && settings.Value != nil { + if cid, ok := settings.Value["client_id"].(string); ok { + clientID = cid + } + } + + if clientID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Slack App is not configured. Ask an instance admin to set the slack_app section."}) + return + } + + redirectURI := h.APIPublicURL + "/auth/slack/callback" + provider := oauth.NewSlackProvider(oauth.ProviderConfig{ + ClientID: clientID, + RedirectURI: redirectURI, + }) + installURL := provider.AuthURL(state) + c.Redirect(http.StatusFound, installURL) +} + +// SlackInstallCallback handles the redirect back from slack.com after the +// user authorizes the App. Slack appends ?code=&state= to the redirect URL. +// GET /auth/slack/callback?code=...&state=... +func (h *IntegrationHandler) SlackInstallCallback(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + next := "/login" + if h.AppBaseURL != "" { + next = strings.TrimSuffix(h.AppBaseURL, "/") + "/login" + } + c.Redirect(http.StatusTemporaryRedirect, next) + return + } + + code := c.Query("code") + stateRaw := c.Query("state") + cookieVal, _ := c.Cookie("slack_app_state") + + // Clear cookie regardless of outcome + http.SetCookie(c.Writer, &http.Cookie{ + Name: "slack_app_state", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, + Secure: isSecureRequest(c), SameSite: http.SameSiteLaxMode, + }) + + if cookieVal == "" || cookieVal != stateRaw { + h.redirectIntegration(c, "", "Slack App install state mismatch") + return + } + + parts := strings.SplitN(stateRaw, ":", 2) + if len(parts) != 2 { + h.redirectIntegration(c, "", "Invalid Slack App install state") + return + } + workspaceSlug := parts[1] + + settings, _ := h.Settings.Get(c.Request.Context(), "slack_app") + clientID, clientSecret := "", "" + if settings != nil && settings.Value != nil { + if cid, ok := settings.Value["client_id"].(string); ok { + clientID = cid + } + if sec, ok := settings.Value["client_secret"].(string); ok { + clientSecret = crypto.DecryptOrPlain(sec) + } + } + + provider := oauth.NewSlackProvider(oauth.ProviderConfig{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURI: h.APIPublicURL + "/auth/slack/callback", + }) + + tokenData, err := provider.Exchange(c.Request.Context(), code) + if err != nil { + h.redirectIntegration(c, workspaceSlug, "Failed to exchange Slack code: "+err.Error()) + return + } + + if _, err := h.Integration.InstallSlack(c.Request.Context(), workspaceSlug, user.ID, tokenData); err != nil { + h.redirectIntegrationFor(c, workspaceSlug, "slack", "Failed to save Slack integration: "+err.Error()) + return + } + + h.redirectIntegrationFor(c, workspaceSlug, "slack", "") +} + // --------------------------------------------------------------------------- // GitHub App install flow (browser → github.com → callback → workspace settings) // --------------------------------------------------------------------------- @@ -175,6 +307,10 @@ func (h *IntegrationHandler) GitHubInstallCallback(c *gin.Context) { } func (h *IntegrationHandler) redirectIntegration(c *gin.Context, workspaceSlug, errMsg string) { + h.redirectIntegrationFor(c, workspaceSlug, "github", errMsg) +} + +func (h *IntegrationHandler) redirectIntegrationFor(c *gin.Context, workspaceSlug, provider, errMsg string) { target := strings.TrimSuffix(h.AppBaseURL, "/") if target == "" { target = "" @@ -189,7 +325,7 @@ func (h *IntegrationHandler) redirectIntegration(c *gin.Context, workspaceSlug, if errMsg != "" { q.Set("error", errMsg) } else { - q.Set("connected", "github") + q.Set("connected", provider) } target += "?" + q.Encode() c.Redirect(http.StatusTemporaryRedirect, target) @@ -533,6 +669,139 @@ func (h *IntegrationHandler) GitHubWebhook(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) } +// --------------------------------------------------------------------------- +// Slack Channel Link management +// --------------------------------------------------------------------------- + +// SlackListChannels fetches all available channels from Slack +// GET /api/workspaces/:slug/integrations/slack/channels/ +func (h *IntegrationHandler) SlackListChannels(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + channels, err := h.Integration.SlackListChannels(c.Request.Context(), c.Param("slug"), user.ID) + if err != nil { + writeIntegrationError(c, err) + return + } + c.JSON(http.StatusOK, channels) +} + +// SlackLinkChannel saves a Slack channel to be used for a specific project +// POST /api/workspaces/:slug/projects/:projectId/integrations/slack/channel/ +func (h *IntegrationHandler) SlackLinkChannel(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) + return + } + + var req struct { + ChannelID string `json:"channel_id"` + ChannelName string `json:"channel_name"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload"}) + return + } + + link, err := h.Integration.SlackLinkChannel(c.Request.Context(), c.Param("slug"), projectID, user.ID, req.ChannelID, req.ChannelName) + if err != nil { + writeIntegrationError(c, err) + return + } + c.JSON(http.StatusCreated, link) +} + +// SlackGetChannel fetches the linked Slack channel for a specific project +// GET /api/workspaces/:slug/projects/:projectId/integrations/slack/channel/ +func (h *IntegrationHandler) SlackGetChannel(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) + return + } + + link, err := h.Integration.SlackGetChannel(c.Request.Context(), c.Param("slug"), projectID, user.ID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusOK, nil) + return + } + writeIntegrationError(c, err) + return + } + c.JSON(http.StatusOK, link) +} + +// SlackUpdateChannel updates the events enabled for a specific project's Slack channel +// PATCH /api/workspaces/:slug/projects/:projectId/integrations/slack/channel/ +func (h *IntegrationHandler) SlackUpdateChannel(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) + return + } + + var req struct { + Events model.JSONMap `json:"events"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload"}) + return + } + + link, err := h.Integration.SlackUpdateChannel(c.Request.Context(), c.Param("slug"), projectID, user.ID, req.Events) + if err != nil { + writeIntegrationError(c, err) + return + } + c.JSON(http.StatusOK, link) +} + +// SlackUnlinkChannel unlinks a Slack channel from a specific project +// DELETE /api/workspaces/:slug/projects/:projectId/integrations/slack/channel/ +func (h *IntegrationHandler) SlackUnlinkChannel(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) + return + } + + if err := h.Integration.SlackUnlinkChannel(c.Request.Context(), c.Param("slug"), projectID, user.ID); err != nil { + writeIntegrationError(c, err) + return + } + c.JSON(http.StatusNoContent, nil) +} + // writeIntegrationError maps service errors to HTTP responses. func writeIntegrationError(c *gin.Context, err error) { switch { diff --git a/apps/api/internal/model/notification.go b/apps/api/internal/model/notification.go index a56a1c55..c8a90fae 100644 --- a/apps/api/internal/model/notification.go +++ b/apps/api/internal/model/notification.go @@ -54,6 +54,7 @@ const ( NotificationSenderCommented = "commented" NotificationSenderStateChanged = "state_changed" NotificationSenderSubscribed = "subscribed" + NotificationSenderCreated = "created" NotificationEntityIssue = "issue" ) diff --git a/apps/api/internal/model/slack.go b/apps/api/internal/model/slack.go new file mode 100644 index 00000000..bc2d4215 --- /dev/null +++ b/apps/api/internal/model/slack.go @@ -0,0 +1,46 @@ +package model + +import ( + "github.com/google/uuid" + "gorm.io/gorm" + "time" +) + +/* +SlackChannelLink is the per-project channel configuration for Slack notification +Matches table: "slack_channel_links" +*/ + +type SlackChannelLink struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + WorkspaceIntegrationID uuid.UUID `gorm:"column:workspace_integration_id;type:uuid;not null" json:"workspace_integration_id"` + ProjectID uuid.UUID `gorm:"column:project_id;type:uuid;not null" json:"project_id"` + WorkspaceID uuid.UUID `gorm:"column:workspace_id;type:uuid;not null" json:"workspace_id"` + ChannelID string `gorm:"column:channel_id;type:varchar(64);not null" json:"channel_id"` + ChannelName string `gorm:"column:channel_name;type:varchar(255);not null" json:"channel_name"` + Events JSONMap `gorm:"type:jsonb; default:'{}';serializer:json" json:"events"` + ActorID uuid.UUID `gorm:"column:actor_id;type:uuid;not null" json:"actor_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` +} + +/* +TableName tells Gorm the exact name of the table in Postgres +*/ +func (SlackChannelLink) TableName() string { + return "slack_channel_links" +} + +/* +BeforeCreate is a Gorm hook that aut-generates a UUID if one wasn't provided +*/ +func (s *SlackChannelLink) BeforeCreate(tx *gorm.DB) error { + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + + return nil +} diff --git a/apps/api/internal/oauth/slack.go b/apps/api/internal/oauth/slack.go new file mode 100644 index 00000000..55296ecd --- /dev/null +++ b/apps/api/internal/oauth/slack.go @@ -0,0 +1,58 @@ +package oauth + +import ( + "context" + "errors" + "net/url" +) + +const ( + slackAuthURL = "https://slack.com/oauth/v2/authorize" + slackTokenURL = "https://slack.com/api/oauth.v2.access" +) + +type SlackProvider struct { + cfg ProviderConfig +} + +func NewSlackProvider(cfg ProviderConfig) *SlackProvider { + return &SlackProvider{cfg: cfg} +} + +func (s *SlackProvider) Name() string { return "slack" } + +func (s *SlackProvider) AuthURL(state string) string { + params := url.Values{ + "client_id": {s.cfg.ClientID}, + "redirect_uri": {s.cfg.RedirectURI}, + "state": {state}, + "scope": {"chat:write,channels:read,groups:read"}, + } + + return slackAuthURL + "?" + params.Encode() +} + +func (s *SlackProvider) Exchange(ctx context.Context, code string) (*TokenData, error) { + data := url.Values{ + "client_id": {s.cfg.ClientID}, + "client_secret": {s.cfg.ClientSecret}, + "code": {code}, + "redirect_uri": {s.cfg.RedirectURI}, + } + + resp, err := httpPostForm(ctx, slackTokenURL, data, map[string]string{"Accept": "application/json"}) + if err != nil { + return nil, err + } + + td := &TokenData{ + AccessToken: strVal(resp, "access_token"), + RefreshToken: strVal(resp, "refresh_token"), + } + + return td, nil +} + +func (s *SlackProvider) GetUserInfo(ctx context.Context, token *TokenData) (*UserInfo, error) { + return nil, errors.New("GetUserInfo is not implemented for Slack because it is only used for bot installation") +} diff --git a/apps/api/internal/queue/consumer.go b/apps/api/internal/queue/consumer.go index 3aadca01..08ee2eb4 100644 --- a/apps/api/internal/queue/consumer.go +++ b/apps/api/internal/queue/consumer.go @@ -160,6 +160,37 @@ func HandleWebhook(deliverer func(ctx context.Context, p WebhookPayload) error) } } +// HandleSlackPost parses slack_post task and runs the given poster. +func HandleSlackPost(log *slog.Logger, poster func(ctx context.Context, token, channelID, text string, blocks interface{}) error) TaskHandler { + return func(ctx context.Context, queue string, body []byte) error { + var msg struct { + Type string `json:"type"` + Payload SlackPostPayload `json:"payload"` + } + if err := json.Unmarshal(body, &msg); err != nil { + return err + } + if msg.Type != TaskSlackPost { + return nil + } + p := &msg.Payload + if log != nil { + log.Info("queue processing slack_post", "channel", p.ChannelID) + } + err := poster(ctx, p.Token, p.ChannelID, p.Text, p.Blocks) + if err != nil { + if log != nil { + log.Error("slack post failed", "channel", p.ChannelID, "error", err) + } + return err + } + if log != nil { + log.Info("slack post succeeded", "channel", p.ChannelID) + } + return nil + } +} + // HandleImport parses an import_run task and runs the given importer. func HandleImport(runner func(ctx context.Context, importerID string) error) TaskHandler { return func(ctx context.Context, queue string, body []byte) error { diff --git a/apps/api/internal/queue/queue.go b/apps/api/internal/queue/queue.go index 5395df4b..870f00cb 100644 --- a/apps/api/internal/queue/queue.go +++ b/apps/api/internal/queue/queue.go @@ -15,6 +15,7 @@ const ( QueueEmails = "devlane.emails" QueueWebhooks = "devlane.webhooks" QueueImports = "devlane.imports" + QueueSlack = "devlane.slack" QueueDefault = "devlane.default" ) @@ -22,6 +23,7 @@ const ( const ( TaskSendEmail = "send_email" TaskWebhookDeliver = "webhook_deliver" + TaskSlackPost = "slack_post" TaskImportRun = "import_run" ) @@ -51,6 +53,14 @@ type ImportPayload struct { ImporterID string `json:"importer_id"` } +// SlackPostPayload is the payload for a slack_post task. +type SlackPostPayload struct { + Token string `json:"token"` + ChannelID string `json:"channel_id"` + Text string `json:"text"` + Blocks interface{} `json:"blocks"` +} + // Publisher publishes tasks to RabbitMQ. type Publisher struct { ch *amqp.Channel @@ -60,13 +70,13 @@ type Publisher struct { // NewPublisher declares queues and returns a publisher. func NewPublisher(ch *amqp.Channel, log *slog.Logger) (*Publisher, error) { - for _, q := range []string{QueueEmails, QueueWebhooks, QueueImports, QueueDefault} { + for _, q := range []string{QueueEmails, QueueWebhooks, QueueImports, QueueSlack, QueueDefault} { if _, err := ch.QueueDeclare(q, true, false, false, false, nil); err != nil { return nil, fmt.Errorf("declare queue %s: %w", q, err) } } return &Publisher{ch: ch, log: log, queues: map[string]bool{ - QueueEmails: true, QueueWebhooks: true, QueueImports: true, QueueDefault: true, + QueueEmails: true, QueueWebhooks: true, QueueImports: true, QueueSlack: true, QueueDefault: true, }}, nil } @@ -119,3 +129,14 @@ func (p *Publisher) PublishImport(ctx context.Context, payload ImportPayload) er "payload": payload, }) } + +// PublishSlackPost enqueues a slack_post task. +func (p *Publisher) PublishSlackPost(ctx context.Context, payload SlackPostPayload) error { + if p.log != nil { + p.log.Debug("queue publish slack_post", "channel", payload.ChannelID) + } + return p.PublishJSON(ctx, QueueSlack, map[string]interface{}{ + "type": TaskSlackPost, + "payload": payload, + }) +} diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index 09c51d2e..1ab6aefe 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -91,6 +91,7 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) { // Integration stores integrationStore := store.NewIntegrationStore(cfg.DB) + slackChannelLinkStore := store.NewSlackChannelLinkStore(cfg.DB) workspaceIntegrationStore := store.NewWorkspaceIntegrationStore(cfg.DB) githubRepoStore := store.NewGithubRepositoryStore(cfg.DB) githubRepoSyncStore := store.NewGithubRepositorySyncStore(cfg.DB) @@ -175,6 +176,10 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) { notificationSvc.SetEmailLogStore(emailLogStore) notificationSvc.SetQueue(cfg.Queue) notificationSvc.SetAppBaseURL(appBaseURL) + + // Wire Slack notifications to the same publisher + notificationSvc.SetSlackQueue(cfg.Queue) + notificationSvc.SetSlackStores(slackChannelLinkStore, workspaceIntegrationStore) } issueSvc.SetNotificationService(notificationSvc) issueSvc.SetSubscriberStore(issueSubscriberStore) @@ -205,7 +210,7 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) { } integrationSvc := service.NewIntegrationService( - integrationStore, workspaceIntegrationStore, workspaceStore, instanceSettingStore, githubClient, + integrationStore, workspaceIntegrationStore, workspaceStore, instanceSettingStore, slackChannelLinkStore, githubClient, ) githubSyncSvc := service.NewGithubSyncService( integrationSvc, workspaceIntegrationStore, githubRepoStore, githubRepoSyncStore, @@ -551,6 +556,13 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) { api.GET("/workspaces/:slug/integrations/", integrationHandler.ListInstalled) api.DELETE("/workspaces/:slug/integrations/:provider/", integrationHandler.Uninstall) + // Slack Channel Link Routes + api.GET("/workspaces/:slug/integrations/slack/channels/", integrationHandler.SlackListChannels) + api.GET("/workspaces/:slug/projects/:projectId/integrations/slack/channel/", integrationHandler.SlackGetChannel) + api.POST("/workspaces/:slug/projects/:projectId/integrations/slack/channel/", integrationHandler.SlackLinkChannel) + api.PATCH("/workspaces/:slug/projects/:projectId/integrations/slack/channel/", integrationHandler.SlackUpdateChannel) + api.DELETE("/workspaces/:slug/projects/:projectId/integrations/slack/channel/", integrationHandler.SlackUnlinkChannel) + // GitHub-specific (workspace-level): list installation repos. api.GET("/workspaces/:slug/integrations/github/repositories/", integrationHandler.GitHubListRepositories) @@ -616,6 +628,10 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) { r.GET("/auth/github-app/install", middleware.RequireAuth(authSvc, cfg.Log), integrationHandler.GitHubInstallStart) r.GET("/auth/github-app/callback", middleware.RequireAuth(authSvc, cfg.Log), integrationHandler.GitHubInstallCallback) + // Slack App install flow. + r.GET("/auth/slack/install", middleware.RequireAuth(authSvc, cfg.Log), integrationHandler.SlackInstallStart) + r.GET("/auth/slack/callback", middleware.RequireAuth(authSvc, cfg.Log), integrationHandler.SlackInstallCallback) + // GitHub webhook receiver — public; HMAC-signature-verified. r.POST("/webhooks/github", integrationHandler.GitHubWebhook) r.POST("/webhooks/github/", integrationHandler.GitHubWebhook) diff --git a/apps/api/internal/service/integration.go b/apps/api/internal/service/integration.go index 90b7a856..04c03c3b 100644 --- a/apps/api/internal/service/integration.go +++ b/apps/api/internal/service/integration.go @@ -2,14 +2,18 @@ package service import ( "context" + "encoding/json" "errors" "fmt" + "net/http" "strconv" "strings" "github.com/Devlaner/devlane/api/internal/crypto" "github.com/Devlaner/devlane/api/internal/github" "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/oauth" + "github.com/Devlaner/devlane/api/internal/slack" "github.com/Devlaner/devlane/api/internal/store" "github.com/google/uuid" ) @@ -30,6 +34,7 @@ type IntegrationService struct { wis *store.WorkspaceIntegrationStore ws *store.WorkspaceStore set *store.InstanceSettingStore + sls *store.SlackChannelLinkStore githubClient *github.Client } @@ -41,9 +46,10 @@ func NewIntegrationService( wis *store.WorkspaceIntegrationStore, ws *store.WorkspaceStore, set *store.InstanceSettingStore, + sls *store.SlackChannelLinkStore, githubClient *github.Client, ) *IntegrationService { - return &IntegrationService{is: is, wis: wis, ws: ws, set: set, githubClient: githubClient} + return &IntegrationService{is: is, wis: wis, ws: ws, set: set, githubClient: githubClient, sls: sls} } // SetGitHubClient replaces the cached client (called when admin updates @@ -110,6 +116,206 @@ func (s *IntegrationService) GetByProvider(ctx context.Context, workspaceSlug, p return wi, nil } +func (s *IntegrationService) InstallSlack(ctx context.Context, workspaceSlug string, userID uuid.UUID, tokenData *oauth.TokenData) (*model.WorkspaceIntegration, error) { + w, err := s.ws.GetBySlug(ctx, workspaceSlug) + if err != nil { + return nil, ErrWorkspaceNotFound + } + + m, err := s.ws.GetMember(ctx, w.ID, userID) + if err != nil || m == nil || m.Role < model.RoleAdmin { + return nil, ErrWorkspaceForbidden + } + + slk, err := s.is.GetByProvider(ctx, "slack") + if err != nil { + return nil, ErrIntegrationNotFound + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://slack.com/api/auth.test", nil) + if err != nil { + return nil, fmt.Errorf("failed to create auth request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+tokenData.AccessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to verify slack token: %w", err) + } + defer resp.Body.Close() + + var authResult struct { + Ok bool `json:"ok"` + TeamID string `json:"team_id"` + Team string `json:"team"` + Error string `json:"error"` + } + + if err := json.NewDecoder(resp.Body).Decode(&authResult); err != nil { + return nil, fmt.Errorf("failed to parse auth response: %w", err) + } + + if !authResult.Ok { + return nil, fmt.Errorf("slack token verification failed: %s", authResult.Error) + } + + // Encrypt the token for safe storage + encryptedToken, err := crypto.Encrypt(tokenData.AccessToken) + if err != nil { + return nil, fmt.Errorf("failed to encrypt token: %w", err) + } + + // Upsert the WorkspaceIntegration + wi, err := s.wis.GetByWorkspaceAndProvider(ctx, w.ID, "slack") + if err != nil { + wi = &model.WorkspaceIntegration{ + WorkspaceID: w.ID, + IntegrationID: slk.ID, + ActorID: userID, + AccountLogin: authResult.Team, + Config: model.JSONMap{ + "bot_token": encryptedToken, + "team_id": authResult.TeamID, + }, + } + if createErr := s.wis.Create(ctx, wi); createErr != nil { + // There may be a soft-deleted row blocking the unique constraint. + // Try to revive it instead. + revived, reviveErr := s.wis.ReviveByWorkspaceAndIntegration(ctx, w.ID, slk.ID) + if reviveErr != nil || revived == nil { + return nil, fmt.Errorf("failed to save slack integration: %w", createErr) + } + wi = revived + // Fall through to update the revived row below. + } else { + return wi, nil + } + } + + // Already exists (or just revived), update the token, name, and actor + if wi.Config == nil { + wi.Config = make(model.JSONMap) + } + wi.Config["bot_token"] = encryptedToken + wi.Config["team_id"] = authResult.TeamID + wi.AccountLogin = authResult.Team + wi.ActorID = userID + + if err := s.wis.Update(ctx, wi); err != nil { + return nil, fmt.Errorf("failed to update slack integration: %w", err) + } + + return wi, nil +} + +func (s *IntegrationService) SlackListChannels(ctx context.Context, workspaceSlug string, userID uuid.UUID) ([]slack.Channel, error) { + w, err := s.ws.GetBySlug(ctx, workspaceSlug) + if err != nil { + return nil, ErrWorkspaceNotFound + } + + ok, _ := s.ws.IsMember(ctx, w.ID, userID) + if !ok { + return nil, ErrWorkspaceForbidden + } + + wi, err := s.wis.GetByWorkspaceAndProvider(ctx, w.ID, "slack") + if err != nil { + return nil, ErrIntegrationNotFound + } + + rawToken, ok := wi.Config["bot_token"].(string) + if !ok || rawToken == "" { + return nil, errors.New("slack bot token is missing or invalid") + } + + token := crypto.DecryptOrPlain(rawToken) + slackClient := slack.NewClient(token) + + return slackClient.ListChannels(ctx) +} + +func (s *IntegrationService) SlackLinkChannel(ctx context.Context, workspaceSlug string, projectID, userID uuid.UUID, channelID, channelName string) (*model.SlackChannelLink, error) { + w, err := s.ws.GetBySlug(ctx, workspaceSlug) + if err != nil { + return nil, ErrWorkspaceNotFound + } + m, err := s.ws.GetMember(ctx, w.ID, userID) + if err != nil || m == nil || m.Role < model.RoleAdmin { + return nil, ErrWorkspaceForbidden + } + wi, err := s.wis.GetByWorkspaceAndProvider(ctx, w.ID, "slack") + if err != nil { + return nil, ErrIntegrationNotFound + } + + link := &model.SlackChannelLink{ + WorkspaceIntegrationID: wi.ID, + ProjectID: projectID, + WorkspaceID: w.ID, + ChannelID: channelID, + ChannelName: channelName, + ActorID: userID, + Events: model.JSONMap{"created": true, "state_changed": true, "commented": true}, + } + + // Ensure we only have one active channel per project + _ = s.sls.SoftDelete(ctx, projectID) + + if err := s.sls.Create(ctx, link); err != nil { + return nil, err + } + return link, nil +} + +func (s *IntegrationService) SlackGetChannel(ctx context.Context, workspaceSlug string, projectID, userID uuid.UUID) (*model.SlackChannelLink, error) { + w, err := s.ws.GetBySlug(ctx, workspaceSlug) + if err != nil { + return nil, ErrWorkspaceNotFound + } + ok, _ := s.ws.IsMember(ctx, w.ID, userID) + if !ok { + return nil, ErrWorkspaceForbidden + } + return s.sls.GetByProject(ctx, projectID) +} + +func (s *IntegrationService) SlackUpdateChannel(ctx context.Context, workspaceSlug string, projectID, userID uuid.UUID, events model.JSONMap) (*model.SlackChannelLink, error) { + w, err := s.ws.GetBySlug(ctx, workspaceSlug) + if err != nil { + return nil, ErrWorkspaceNotFound + } + m, err := s.ws.GetMember(ctx, w.ID, userID) + if err != nil || m == nil || m.Role < model.RoleAdmin { + return nil, ErrWorkspaceForbidden + } + + link, err := s.sls.GetByProject(ctx, projectID) + if err != nil { + return nil, err + } + + link.Events = events + link.ActorID = userID + + if err := s.sls.Update(ctx, link); err != nil { + return nil, err + } + return link, nil +} + +func (s *IntegrationService) SlackUnlinkChannel(ctx context.Context, workspaceSlug string, projectID, userID uuid.UUID) error { + w, err := s.ws.GetBySlug(ctx, workspaceSlug) + if err != nil { + return ErrWorkspaceNotFound + } + m, err := s.ws.GetMember(ctx, w.ID, userID) + if err != nil || m == nil || m.Role < model.RoleAdmin { + return ErrWorkspaceForbidden + } + return s.sls.SoftDelete(ctx, projectID) +} + // InstallGitHub creates (or updates) a workspace_integrations row for a fresh // GitHub App installation. Called from the App callback after the user // completes the install flow on github.com. diff --git a/apps/api/internal/service/issue.go b/apps/api/internal/service/issue.go index 5d651e82..812e6a1f 100644 --- a/apps/api/internal/service/issue.go +++ b/apps/api/internal/service/issue.go @@ -646,6 +646,9 @@ func (s *IssueService) Create(ctx context.Context, workspaceSlug string, project } } } + if s.notify != nil { + s.notify.IssueCreated(ctx, issue, userID) + } s.dispatchIssueWebhook(ctx, issue, "created") return issue, nil } diff --git a/apps/api/internal/service/notification.go b/apps/api/internal/service/notification.go index f3e4a907..33e045b8 100644 --- a/apps/api/internal/service/notification.go +++ b/apps/api/internal/service/notification.go @@ -8,9 +8,11 @@ import ( "strings" "time" + "github.com/Devlaner/devlane/api/internal/crypto" "github.com/Devlaner/devlane/api/internal/mail" "github.com/Devlaner/devlane/api/internal/model" "github.com/Devlaner/devlane/api/internal/queue" + "github.com/Devlaner/devlane/api/internal/slack" "github.com/Devlaner/devlane/api/internal/store" "github.com/google/uuid" ) @@ -23,18 +25,21 @@ import ( // own DB writes succeed. emit() returns are logged and swallowed: a transient // notifications-table failure must not roll back the user's actual change. type NotificationService struct { - ns *store.NotificationStore - ws *store.WorkspaceStore - is *store.IssueStore // for assignee + creator lookups (receiver computation) - ps *store.ProjectStore // for project-membership filter - us *store.UserStore // for actor display name - ss *store.StateStore // for state name resolution in Message payload - subs *store.IssueSubscriberStore // optional — subscriber-based receivers - prefs *store.UserNotificationPreferenceStore // optional — preference gating - log *slog.Logger - emailLog *store.EmailNotificationLogStore // optional — email notification audit logging - queue *queue.Publisher // optional — RabbitMQ publisher for email notifications - appURL string // optional — base URL for issue links in notification emails + ns *store.NotificationStore + ws *store.WorkspaceStore + is *store.IssueStore // for assignee + creator lookups (receiver computation) + ps *store.ProjectStore // for project-membership filter + us *store.UserStore // for actor display name + ss *store.StateStore // for state name resolution in Message payload + subs *store.IssueSubscriberStore // optional — subscriber-based receivers + prefs *store.UserNotificationPreferenceStore // optional — preference gating + log *slog.Logger + emailLog *store.EmailNotificationLogStore // optional — email notification audit logging + queue *queue.Publisher // optional — RabbitMQ publisher for email notifications + slackQueue *queue.Publisher // optional — RabbitMQ publisher for slack notifications + slackStore *store.SlackChannelLinkStore // optional + wintegStore *store.WorkspaceIntegrationStore // optional + appURL string // optional — base URL for issue links in notification emails } func NewNotificationService( @@ -80,6 +85,17 @@ func (s *NotificationService) SetQueue(q *queue.Publisher) { s.queue = q } +// SetSlackQueue wires the RabbitMQ publisher for slack notifications. Optional. +func (s *NotificationService) SetSlackQueue(q *queue.Publisher) { + s.slackQueue = q +} + +// SetSlackStores wires the stores needed for Slack notifications. Optional. +func (s *NotificationService) SetSlackStores(sl *store.SlackChannelLinkStore, wi *store.WorkspaceIntegrationStore) { + s.slackStore = sl + s.wintegStore = wi +} + // SetAppBaseURL wires the base URL for issue links in notification emails. Optional. func (s *NotificationService) SetAppBaseURL(url string) { s.appURL = url @@ -241,6 +257,19 @@ func (s *NotificationService) emit(ctx context.Context, receivers []uuid.UUID, p if params.issue == nil { return } + + // Queue slack notifications if available. Slack notifications go to a + // channel (not individual users), so they must fire regardless of the + // in-app receiver list. We do this first so that even a solo developer + // (who is excluded from their own in-app notifications) still gets + // messages posted to the linked Slack channel. + if s.slackQueue != nil && s.slackStore != nil && s.wintegStore != nil && s.appURL != "" { + actorName := s.actorDisplayName(ctx, params.actorID) + projectIdent := s.projectIdentifier(ctx, params.issue.ProjectID) + issueRef := fmt.Sprintf("%s-%d", projectIdent, params.issue.SequenceID) + s.enqueueSlackNotifications(ctx, params, actorName, issueRef) + } + if len(receivers) == 0 { return } @@ -441,6 +470,66 @@ func (s *NotificationService) enqueueNotificationEmails(ctx context.Context, rec } } +func (s *NotificationService) enqueueSlackNotifications(ctx context.Context, params emitParams, actorName, issueRef string) { + if params.issue == nil { + return + } + + var action string + var eventType string + switch params.sender { + case model.NotificationSenderCreated: + action = "created" + eventType = "created" + case model.NotificationSenderAssigned: + action = "was assigned" + eventType = "state_changed" // fallback to state_changed + case model.NotificationSenderStateChanged: + action = "moved to " + params.after + eventType = "state_changed" + case model.NotificationSenderCommented, model.NotificationSenderMentioned: + action = "was commented on" + eventType = "commented" + case model.NotificationSenderSubscribed: + action = "changed " + params.field + " to " + params.after + eventType = "state_changed" // fallback to state_changed config toggle + default: + return + } + + link, err := s.slackStore.GetByProject(ctx, params.issue.ProjectID) + if err != nil || link == nil { + return + } + + if link.Events != nil { + if enabled, ok := link.Events[eventType].(bool); ok && !enabled { + return + } + } + + winteg, err := s.wintegStore.GetByID(ctx, link.WorkspaceIntegrationID) + if err != nil || winteg == nil || winteg.Config == nil { + return + } + + rawToken, ok := winteg.Config["bot_token"].(string) + if !ok || rawToken == "" { + return + } + token := crypto.DecryptOrPlain(rawToken) + + issueURL := fmt.Sprintf("%s/issue/%s", strings.TrimSuffix(s.appURL, "/"), params.issue.ID) + text, blocks := slack.BuildSlackMessage(issueRef, params.issue.Name, actorName, action, issueURL) + + _ = s.slackQueue.PublishSlackPost(ctx, queue.SlackPostPayload{ + Token: token, + ChannelID: link.ChannelID, + Text: text, + Blocks: blocks, + }) +} + // actorDisplayName returns the user's display name, falling back through // first+last name → username → "Someone". func (s *NotificationService) actorDisplayName(ctx context.Context, id uuid.UUID) string { @@ -504,6 +593,19 @@ func (s *NotificationService) computeIssueReceivers(ctx context.Context, issue * // ----- Public emitters ---------------------------------------------------- +// IssueCreated notifies linked Slack channels when an issue is created. +// (It intentionally sends no in-app notifications, since the creator already knows). +func (s *NotificationService) IssueCreated(ctx context.Context, issue *model.Issue, actorID uuid.UUID) { + if issue == nil { + return + } + s.emit(ctx, []uuid.UUID{}, emitParams{ + issue: issue, + actorID: actorID, + sender: model.NotificationSenderCreated, + }) +} + // IssueAssigned notifies the newly-added assignees. Receivers = added IDs only. func (s *NotificationService) IssueAssigned(ctx context.Context, issue *model.Issue, actorID uuid.UUID, added []uuid.UUID) { if issue == nil || len(added) == 0 { diff --git a/apps/api/internal/slack/client.go b/apps/api/internal/slack/client.go new file mode 100644 index 00000000..bf548591 --- /dev/null +++ b/apps/api/internal/slack/client.go @@ -0,0 +1,97 @@ +package slack + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" +) + +type Channel struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type Client struct { + token string +} + +func NewClient(token string) *Client { + return &Client{token: token} +} + +func (c *Client) ListChannels(ctx context.Context) ([]Channel, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "https://slack.com/api/conversations.list?types=public_channel,private_channel", + nil, + ) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+c.token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Ok bool `json:"ok"` + Error string `json:"error"` + Channels []Channel `json:"channels"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + if !result.Ok { + return nil, fmt.Errorf("slack api error: %s", result.Error) + } + + return result.Channels, nil +} + +func PostMessage(ctx context.Context, token, channelID, text string, blocks interface{}) error { + payload := map[string]interface{}{ + "channel": channelID, + "text": text, + } + if blocks != nil { + payload["blocks"] = blocks + } + + data, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://slack.com/api/chat.postMessage", bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + var result struct { + Ok bool `json:"ok"` + Error string `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return err + } + if !result.Ok { + return fmt.Errorf("slack chat.postMessage error: %s", result.Error) + } + return nil +} diff --git a/apps/api/internal/slack/notification.go b/apps/api/internal/slack/notification.go new file mode 100644 index 00000000..c7b29f37 --- /dev/null +++ b/apps/api/internal/slack/notification.go @@ -0,0 +1,21 @@ +package slack + +import ( + "fmt" +) + +// BuildSlackMessage returns a formatted Slack text and a set of Block Kit blocks +// describing an issue event. +func BuildSlackMessage(issueRef, title, actor, action, link string) (string, interface{}) { + text := fmt.Sprintf("[%s] %s %s by %s", issueRef, title, action, actor) + blocks := []map[string]interface{}{ + { + "type": "section", + "text": map[string]interface{}{ + "type": "mrkdwn", + "text": fmt.Sprintf("*<%s|[%s] %s>* \n%s by %s", link, issueRef, title, action, actor), + }, + }, + } + return text, blocks +} diff --git a/apps/api/internal/store/integration.go b/apps/api/internal/store/integration.go index 22af7a18..fb0f94ad 100644 --- a/apps/api/internal/store/integration.go +++ b/apps/api/internal/store/integration.go @@ -44,6 +44,26 @@ func (s *WorkspaceIntegrationStore) Update(ctx context.Context, w *model.Workspa return s.db.WithContext(ctx).Save(w).Error } +// ReviveByWorkspaceAndIntegration finds a soft-deleted row matching the unique +// constraint (workspace_id, integration_id), clears deleted_at, and returns it. +// This handles the case where a previous Uninstall soft-deleted the row but the +// unique index still blocks a new INSERT. +func (s *WorkspaceIntegrationStore) ReviveByWorkspaceAndIntegration(ctx context.Context, workspaceID, integrationID uuid.UUID) (*model.WorkspaceIntegration, error) { + var w model.WorkspaceIntegration + err := s.db.WithContext(ctx).Unscoped(). + Where("workspace_id = ? AND integration_id = ? AND deleted_at IS NOT NULL", workspaceID, integrationID). + First(&w).Error + if err != nil { + return nil, err + } + // Clear soft-delete + w.DeletedAt.Valid = false + if err := s.db.WithContext(ctx).Unscoped().Model(&w).Update("deleted_at", nil).Error; err != nil { + return nil, err + } + return &w, nil +} + func (s *WorkspaceIntegrationStore) GetByID(ctx context.Context, id uuid.UUID) (*model.WorkspaceIntegration, error) { var w model.WorkspaceIntegration err := s.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&w).Error diff --git a/apps/api/internal/store/slack.go b/apps/api/internal/store/slack.go new file mode 100644 index 00000000..44f1948d --- /dev/null +++ b/apps/api/internal/store/slack.go @@ -0,0 +1,67 @@ +package store + +import ( + "context" + "github.com/Devlaner/devlane/api/internal/model" + "github.com/google/uuid" + "gorm.io/gorm" +) + +type SlackChannelLinkStore struct { + db *gorm.DB +} + +func NewSlackChannelLinkStore(db *gorm.DB) *SlackChannelLinkStore { + return &SlackChannelLinkStore{db: db} + +} + +/* +Create links a new Slack channel to a project +*/ +func (s *SlackChannelLinkStore) Create(ctx context.Context, link *model.SlackChannelLink) error { + return s.db.WithContext(ctx).Create(link).Error +} + +/* +Update saves changes (like event toggles) +*/ +func (s *SlackChannelLinkStore) Update(ctx context.Context, link *model.SlackChannelLink) error { + return s.db.WithContext(ctx).Save(link).Error +} + +/* +GetByProject fetches the linked Slack channel for a specific project +*/ +func (s *SlackChannelLinkStore) GetByProject(ctx context.Context, projectID uuid.UUID) (*model.SlackChannelLink, error) { + var link model.SlackChannelLink + err := s.db.WithContext(ctx).Where("project_id = ? AND deleted_at IS NULL", projectID).First(&link).Error + if err != nil { + return nil, err + } + + return &link, nil +} + +/* +ListByWorkspaceIntegration find all linked channels for a specific Slack installation +*/ +func (s *SlackChannelLinkStore) ListByWorkspaceIntegration(ctx context.Context, workspaceIntegrationID uuid.UUID) ([]model.SlackChannelLink, error) { + var list []model.SlackChannelLink + err := s.db.WithContext(ctx).Where("workspace_integration_id = ? AND deleted_at IS NULL", workspaceIntegrationID).Find(&list).Error + return list, err +} + +/* +SoftDelete unlinks a channel from a project by setting deleted_at +*/ +func (s *SlackChannelLinkStore) SoftDelete(ctx context.Context, projectID uuid.UUID) error { + return s.db.WithContext(ctx).Where("project_id = ? AND deleted_at IS NULL", projectID).Delete(&model.SlackChannelLink{}).Error +} + +/* +DeleteByWorkspaceIntegration cascading delete when a user unistalls the Slack app +*/ +func (s *SlackChannelLinkStore) DeleteByWorkspaceIntegration(ctx context.Context, WorkspaceIntegratonID uuid.UUID) error { + return s.db.WithContext(ctx).Where("workspace_integration_id = ?", WorkspaceIntegratonID).Delete(&model.SlackChannelLink{}).Error +} diff --git a/apps/api/migrations/000013_slack_integration.down.sql b/apps/api/migrations/000013_slack_integration.down.sql new file mode 100644 index 00000000..f5dbac3d --- /dev/null +++ b/apps/api/migrations/000013_slack_integration.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS slack_channel_links; +DELETE FROM integrations WHERE provider = 'slack'; diff --git a/apps/api/migrations/000013_slack_integration.up.sql b/apps/api/migrations/000013_slack_integration.up.sql new file mode 100644 index 00000000..80ec48f7 --- /dev/null +++ b/apps/api/migrations/000013_slack_integration.up.sql @@ -0,0 +1,29 @@ +INSERT INTO integrations (id, title, provider, network, verified, created_at, updated_at) +VALUES ( + gen_random_uuid(), + 'Slack', + 'slack', + 1, + true, + now(), + now() +) +ON CONFLICT (provider) DO NOTHING; + +-- Create slack_channel_links table -- +CREATE TABLE slack_channel_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_integration_id UUID NOT NULL REFERENCES workspace_integrations (id) ON DELETE CASCADE, + project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + channel_id VARCHAR(64) NOT NULL, + channel_name VARCHAR(255) NOT NULL, + events JSONB NOT NULL DEFAULT '{}', + actor_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + created_by_id UUID, + updated_by_id UUID, + UNIQUE (project_id, deleted_at) +); diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index ddaae18c..72ee42ca 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -607,6 +607,13 @@ export interface InstanceGitHubAppSection { webhook_secret_set?: boolean; } +/** Slack App config (instance admin). Secrets are never echoed back. */ +export interface InstanceSlackAppSection { + client_id?: string; + client_secret?: string; + client_secret_set?: boolean; +} + /** Available integration provider, returned by GET /api/integrations/. */ export interface IntegrationApiResponse { id: string; @@ -734,6 +741,26 @@ export interface GitHubRepositorySyncResponse { } | null; } +/** Slack channel from the Slack API */ +export interface SlackChannel { + id: string; + name: string; +} + +/** A linked Slack channel for a project */ +export interface SlackChannelLinkResponse { + id: string; + workspace_integration_id: string; + project_id: string; + workspace_id: string; + channel_id: string; + channel_name: string; + events: Record; + actor_id: string; + created_at: string; + updated_at: string; +} + /** Cycle as returned by the API */ export interface CycleApiResponse { id: string; diff --git a/apps/web/src/components/integrations/IntegrationsSection.tsx b/apps/web/src/components/integrations/IntegrationsSection.tsx index 608985ff..573e4aa7 100644 --- a/apps/web/src/components/integrations/IntegrationsSection.tsx +++ b/apps/web/src/components/integrations/IntegrationsSection.tsx @@ -6,11 +6,13 @@ import { Button, Card, CardContent, Badge, Modal } from '../ui'; import { integrationService } from '../../services/integrationService'; import { getApiErrorMessage } from '../../api/client'; import { RepoSyncSettingsModal } from './RepoSyncSettingsModal'; +import { SlackChannelSettingsModal } from './SlackChannelSettingsModal'; import type { GitHubRepositoryApiResponse, GitHubRepositorySyncResponse, ProjectApiResponse, WorkspaceIntegrationApiResponse, + SlackChannelLinkResponse, } from '../../api/types'; const IconGitHub = () => ( @@ -19,6 +21,12 @@ const IconGitHub = () => ( ); +const IconSlack = () => ( + + + +); + interface IntegrationsSectionProps { workspaceSlug: string; projects: ProjectApiResponse[]; @@ -46,6 +54,15 @@ export function IntegrationsSection({ workspaceSlug, projects }: IntegrationsSec Record >({}); + // Slack project links + const [slackProjectLinks, setSlackProjectLinks] = useState< + Record + >({}); + const [slackSettingsOpenForProjectId, setSlackSettingsOpenForProjectId] = useState( + null, + ); + const [slackDisconnecting, setSlackDisconnecting] = useState(false); + // Repo link modal state. const [linkModalOpen, setLinkModalOpen] = useState(false); const [linkingProjectId, setLinkingProjectId] = useState(null); @@ -62,8 +79,10 @@ export function IntegrationsSection({ workspaceSlug, projects }: IntegrationsSec () => installed.find((wi) => wi.provider === 'github') ?? null, [installed], ); + const slack = useMemo(() => installed.find((wi) => wi.provider === 'slack') ?? null, [installed]); const isConnected = !!github; + const isSlackConnected = !!slack; // Surface OAuth callback redirect outcome (?connected=github or ?error=...). useEffect(() => { @@ -74,6 +93,11 @@ export function IntegrationsSection({ workspaceSlug, projects }: IntegrationsSec const next = new URLSearchParams(searchParams); next.delete('connected'); setSearchParams(next, { replace: true }); + } else if (connected === 'slack') { + setSuccess(t('integrations.slack.connected', 'Slack connected.')); + const next = new URLSearchParams(searchParams); + next.delete('connected'); + setSearchParams(next, { replace: true }); } else if (errParam) { setError(errParam); const next = new URLSearchParams(searchParams); @@ -128,6 +152,31 @@ export function IntegrationsSection({ workspaceSlug, projects }: IntegrationsSec }; }, [workspaceSlug, isConnected, projects]); + // When connected, hydrate per-project slack links. + useEffect(() => { + if (!isSlackConnected || projects.length === 0) { + setSlackProjectLinks({}); + return; + } + let cancelled = false; + Promise.all( + projects.map((p) => + integrationService + .slackGetProjectChannel(workspaceSlug, p.id) + .then((r) => [p.id, r] as const) + .catch(() => [p.id, null] as const), + ), + ).then((entries) => { + if (cancelled) return; + const next: Record = {}; + for (const [pid, r] of entries) next[pid] = r; + setSlackProjectLinks(next); + }); + return () => { + cancelled = true; + }; + }, [workspaceSlug, isSlackConnected, projects]); + const handleConnect = () => { // Top-level navigation — GitHub will redirect us back to //settings?section=integrations. window.location.href = integrationService.githubInstallUrl(workspaceSlug); @@ -147,7 +196,7 @@ export function IntegrationsSection({ workspaceSlug, projects }: IntegrationsSec setError(''); try { await integrationService.uninstall(workspaceSlug, 'github'); - setInstalled([]); + setInstalled((prev) => prev.filter((i) => i.provider !== 'github')); setProjectSyncs({}); setSuccess(t('integrations.github.disconnected', 'GitHub disconnected.')); } catch (e) { @@ -157,6 +206,34 @@ export function IntegrationsSection({ workspaceSlug, projects }: IntegrationsSec } }; + const handleSlackConnect = () => { + window.location.href = integrationService.slackInstallUrl(workspaceSlug); + }; + + const handleSlackDisconnect = async () => { + if ( + !confirm( + t( + 'integrations.slack.disconnectConfirm', + 'Disconnect Slack from this workspace? Project channels will be unlinked.', + ), + ) + ) + return; + setSlackDisconnecting(true); + setError(''); + try { + await integrationService.uninstall(workspaceSlug, 'slack'); + setInstalled((prev) => prev.filter((i) => i.provider !== 'slack')); + setSlackProjectLinks({}); + setSuccess(t('integrations.slack.disconnected', 'Slack disconnected.')); + } catch (e) { + setError(getApiErrorMessage(e)); + } finally { + setSlackDisconnecting(false); + } + }; + const openLinkModal = async (projectId: string) => { setLinkingProjectId(projectId); setLinkModalOpen(true); @@ -420,6 +497,154 @@ export function IntegrationsSection({ workspaceSlug, projects }: IntegrationsSec )} + {/* --- Slack Section --- */} +
+

+ {t('integrations.communications', 'Communications')} +

+ + +
+ + + +
+
+

+ {t('integrations.slack.name', 'Slack')} +

+ {isSlackConnected ? ( + + {t('integrations.status.connected', 'Connected')} + + ) : ( + + {t('integrations.status.available', 'Available')} + + )} + {slack?.suspended_at && ( + + {t('integrations.status.suspended', 'Suspended')} + + )} +
+

+ {t( + 'integrations.slack.description', + 'Push issue events directly to Slack channels. Get notified when issues are created, move across states, or receive comments.', + )} +

+ {!isSlackConnected && ( +
    +
  • + •{' '} + {t( + 'integrations.slack.feature.notify', + 'Route notifications to project-specific channels', + )} +
  • +
  • + •{' '} + {t( + 'integrations.slack.feature.events', + 'Customize which events trigger a message', + )} +
  • +
+ )} +
+
+
+ {loading ? ( + + ) : isSlackConnected ? ( + + ) : ( + + )} +
+
+
+
+ + {isSlackConnected && projects.length > 0 && ( +
+

+ {t('integrations.slack.linkedChannels', 'Linked channels')} +

+ + +
    + {projects.map((p) => { + const link = slackProjectLinks[p.id]; + return ( +
  • +
    +

    + {p.name} + {p.identifier ? ( + + {p.identifier} + + ) : null} +

    + {link ? ( +

    + #{link.channel_name} +

    + ) : ( +

    + {t('integrations.slack.noChannelLinked', 'No channel linked.')} +

    + )} +
    + {link ? ( +
    + +
    + ) : ( + + )} +
  • + ); + })} +
+
+
+
+ )} + + {/* --- Modals --- */} { @@ -513,6 +738,22 @@ export function IntegrationsSection({ workspaceSlug, projects }: IntegrationsSec /> ); })()} + + {slackSettingsOpenForProjectId && + (() => { + const proj = projects.find((p) => p.id === slackSettingsOpenForProjectId); + if (!proj) return null; + return ( + setSlackSettingsOpenForProjectId(null)} + workspaceSlug={workspaceSlug} + project={proj} + initialLink={slackProjectLinks[proj.id] ?? null} + onSaved={(next) => setSlackProjectLinks((prev) => ({ ...prev, [proj.id]: next }))} + /> + ); + })()} ); } diff --git a/apps/web/src/components/integrations/SlackChannelSettingsModal.tsx b/apps/web/src/components/integrations/SlackChannelSettingsModal.tsx new file mode 100644 index 00000000..05d04ad7 --- /dev/null +++ b/apps/web/src/components/integrations/SlackChannelSettingsModal.tsx @@ -0,0 +1,242 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Modal } from '../ui'; +import { integrationService } from '../../services/integrationService'; +import { getApiErrorMessage } from '../../api/client'; +import type { ProjectApiResponse, SlackChannel, SlackChannelLinkResponse } from '../../api/types'; + +interface SlackChannelSettingsModalProps { + open: boolean; + onClose: () => void; + workspaceSlug: string; + project: ProjectApiResponse; + initialLink: SlackChannelLinkResponse | null; + onSaved: (next: SlackChannelLinkResponse | null) => void; +} + +export function SlackChannelSettingsModal({ + open, + onClose, + workspaceSlug, + project, + initialLink, + onSaved, +}: SlackChannelSettingsModalProps) { + const { t } = useTranslation(); + + const [channels, setChannels] = useState([]); + const [loadingChannels, setLoadingChannels] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + // Form state + const [selectedChannelId, setSelectedChannelId] = useState(''); + + // Events + const [eventCreated, setEventCreated] = useState(true); + const [eventStateChanged, setEventStateChanged] = useState(true); + const [eventCommented, setEventCommented] = useState(true); + + useEffect(() => { + if (!open) return; + setError(''); + + if (initialLink) { + setEventCreated(initialLink.events?.created ?? true); + setEventStateChanged(initialLink.events?.state_changed ?? true); + setEventCommented(initialLink.events?.commented ?? true); + } else { + // Load channels to pick from + setLoadingChannels(true); + integrationService + .slackListChannels(workspaceSlug) + .then((list) => { + setChannels(list ?? []); + if (list && list.length > 0) { + setSelectedChannelId(list[0].id); + } + }) + .catch((e) => setError(getApiErrorMessage(e))) + .finally(() => setLoadingChannels(false)); + } + }, [open, workspaceSlug, initialLink]); + + const handleSave = async () => { + setError(''); + setSaving(true); + try { + if (initialLink) { + // Update events + const next = await integrationService.slackUpdateProjectChannel(workspaceSlug, project.id, { + created: eventCreated, + state_changed: eventStateChanged, + commented: eventCommented, + }); + onSaved(next); + } else { + // Link channel + if (!selectedChannelId) { + throw new Error('Please select a channel'); + } + const channelName = channels.find((c) => c.id === selectedChannelId)?.name || 'unknown'; + const next = await integrationService.slackLinkProjectChannel(workspaceSlug, project.id, { + channel_id: selectedChannelId, + channel_name: channelName, + }); + onSaved(next); + } + onClose(); + } catch (e) { + setError(getApiErrorMessage(e)); + } finally { + setSaving(false); + } + }; + + const handleUnlink = async () => { + if (!confirm(t('integrations.slack.unlinkConfirm', 'Unlink Slack channel from this project?'))) + return; + setError(''); + setSaving(true); + try { + await integrationService.slackUnlinkProjectChannel(workspaceSlug, project.id); + onSaved(null); + onClose(); + } catch (e) { + setError(getApiErrorMessage(e)); + } finally { + setSaving(false); + } + }; + + return ( + { + if (!saving) onClose(); + }} + title={t('integrations.slack.modalTitle', 'Slack notifications for {{name}}', { + name: project.name, + })} + footer={ +
+
+ {initialLink && ( + + )} +
+
+ + +
+
+ } + > +
+ {error && ( +
+ {error} +
+ )} + + {!initialLink ? ( +
+ + {loadingChannels ? ( +

{t('common.loading', 'Loading…')}

+ ) : channels.length > 0 ? ( + + ) : ( +

+ {t( + 'integrations.slack.noChannels', + 'No public channels found. Ensure the bot is invited to your Slack workspace.', + )} +

+ )} +
+ ) : ( +
+

+ {t('integrations.slack.linkedChannel', 'Linked Channel: #{{name}}', { + name: initialLink.channel_name, + })} +

+
+ )} + + {(initialLink || (!initialLink && channels.length > 0)) && ( +
+

+ {t('integrations.slack.notifyOn', 'Notify channel when:')} +

+ + + + + + +
+ )} +
+
+ ); +} diff --git a/apps/web/src/pages/instance-admin/InstanceAdminIntegrationsPage.tsx b/apps/web/src/pages/instance-admin/InstanceAdminIntegrationsPage.tsx index fd11e166..3c957631 100644 --- a/apps/web/src/pages/instance-admin/InstanceAdminIntegrationsPage.tsx +++ b/apps/web/src/pages/instance-admin/InstanceAdminIntegrationsPage.tsx @@ -6,7 +6,7 @@ import { Skeleton } from '../../components/ui'; import { instanceSettingsService } from '../../services/instanceService'; import { getApiErrorMessage } from '../../api/client'; import { useDocumentTitle } from '../../hooks/useDocumentTitle'; -import type { InstanceGitHubAppSection } from '../../api/types'; +import type { InstanceGitHubAppSection, InstanceSlackAppSection } from '../../api/types'; const IconGitHub = () => ( @@ -14,8 +14,14 @@ const IconGitHub = () => ( ); +const IconSlack = () => ( + + + +); + interface ProviderRow { - id: 'github'; + id: 'github' | 'slack'; name: string; desc: string; Icon: () => React.ReactElement; @@ -34,9 +40,14 @@ function isGitHubAppConfigured(s: InstanceGitHubAppSection): boolean { ); } +function isSlackAppConfigured(s: InstanceSlackAppSection): boolean { + return !!(s.client_id && s.client_secret_set); +} + export function InstanceAdminIntegrationsPage() { const { t } = useTranslation(); const [github, setGithub] = useState({}); + const [slack, setSlack] = useState({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); useDocumentTitle(t('instanceAdmin.integrations.documentTitle', 'Integrations')); @@ -49,6 +60,8 @@ export function InstanceAdminIntegrationsPage() { if (cancelled) return; const g = (settings.github_app || {}) as InstanceGitHubAppSection; setGithub(g); + const sl = (settings.slack_app || {}) as InstanceSlackAppSection; + setSlack(sl); }) .catch((err) => { if (!cancelled) setError(getApiErrorMessage(err)); @@ -73,6 +86,17 @@ export function InstanceAdminIntegrationsPage() { editPath: '/instance-admin/integrations/github', configured: isGitHubAppConfigured(github), }, + { + id: 'slack', + name: 'Slack', + desc: t( + 'instanceAdmin.integrations.slack.desc', + 'Push issue events directly to Slack channels. Get notified when issues are created, move across states, or receive comments.', + ), + Icon: IconSlack, + editPath: '/instance-admin/integrations/slack', + configured: isSlackAppConfigured(slack), + }, ]; if (loading) { diff --git a/apps/web/src/services/integrationService.ts b/apps/web/src/services/integrationService.ts index 5e95dfb8..8078d259 100644 --- a/apps/web/src/services/integrationService.ts +++ b/apps/web/src/services/integrationService.ts @@ -6,6 +6,8 @@ import type { GitHubRepositorySyncResponse, IntegrationApiResponse, WorkspaceIntegrationApiResponse, + SlackChannel, + SlackChannelLinkResponse, } from '../api/types'; const GITHUB_ISSUE_SUMMARY_BATCH_SIZE = 100; @@ -194,4 +196,70 @@ export const integrationService = { } return summary; }, + + /** + * Build the URL to start the Slack App install flow. + */ + slackInstallUrl(workspaceSlug: string): string { + const base = API_BASE || ''; + return `${base}/auth/slack/install?workspace=${encodeURIComponent(workspaceSlug)}`; + }, + + /** GET /api/workspaces/:slug/integrations/slack/channels/ */ + async slackListChannels(workspaceSlug: string): Promise { + const { data } = await apiClient.get( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/integrations/slack/channels/`, + ); + return data; + }, + + /** GET /api/workspaces/:slug/projects/:projectId/integrations/slack/channel/ */ + async slackGetProjectChannel( + workspaceSlug: string, + projectId: string, + ): Promise { + try { + const { data } = await apiClient.get( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/integrations/slack/channel/`, + ); + return data; + } catch (err) { + const e = err as { response?: { status?: number } }; + if (e?.response?.status === 404) return null; + throw err; + } + }, + + /** POST /api/workspaces/:slug/projects/:projectId/integrations/slack/channel/ */ + async slackLinkProjectChannel( + workspaceSlug: string, + projectId: string, + payload: { channel_id: string; channel_name: string }, + ): Promise { + const { data } = await apiClient.post( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/integrations/slack/channel/`, + payload, + ); + return data; + }, + + /** PATCH /api/workspaces/:slug/projects/:projectId/integrations/slack/channel/ */ + async slackUpdateProjectChannel( + workspaceSlug: string, + projectId: string, + events: Record, + ): Promise { + const { data } = await apiClient.patch( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/integrations/slack/channel/`, + { events }, + ); + return data; + }, + + /** DELETE /api/workspaces/:slug/projects/:projectId/integrations/slack/channel/ */ + async slackUnlinkProjectChannel(workspaceSlug: string, projectId: string): Promise { + await apiClient.delete( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/integrations/slack/channel/`, + ); + }, }; From dacfc95a5040100a131a970452c609869bfa60d6 Mon Sep 17 00:00:00 2001 From: Amit kumar Date: Thu, 30 Jul 2026 04:06:49 +0530 Subject: [PATCH 2/6] fix: resolve CodeRabbit suggestions for Slack integration --- apps/api/cmd/api/main.go | 2 +- apps/api/internal/queue/consumer.go | 28 +++++++++++++++++-- apps/api/internal/queue/queue.go | 8 +++--- apps/api/internal/service/notification.go | 19 ++++++++----- apps/api/internal/slack/notification.go | 15 ++++++++-- .../src/components/icons/IntegrationIcons.tsx | 11 ++++++++ .../integrations/IntegrationsSection.tsx | 12 +------- .../SlackChannelSettingsModal.tsx | 22 +++++++++++++-- .../InstanceAdminIntegrationsPage.tsx | 15 ++-------- apps/web/src/services/integrationService.ts | 3 +- 10 files changed, 91 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/components/icons/IntegrationIcons.tsx diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index b174eaa3..db44d24e 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -126,7 +126,7 @@ func main() { webhookDeliverer := service.NewWebhookDeliverer(store.NewWebhookStore(db), log) consumer.Register(queue.QueueWebhooks, queue.HandleWebhook(webhookDeliverer)) consumer.Register(queue.QueueImports, queue.HandleImport(importerSvc.Run)) - consumer.Register(queue.QueueSlack, queue.HandleSlackPost(log, slack.PostMessage)) + consumer.Register(queue.QueueSlack, queue.HandleSlackPost(log, store.NewWorkspaceIntegrationStore(db), slack.PostMessage)) if err := consumer.Run(consumerCtx, []string{queue.QueueEmails, queue.QueueWebhooks, queue.QueueImports, queue.QueueSlack}); err != nil { log.Warn("queue consumer", "error", err) } diff --git a/apps/api/internal/queue/consumer.go b/apps/api/internal/queue/consumer.go index 08ee2eb4..2b0a8e1a 100644 --- a/apps/api/internal/queue/consumer.go +++ b/apps/api/internal/queue/consumer.go @@ -3,9 +3,13 @@ package queue import ( "context" "encoding/json" + "fmt" "log/slog" + "github.com/Devlaner/devlane/api/internal/crypto" "github.com/Devlaner/devlane/api/internal/mail" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/google/uuid" amqp "github.com/rabbitmq/amqp091-go" ) @@ -161,7 +165,7 @@ func HandleWebhook(deliverer func(ctx context.Context, p WebhookPayload) error) } // HandleSlackPost parses slack_post task and runs the given poster. -func HandleSlackPost(log *slog.Logger, poster func(ctx context.Context, token, channelID, text string, blocks interface{}) error) TaskHandler { +func HandleSlackPost(log *slog.Logger, wintegStore *store.WorkspaceIntegrationStore, poster func(ctx context.Context, token, channelID, text string, blocks interface{}) error) TaskHandler { return func(ctx context.Context, queue string, body []byte) error { var msg struct { Type string `json:"type"` @@ -177,7 +181,27 @@ func HandleSlackPost(log *slog.Logger, poster func(ctx context.Context, token, c if log != nil { log.Info("queue processing slack_post", "channel", p.ChannelID) } - err := poster(ctx, p.Token, p.ChannelID, p.Text, p.Blocks) + + wiID, err := uuid.Parse(p.WorkspaceIntegrationID) + if err != nil { + return fmt.Errorf("invalid workspace integration ID: %w", err) + } + + winteg, err := wintegStore.GetByID(ctx, wiID) + if err != nil { + return fmt.Errorf("get workspace integration: %w", err) + } + if winteg == nil || winteg.Config == nil { + return fmt.Errorf("workspace integration not found or unconfigured") + } + + rawToken, ok := winteg.Config["bot_token"].(string) + if !ok || rawToken == "" { + return fmt.Errorf("no bot_token in workspace integration") + } + token := crypto.DecryptOrPlain(rawToken) + + err = poster(ctx, token, p.ChannelID, p.Text, p.Blocks) if err != nil { if log != nil { log.Error("slack post failed", "channel", p.ChannelID, "error", err) diff --git a/apps/api/internal/queue/queue.go b/apps/api/internal/queue/queue.go index 870f00cb..16f9f4a2 100644 --- a/apps/api/internal/queue/queue.go +++ b/apps/api/internal/queue/queue.go @@ -55,10 +55,10 @@ type ImportPayload struct { // SlackPostPayload is the payload for a slack_post task. type SlackPostPayload struct { - Token string `json:"token"` - ChannelID string `json:"channel_id"` - Text string `json:"text"` - Blocks interface{} `json:"blocks"` + WorkspaceIntegrationID string `json:"workspace_integration_id"` + ChannelID string `json:"channel_id"` + Text string `json:"text"` + Blocks interface{} `json:"blocks"` } // Publisher publishes tasks to RabbitMQ. diff --git a/apps/api/internal/service/notification.go b/apps/api/internal/service/notification.go index 33e045b8..ad7bb077 100644 --- a/apps/api/internal/service/notification.go +++ b/apps/api/internal/service/notification.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/Devlaner/devlane/api/internal/crypto" "github.com/Devlaner/devlane/api/internal/mail" "github.com/Devlaner/devlane/api/internal/model" "github.com/Devlaner/devlane/api/internal/queue" @@ -517,17 +516,23 @@ func (s *NotificationService) enqueueSlackNotifications(ctx context.Context, par if !ok || rawToken == "" { return } - token := crypto.DecryptOrPlain(rawToken) issueURL := fmt.Sprintf("%s/issue/%s", strings.TrimSuffix(s.appURL, "/"), params.issue.ID) text, blocks := slack.BuildSlackMessage(issueRef, params.issue.Name, actorName, action, issueURL) - _ = s.slackQueue.PublishSlackPost(ctx, queue.SlackPostPayload{ - Token: token, - ChannelID: link.ChannelID, - Text: text, - Blocks: blocks, + err = s.slackQueue.PublishSlackPost(ctx, queue.SlackPostPayload{ + WorkspaceIntegrationID: link.WorkspaceIntegrationID.String(), + ChannelID: link.ChannelID, + Text: text, + Blocks: blocks, }) + if err != nil { + s.log.Warn("Failed to enqueue Slack notification (dropped)", + "issue_id", params.issue.ID, + "project_id", params.issue.ProjectID, + "channel_id", link.ChannelID, + "error", err) + } } // actorDisplayName returns the user's display name, falling back through diff --git a/apps/api/internal/slack/notification.go b/apps/api/internal/slack/notification.go index c7b29f37..5dcc9f4e 100644 --- a/apps/api/internal/slack/notification.go +++ b/apps/api/internal/slack/notification.go @@ -2,18 +2,29 @@ package slack import ( "fmt" + "strings" ) +// escapeMrkdwn escapes Slack's restricted mrkdwn characters +func escapeMrkdwn(s string) string { + return strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(s) +} + // BuildSlackMessage returns a formatted Slack text and a set of Block Kit blocks // describing an issue event. func BuildSlackMessage(issueRef, title, actor, action, link string) (string, interface{}) { - text := fmt.Sprintf("[%s] %s %s by %s", issueRef, title, action, actor) + escRef := escapeMrkdwn(issueRef) + escTitle := escapeMrkdwn(title) + escActor := escapeMrkdwn(actor) + escAction := escapeMrkdwn(action) + + text := fmt.Sprintf("[%s] %s %s by %s", escRef, escTitle, escAction, escActor) blocks := []map[string]interface{}{ { "type": "section", "text": map[string]interface{}{ "type": "mrkdwn", - "text": fmt.Sprintf("*<%s|[%s] %s>* \n%s by %s", link, issueRef, title, action, actor), + "text": fmt.Sprintf("*<%s|[%s] %s>* \n%s by %s", link, escRef, escTitle, escAction, escActor), }, }, } diff --git a/apps/web/src/components/icons/IntegrationIcons.tsx b/apps/web/src/components/icons/IntegrationIcons.tsx new file mode 100644 index 00000000..bda0efa3 --- /dev/null +++ b/apps/web/src/components/icons/IntegrationIcons.tsx @@ -0,0 +1,11 @@ +export const IconGitHub = () => ( + + + +); + +export const IconSlack = () => ( + + + +); diff --git a/apps/web/src/components/integrations/IntegrationsSection.tsx b/apps/web/src/components/integrations/IntegrationsSection.tsx index 573e4aa7..563aca38 100644 --- a/apps/web/src/components/integrations/IntegrationsSection.tsx +++ b/apps/web/src/components/integrations/IntegrationsSection.tsx @@ -15,17 +15,7 @@ import type { SlackChannelLinkResponse, } from '../../api/types'; -const IconGitHub = () => ( - - - -); - -const IconSlack = () => ( - - - -); +import { IconGitHub, IconSlack } from '../icons/IntegrationIcons'; interface IntegrationsSectionProps { workspaceSlug: string; diff --git a/apps/web/src/components/integrations/SlackChannelSettingsModal.tsx b/apps/web/src/components/integrations/SlackChannelSettingsModal.tsx index 05d04ad7..a67454da 100644 --- a/apps/web/src/components/integrations/SlackChannelSettingsModal.tsx +++ b/apps/web/src/components/integrations/SlackChannelSettingsModal.tsx @@ -76,14 +76,30 @@ export function SlackChannelSettingsModal({ } else { // Link channel if (!selectedChannelId) { - throw new Error('Please select a channel'); + throw new Error( + t('integrations.slack.selectChannelRequired', 'Please select a channel.'), + ); + } + const channelName = channels.find((c) => c.id === selectedChannelId)?.name; + if (!channelName) { + throw new Error( + t('integrations.slack.selectChannelRequired', 'Please select a channel.'), + ); } - const channelName = channels.find((c) => c.id === selectedChannelId)?.name || 'unknown'; const next = await integrationService.slackLinkProjectChannel(workspaceSlug, project.id, { channel_id: selectedChannelId, channel_name: channelName, }); - onSaved(next); + const withEvents = await integrationService.slackUpdateProjectChannel( + workspaceSlug, + project.id, + { + created: eventCreated, + state_changed: eventStateChanged, + commented: eventCommented, + }, + ); + onSaved(withEvents ?? next); } onClose(); } catch (e) { diff --git a/apps/web/src/pages/instance-admin/InstanceAdminIntegrationsPage.tsx b/apps/web/src/pages/instance-admin/InstanceAdminIntegrationsPage.tsx index 3c957631..6418cf65 100644 --- a/apps/web/src/pages/instance-admin/InstanceAdminIntegrationsPage.tsx +++ b/apps/web/src/pages/instance-admin/InstanceAdminIntegrationsPage.tsx @@ -7,18 +7,7 @@ import { instanceSettingsService } from '../../services/instanceService'; import { getApiErrorMessage } from '../../api/client'; import { useDocumentTitle } from '../../hooks/useDocumentTitle'; import type { InstanceGitHubAppSection, InstanceSlackAppSection } from '../../api/types'; - -const IconGitHub = () => ( - - - -); - -const IconSlack = () => ( - - - -); +import { IconGitHub, IconSlack } from '../../components/icons/IntegrationIcons'; interface ProviderRow { id: 'github' | 'slack'; @@ -145,7 +134,7 @@ export function InstanceAdminIntegrationsPage() {

- {t('instanceAdmin.integrations.sourceControl', 'Source control')} + {t('instanceAdmin.integrations.providers', 'Available integrations')}

    {providers.map((p) => { diff --git a/apps/web/src/services/integrationService.ts b/apps/web/src/services/integrationService.ts index 8078d259..9f5d14cb 100644 --- a/apps/web/src/services/integrationService.ts +++ b/apps/web/src/services/integrationService.ts @@ -222,7 +222,8 @@ export const integrationService = { const { data } = await apiClient.get( `/api/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/integrations/slack/channel/`, ); - return data; + // Backend answers 200 + null body when no channel is linked. + return data ?? null; } catch (err) { const e = err as { response?: { status?: number } }; if (e?.response?.status === 404) return null; From b26b80cefd43ed3b6640af3399b53a3fa6262e18 Mon Sep 17 00:00:00 2001 From: Amit kumar Date: Thu, 30 Jul 2026 04:20:08 +0530 Subject: [PATCH 3/6] fix: resolve remaining CodeRabbit inline suggestions --- apps/api/internal/queue/consumer.go | 5 ++++- .../components/integrations/SlackChannelSettingsModal.tsx | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/api/internal/queue/consumer.go b/apps/api/internal/queue/consumer.go index 2b0a8e1a..b1cb1bef 100644 --- a/apps/api/internal/queue/consumer.go +++ b/apps/api/internal/queue/consumer.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "time" "github.com/Devlaner/devlane/api/internal/crypto" "github.com/Devlaner/devlane/api/internal/mail" @@ -201,7 +202,9 @@ func HandleSlackPost(log *slog.Logger, wintegStore *store.WorkspaceIntegrationSt } token := crypto.DecryptOrPlain(rawToken) - err = poster(ctx, token, p.ChannelID, p.Text, p.Blocks) + postCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + err = poster(postCtx, token, p.ChannelID, p.Text, p.Blocks) if err != nil { if log != nil { log.Error("slack post failed", "channel", p.ChannelID, "error", err) diff --git a/apps/web/src/components/integrations/SlackChannelSettingsModal.tsx b/apps/web/src/components/integrations/SlackChannelSettingsModal.tsx index a67454da..15af7196 100644 --- a/apps/web/src/components/integrations/SlackChannelSettingsModal.tsx +++ b/apps/web/src/components/integrations/SlackChannelSettingsModal.tsx @@ -90,6 +90,7 @@ export function SlackChannelSettingsModal({ channel_id: selectedChannelId, channel_name: channelName, }); + onSaved(next); const withEvents = await integrationService.slackUpdateProjectChannel( workspaceSlug, project.id, @@ -99,7 +100,7 @@ export function SlackChannelSettingsModal({ commented: eventCommented, }, ); - onSaved(withEvents ?? next); + onSaved(withEvents); } onClose(); } catch (e) { From 44cf807bfc7545d08f34c1acd316050bd3b1e290 Mon Sep 17 00:00:00 2001 From: Amit kumar Date: Thu, 30 Jul 2026 16:09:34 +0530 Subject: [PATCH 4/6] chore: remove redundant comment in CommentReactions --- apps/web/src/components/work-item/CommentReactions.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/components/work-item/CommentReactions.tsx b/apps/web/src/components/work-item/CommentReactions.tsx index 9be3f8b2..0ddd36cf 100644 --- a/apps/web/src/components/work-item/CommentReactions.tsx +++ b/apps/web/src/components/work-item/CommentReactions.tsx @@ -11,7 +11,6 @@ interface CommentReactionsProps { projectId: string; issueId: string; commentId: string; - /** ID of the current user — needed to know which reactions are "mine" so we can toggle. */ currentUserId?: string | null; } From af0924041afd5635749c366d9f9599111a280730 Mon Sep 17 00:00:00 2001 From: Amit kumar Date: Thu, 30 Jul 2026 16:12:10 +0530 Subject: [PATCH 5/6] chore: trigger github actions workflow From 1f5d99124d412c6068b2930a87362e587ed3b9a3 Mon Sep 17 00:00:00 2001 From: Amit kumar Date: Thu, 30 Jul 2026 16:16:16 +0530 Subject: [PATCH 6/6] fix: validate decrypted slack bot token is non-empty before posting --- apps/api/internal/queue/consumer.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/api/internal/queue/consumer.go b/apps/api/internal/queue/consumer.go index b1cb1bef..654f25aa 100644 --- a/apps/api/internal/queue/consumer.go +++ b/apps/api/internal/queue/consumer.go @@ -201,6 +201,9 @@ func HandleSlackPost(log *slog.Logger, wintegStore *store.WorkspaceIntegrationSt return fmt.Errorf("no bot_token in workspace integration") } token := crypto.DecryptOrPlain(rawToken) + if token == "" { + return fmt.Errorf("failed to decrypt workspace integration bot token") + } postCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel()