From 1b4a8beab1f08906e6c473a43396382049a88367 Mon Sep 17 00:00:00 2001 From: Deep Mistry Date: Tue, 17 Feb 2026 10:29:13 -0500 Subject: [PATCH 1/2] Add emoji-based Jira issue creation from Slack threads --- cmd/slack-bot/main.go | 2 +- pkg/jira/issues.go | 80 ++++++++++++++++++++++++++++ pkg/slack/events/jira/create.go | 75 ++++++++++++++++++++++++++ pkg/slack/events/jira/reaction.go | 62 ++++++++++++++++++++++ pkg/slack/events/jira/storage.go | 73 ++++++++++++++++++++++++++ pkg/slack/events/jira/summarizer.go | 79 ++++++++++++++++++++++++++++ pkg/slack/events/jira/thread.go | 81 +++++++++++++++++++++++++++++ pkg/slack/events/router/router.go | 47 ++++++++++++++++- 8 files changed, 497 insertions(+), 2 deletions(-) create mode 100644 pkg/slack/events/jira/create.go create mode 100644 pkg/slack/events/jira/reaction.go create mode 100644 pkg/slack/events/jira/storage.go create mode 100644 pkg/slack/events/jira/summarizer.go create mode 100644 pkg/slack/events/jira/thread.go diff --git a/cmd/slack-bot/main.go b/cmd/slack-bot/main.go index 8ea9f568df1..6fb878e975e 100644 --- a/cmd/slack-bot/main.go +++ b/cmd/slack-bot/main.go @@ -202,7 +202,7 @@ func main() { // handle the root to allow for a simple uptime probe mux.Handle("/", handler(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusOK) }))) mux.Handle("/slack/interactive-endpoint", handler(handleInteraction(secret.GetTokenGenerator(o.slackSigningSecretPath), interactionrouter.ForModals(issueFiler, slackClient)))) - mux.Handle("/slack/events-endpoint", handler(handleEvent(secret.GetTokenGenerator(o.slackSigningSecretPath), eventrouter.ForEvents(slackClient, kubeClient, configAgent.Config, gcsClient, keywordsConfig, o.helpdeskAlias, o.forumChannelId, o.reviewRequestWorkflowID, o.namespace, o.requireWorkflowsInForum)))) + mux.Handle("/slack/events-endpoint", handler(handleEvent(secret.GetTokenGenerator(o.slackSigningSecretPath), eventrouter.ForEvents(slackClient, kubeClient, configAgent.Config, gcsClient, keywordsConfig, o.helpdeskAlias, o.forumChannelId, o.reviewRequestWorkflowID, o.namespace, o.requireWorkflowsInForum, issueFiler)))) server := &http.Server{Addr: ":" + strconv.Itoa(o.port), Handler: mux} health.ServeReady() diff --git a/pkg/jira/issues.go b/pkg/jira/issues.go index 2c74f660538..3ebddf27402 100644 --- a/pkg/jira/issues.go +++ b/pkg/jira/issues.go @@ -26,6 +26,12 @@ type IssueFiler interface { FileIssue(issueType, title, description, reporter string, logger *logrus.Entry) (*jira.Issue, error) } +// IssueUpdater knows how to update and close Jira issues +type IssueUpdater interface { + AddComment(issueKey, comment string, logger *logrus.Entry) error + TransitionIssue(issueKey, transitionName string, logger *logrus.Entry) error +} + type slackClient interface { GetUserInfo(user string) (*slack.User, error) } @@ -60,6 +66,8 @@ type jiraClient interface { type filer struct { slackClient slackClient jiraClient jiraClient + // delegateClient is the underlying jira.Client for operations not covered by jiraClient interface + delegateClient *jira.Client // project caches metadata for the Jira project we create // issues under - this will never change so we can read it // once at startup and reuse it forever @@ -96,6 +104,76 @@ func (f *filer) FileIssue(issueType, title, description, reporter string, logger return issue, jirautil.HandleJiraError(response, err) } +// AddWatchers adds watchers to a Jira issue. +// watchers is a slice of Jira usernames (account IDs) to add as watchers. +func (f *filer) AddWatchers(issueKey string, watchers []string, logger *logrus.Entry) error { + if f.delegateClient == nil { + return fmt.Errorf("delegate client not available") + } + for _, watcher := range watchers { + response, err := f.delegateClient.Issue.AddWatcher(issueKey, watcher) + if err != nil { + if err := jirautil.HandleJiraError(response, err); err != nil { + logger.WithError(err).WithField("watcher", watcher).Warn("failed to add watcher to Jira issue") + // Continue with other watchers even if one fails + continue + } + } + logger.WithField("watcher", watcher).Debug("added watcher to Jira issue") + } + return nil +} + +// AddComment adds a comment to a Jira issue with private visibility for Red Hat employees only. +func (f *filer) AddComment(issueKey, comment string, logger *logrus.Entry) error { + if f.delegateClient == nil { + return fmt.Errorf("delegate client not available") + } + jiraComment := &jira.Comment{ + Body: comment, + Visibility: jira.CommentVisibility{ + Type: "group", + Value: "Red Hat Employee", + }, + } + _, response, err := f.delegateClient.Issue.AddComment(issueKey, jiraComment) + if err != nil { + return jirautil.HandleJiraError(response, err) + } + logger.WithField("issue", issueKey).Debug("added private comment to Jira issue") + return nil +} + +// TransitionIssue transitions a Jira issue to a new status. +func (f *filer) TransitionIssue(issueKey, transitionName string, logger *logrus.Entry) error { + if f.delegateClient == nil { + return fmt.Errorf("delegate client not available") + } + transitions, response, err := f.delegateClient.Issue.GetTransitions(issueKey) + if err != nil { + return jirautil.HandleJiraError(response, err) + } + var transitionID string + for _, t := range transitions { + if t.Name == transitionName { + transitionID = t.ID + break + } + } + if transitionID == "" { + return fmt.Errorf("transition %q not found for issue %s", transitionName, issueKey) + } + response, err = f.delegateClient.Issue.DoTransition(issueKey, transitionID) + if err != nil { + return jirautil.HandleJiraError(response, err) + } + logger.WithFields(logrus.Fields{ + "issue": issueKey, + "transition": transitionName, + }).Debug("transitioned Jira issue") + return nil +} + // resolveRequester attempts to get more information about the Slack // user that requested the Jira issue, doing everything best-effort func (f *filer) resolveRequester(reporter string, logger *logrus.Entry) (string, *jira.User) { @@ -123,6 +201,8 @@ func (f *filer) resolveRequester(reporter string, logger *logrus.Entry) (string, return suffix, requester } +var _ IssueFiler = (*filer)(nil) + func NewIssueFiler(slackClient *slack.Client, jiraClient *jira.Client) (IssueFiler, error) { filer := &filer{ slackClient: slackClient, diff --git a/pkg/slack/events/jira/create.go b/pkg/slack/events/jira/create.go new file mode 100644 index 00000000000..462966a1e1a --- /dev/null +++ b/pkg/slack/events/jira/create.go @@ -0,0 +1,75 @@ +package jira + +import ( + "context" + "fmt" + + "github.com/sirupsen/logrus" + "github.com/slack-go/slack" + + "github.com/openshift/ci-tools/pkg/jira" +) + +func handleCreateJira( + client *slack.Client, + issueFiler jira.IssueFiler, + threadMapping *ThreadJiraMapping, + channelID, threadTS, userID string, + logger *logrus.Entry, +) (bool, error) { + log := logger.WithField("action", "create-jira") + + // Check if thread already has Jira issue + ctx := context.Background() + if _, exists := threadMapping.Get(ctx, threadTS); exists { + log.Info("thread already has associated Jira issue") + _, _, err := client.PostMessage(channelID, + slack.MsgOptionText("This thread already has an associated Jira issue.", false), + slack.MsgOptionTS(threadTS)) + return true, err + } + + // Get thread content + messages, err := GetThreadContent(client, channelID, threadTS) + if err != nil { + log.WithError(err).Error("failed to get thread content") + return false, err + } + + if len(messages) == 0 { + log.Warn("thread has no messages") + return false, nil + } + + // Generate title and description (uses Shadowbot summary if available) + title, description := GetSummary(client, channelID, threadTS, messages) + + // Create Jira issue + issue, err := issueFiler.FileIssue("Task", title, description, userID, log) + if err != nil { + log.WithError(err).Error("failed to create Jira issue") + _, _, postErr := client.PostMessage(channelID, + slack.MsgOptionText(fmt.Sprintf("Failed to create Jira issue: %v", err), false), + slack.MsgOptionTS(threadTS)) + return true, postErr + } + + // Store mapping + if err := threadMapping.Store(ctx, threadTS, issue.Key); err != nil { + log.WithError(err).Warn("failed to store thread-jira mapping") + // Continue anyway - issue was created + } + + // Post confirmation + jiraURL := fmt.Sprintf("https://issues.redhat.com/browse/%s", issue.Key) + message := fmt.Sprintf("✅ Created Jira issue: <%s|%s>", jiraURL, issue.Key) + _, _, err = client.PostMessage(channelID, + slack.MsgOptionText(message, false), + slack.MsgOptionTS(threadTS)) + if err != nil { + log.WithError(err).Warn("failed to post confirmation message") + } + + log.Infof("Created Jira issue %s for thread %s", issue.Key, threadTS) + return true, nil +} diff --git a/pkg/slack/events/jira/reaction.go b/pkg/slack/events/jira/reaction.go new file mode 100644 index 00000000000..ee259504435 --- /dev/null +++ b/pkg/slack/events/jira/reaction.go @@ -0,0 +1,62 @@ +package jira + +import ( + "slices" + "strings" + + "github.com/sirupsen/logrus" + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + + "github.com/openshift/ci-tools/pkg/jira" + "github.com/openshift/ci-tools/pkg/slack/events" +) + +const ( + createJiraEmoji = "open_jira_dptp" + closeJiraEmoji = "close_jira_dptp" +) + +// ReactionHandler handles emoji reactions for Jira integration. +func ReactionHandler( + client *slack.Client, + issueFiler jira.IssueFiler, + threadMapping *ThreadJiraMapping, + forumChannelID string, + authorizedUsers []string, +) events.PartialHandler { + return events.PartialHandlerFunc("jira-reaction", + func(callback *slackevents.EventsAPIEvent, logger *logrus.Entry) (handled bool, err error) { + log := logger.WithField("handler", "jira-reaction") + log.Debug("checking event payload") + + if callback.Type != slackevents.CallbackEvent { + return false, nil + } + + event, ok := callback.InnerEvent.Data.(*slackevents.ReactionAddedEvent) + if !ok { + return false, nil + } + + if event.Item.Channel != forumChannelID { + log.Debugf("not in correct channel. wanted: %s, reaction was in: %s", forumChannelID, event.Item.Channel) + return false, nil + } + + if !slices.Contains(authorizedUsers, event.User) { + log.Infof("user with ID: %s is not a testplatform team member, ignoring emoji reaction", event.User) + // Silently ignore - don't process emoji from non-team members + return false, nil + } + + emoji := strings.Trim(event.Reaction, ":") + threadTS := event.Item.Timestamp + + if emoji == createJiraEmoji { + return handleCreateJira(client, issueFiler, threadMapping, forumChannelID, threadTS, event.User, log) + } + log.Tracef("emoji we do not care about: %s", emoji) + return false, nil + }) +} diff --git a/pkg/slack/events/jira/storage.go b/pkg/slack/events/jira/storage.go new file mode 100644 index 00000000000..c0ad3cf0997 --- /dev/null +++ b/pkg/slack/events/jira/storage.go @@ -0,0 +1,73 @@ +package jira + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ThreadJiraMapping stores and retrieves mappings between Slack thread timestamps and Jira issue keys. +type ThreadJiraMapping struct { + client ctrlruntimeclient.Client + namespace string + cmName string +} + +// NewThreadJiraMapping creates a new ThreadJiraMapping instance. +func NewThreadJiraMapping(client ctrlruntimeclient.Client, namespace, cmName string) *ThreadJiraMapping { + return &ThreadJiraMapping{ + client: client, + namespace: namespace, + cmName: cmName, + } +} + +// Store saves a mapping between a thread timestamp and a Jira issue key. +func (s *ThreadJiraMapping) Store(ctx context.Context, threadTS, jiraKey string) error { + cm := &corev1.ConfigMap{} + key := ctrlruntimeclient.ObjectKey{Namespace: s.namespace, Name: s.cmName} + + err := s.client.Get(ctx, key, cm) + if apierrors.IsNotFound(err) { + cm = &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: s.cmName, + Namespace: s.namespace, + }, + Data: make(map[string]string), + } + } else if err != nil { + return fmt.Errorf("failed to get ConfigMap: %w", err) + } + + if cm.Data == nil { + cm.Data = make(map[string]string) + } + cm.Data[threadTS] = jiraKey + + if err := s.client.Get(ctx, key, cm); apierrors.IsNotFound(err) { + return s.client.Create(ctx, cm) + } + return s.client.Update(ctx, cm) +} + +// Get retrieves a Jira issue key for a given thread timestamp. +func (s *ThreadJiraMapping) Get(ctx context.Context, threadTS string) (string, bool) { + cm := &corev1.ConfigMap{} + key := ctrlruntimeclient.ObjectKey{Namespace: s.namespace, Name: s.cmName} + + if err := s.client.Get(ctx, key, cm); err != nil { + return "", false + } + + if cm.Data == nil { + return "", false + } + + jiraKey, exists := cm.Data[threadTS] + return jiraKey, exists +} diff --git a/pkg/slack/events/jira/summarizer.go b/pkg/slack/events/jira/summarizer.go new file mode 100644 index 00000000000..f595d0a33a4 --- /dev/null +++ b/pkg/slack/events/jira/summarizer.go @@ -0,0 +1,79 @@ +package jira + +import ( + "fmt" + "strings" + + "github.com/slack-go/slack" +) + +// GetShadowbotSummary retrieves Shadowbot's thread summary if available. +// Shadowbot is a Red Hat internal bot with thread summary capabilities. +func GetShadowbotSummary(client *slack.Client, channelID, threadTS string) (string, bool) { + // Get all messages in thread + replies, _, _, err := client.GetConversationReplies(&slack.GetConversationRepliesParameters{ + ChannelID: channelID, + Timestamp: threadTS, + }) + if err != nil { + return "", false + } + + // Find Shadowbot's summary message + // Shadowbot posts summaries in threads - identify by bot user ID or message pattern + for _, msg := range replies { + // Option 1: Check by bot user ID (most reliable) + // Get Shadowbot's bot ID from Slack workspace and use it here + // if msg.BotID == "SHADOWBOT_BOT_ID" || msg.User == "SHADOWBOT_USER_ID" { + // return msg.Text, true + // } + + // Option 2: Check by bot name or username + // Shadowbot may post with a specific username or app name + // if msg.Username == "Shadowbot" || msg.AppID == "SHADOWBOT_APP_ID" { + // return msg.Text, true + // } + + // Option 3: Check by message pattern (fallback) + // Shadowbot summaries may contain specific keywords or formatting + lowerText := strings.ToLower(msg.Text) + if (strings.Contains(lowerText, "summary") || + strings.Contains(lowerText, "thread summary") || + strings.Contains(lowerText, "private thread summary")) && + (msg.BotID != "" || strings.ToLower(msg.Username) == "shadowbot") { + // Likely Shadowbot's summary - return it + return msg.Text, true + } + } + + return "", false +} + +// GetSummary creates a title and description, using Shadowbot summary if available. +func GetSummary(client *slack.Client, channelID, threadTS string, messages []ThreadMessage) (title, description string) { + if len(messages) == 0 { + return "Thread Discussion", "No messages found in thread." + } + + // Try to get Shadowbot's summary first + shadowbotSummary, hasSummary := GetShadowbotSummary(client, channelID, threadTS) + + // Title: First 100 chars of first message + firstText := messages[0].Text + title = firstText + if len(title) > 100 { + title = title[:100] + "..." + } + + // Description: Use Shadowbot summary if available, otherwise use full thread content + if hasSummary { + description = fmt.Sprintf("Thread Summary (from Shadowbot):\n\n%s\n\n---\n\nFull Thread Content:\n\n%s", + shadowbotSummary, FormatThreadContent(messages)) + } else { + // Fallback: Full thread content + description = FormatThreadContent(messages) + description = fmt.Sprintf("Thread Discussion from #forum-ocp-testplatform\n\n%s", description) + } + + return title, description +} diff --git a/pkg/slack/events/jira/thread.go b/pkg/slack/events/jira/thread.go new file mode 100644 index 00000000000..65abf958031 --- /dev/null +++ b/pkg/slack/events/jira/thread.go @@ -0,0 +1,81 @@ +package jira + +import ( + "fmt" + "strings" + + "github.com/slack-go/slack" +) + +// ThreadMessage represents a single message in a Slack thread. +type ThreadMessage struct { + User string + UserName string + Text string + Timestamp string +} + +// GetThreadContent fetches all messages in a thread and formats them. +func GetThreadContent(client *slack.Client, channelID, threadTS string) ([]ThreadMessage, error) { + var allMessages []ThreadMessage + + cursor := "" + for { + replies, hasMore, nextCursor, err := client.GetConversationReplies(&slack.GetConversationRepliesParameters{ + ChannelID: channelID, + Timestamp: threadTS, + Cursor: cursor, + }) + if err != nil { + return nil, fmt.Errorf("failed to get conversation replies: %w", err) + } + + for _, msg := range replies { + if msg.User != "" && msg.Text != "" { + user, err := client.GetUserInfo(msg.User) + userName := "Unknown" + if err == nil && user != nil { + userName = user.RealName + if userName == "" { + userName = user.Name + } + } + allMessages = append(allMessages, ThreadMessage{ + User: msg.User, + UserName: userName, + Text: msg.Text, + Timestamp: msg.Timestamp, + }) + } + } + + if !hasMore { + break + } + cursor = nextCursor + } + + return allMessages, nil +} + +// FormatThreadContent formats thread messages for Jira description. +func FormatThreadContent(messages []ThreadMessage) string { + var parts []string + for _, msg := range messages { + parts = append(parts, fmt.Sprintf("[%s]: %s", msg.UserName, msg.Text)) + } + return strings.Join(parts, "\n\n") +} + +// GetUniqueUsers extracts unique user IDs from thread messages. +func GetUniqueUsers(messages []ThreadMessage) []string { + userMap := make(map[string]bool) + var users []string + for _, msg := range messages { + if msg.User != "" && !userMap[msg.User] { + userMap[msg.User] = true + users = append(users, msg.User) + } + } + return users +} diff --git a/pkg/slack/events/router/router.go b/pkg/slack/events/router/router.go index 2f54ddc4925..add836f92cc 100644 --- a/pkg/slack/events/router/router.go +++ b/pkg/slack/events/router/router.go @@ -1,25 +1,70 @@ package router import ( + "context" + "fmt" + "cloud.google.com/go/storage" + "github.com/sirupsen/logrus" "github.com/slack-go/slack" + "k8s.io/apimachinery/pkg/types" ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/prow/pkg/config" + userv1 "github.com/openshift/api/user/v1" + + "github.com/openshift/ci-tools/pkg/jira" "github.com/openshift/ci-tools/pkg/slack/events" "github.com/openshift/ci-tools/pkg/slack/events/helpdesk" + jiraevents "github.com/openshift/ci-tools/pkg/slack/events/jira" "github.com/openshift/ci-tools/pkg/slack/events/joblink" "github.com/openshift/ci-tools/pkg/slack/events/mention" ) // ForEvents returns a Handler that appropriately routes // event callbacks for the handlers we know about -func ForEvents(client *slack.Client, kubeClient ctrlruntimeclient.Client, config config.Getter, gcsClient *storage.Client, keywordsConfig helpdesk.KeywordsConfig, helpdeskAlias, forumChannelId, reviewRequestWorkflowID, namespace string, requireWorkflowsInForum bool) events.Handler { +func ForEvents(client *slack.Client, kubeClient ctrlruntimeclient.Client, config config.Getter, gcsClient *storage.Client, keywordsConfig helpdesk.KeywordsConfig, helpdeskAlias, forumChannelId, reviewRequestWorkflowID, namespace string, requireWorkflowsInForum bool, issueFiler jira.IssueFiler) events.Handler { + // Get testplatform team members for Jira integration authorization + // Reuse the same pattern as FAQ handler + authorizedUsers, err := getAuthorizedUsersForJira(client, kubeClient) + if err != nil { + // Log warning but continue - Jira handler will just not work for anyone + // This matches the pattern where FAQ handler would fatal, but we want to be more lenient + // since Jira integration is optional + authorizedUsers = []string{} + } + + // Create thread-jira mapping storage + threadMapping := jiraevents.NewThreadJiraMapping(kubeClient, namespace, "slack-thread-jira-mapping") + return events.MultiHandler( helpdesk.MessageHandler(client, keywordsConfig, helpdeskAlias, forumChannelId, reviewRequestWorkflowID, requireWorkflowsInForum), helpdesk.FAQHandler(client, kubeClient, forumChannelId, namespace), mention.Handler(client), joblink.Handler(client, joblink.NewJobGetter(config), gcsClient), + jiraevents.ReactionHandler(client, issueFiler, threadMapping, forumChannelId, authorizedUsers), ) } + +// getAuthorizedUsersForJira retrieves authorized users from the test-platform-ci-admins group. +// This is similar to the getAuthorizedUsers function in helpdesk-faq.go. +func getAuthorizedUsersForJira(client *slack.Client, groupClient ctrlruntimeclient.Client) ([]string, error) { + logger := logrus.WithField("handler", "jira-router") + admins := &userv1.Group{} + if err := groupClient.Get(context.TODO(), types.NamespacedName{Name: "test-platform-ci-admins"}, admins); err != nil { + logger.WithError(err).Error("unable to get test-platform-ci-admins group") + return nil, err + } + var slackUsers []string + for _, admin := range admins.Users { + email := fmt.Sprintf("%s@redhat.com", admin) + user, err := client.GetUserByEmail(email) + if err != nil { + logger.WithError(err).Errorf("unable to get user for email: %s", email) + continue + } + slackUsers = append(slackUsers, user.ID) + } + return slackUsers, nil +} From 8dac8f97007aef0872c182c27abfe4a74fc9829c Mon Sep 17 00:00:00 2001 From: Deep Mistry Date: Tue, 17 Feb 2026 10:29:13 -0500 Subject: [PATCH 2/2] Add Jira issue closing functionality with private comments --- pkg/slack/events/jira/close.go | 84 ++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 pkg/slack/events/jira/close.go diff --git a/pkg/slack/events/jira/close.go b/pkg/slack/events/jira/close.go new file mode 100644 index 00000000000..81c0e028b53 --- /dev/null +++ b/pkg/slack/events/jira/close.go @@ -0,0 +1,84 @@ +package jira + +import ( + "context" + "fmt" + + "github.com/sirupsen/logrus" + "github.com/slack-go/slack" + + "github.com/openshift/ci-tools/pkg/jira" +) + +func handleCloseJira( + client *slack.Client, + issueFiler jira.IssueFiler, + threadMapping *ThreadJiraMapping, + channelID, threadTS, userID string, + logger *logrus.Entry, +) (bool, error) { + log := logger.WithField("action", "close-jira") + + // Check if thread has associated Jira issue + ctx := context.Background() + jiraKey, exists := threadMapping.Get(ctx, threadTS) + if !exists { + log.Info("thread does not have associated Jira issue") + _, _, err := client.PostMessage(channelID, + slack.MsgOptionText("This thread does not have an associated Jira issue.", false), + slack.MsgOptionTS(threadTS)) + return true, err + } + + // Get thread content for final summary + messages, err := GetThreadContent(client, channelID, threadTS) + if err != nil { + log.WithError(err).Error("failed to get thread content") + return false, err + } + + // Generate final summary + _, description := GetSummary(client, channelID, threadTS, messages) + finalSummary := fmt.Sprintf("Thread resolved. Final summary:\n\n%s", description) + + // Cast to IssueUpdater to access AddComment and TransitionIssue methods + updater, ok := issueFiler.(jira.IssueUpdater) + if !ok { + log.Error("issueFiler does not support AddComment and TransitionIssue methods") + _, _, postErr := client.PostMessage(channelID, + slack.MsgOptionText("Failed to close Jira issue: issueFiler does not support required methods", false), + slack.MsgOptionTS(threadTS)) + return true, postErr + } + + // Add comment with final summary (private for Red Hat employees) + if err := updater.AddComment(jiraKey, finalSummary, log); err != nil { + log.WithError(err).Error("failed to add comment to Jira issue") + _, _, postErr := client.PostMessage(channelID, + slack.MsgOptionText(fmt.Sprintf("Failed to add comment to Jira issue: %v", err), false), + slack.MsgOptionTS(threadTS)) + return true, postErr + } + + // Transition issue to "Done" + if err := updater.TransitionIssue(jiraKey, "Done", log); err != nil { + log.WithError(err).Error("failed to transition Jira issue") + _, _, postErr := client.PostMessage(channelID, + slack.MsgOptionText(fmt.Sprintf("Failed to transition Jira issue: %v", err), false), + slack.MsgOptionTS(threadTS)) + return true, postErr + } + + // Post confirmation + jiraURL := fmt.Sprintf("https://issues.redhat.com/browse/%s", jiraKey) + message := fmt.Sprintf("✅ Closed Jira issue: <%s|%s>", jiraURL, jiraKey) + _, _, err = client.PostMessage(channelID, + slack.MsgOptionText(message, false), + slack.MsgOptionTS(threadTS)) + if err != nil { + log.WithError(err).Warn("failed to post confirmation message") + } + + log.Infof("Closed Jira issue %s for thread %s", jiraKey, threadTS) + return true, nil +}