Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/slack-bot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
80 changes: 80 additions & 0 deletions pkg/jira/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
84 changes: 84 additions & 0 deletions pkg/slack/events/jira/close.go
Original file line number Diff line number Diff line change
@@ -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
}
75 changes: 75 additions & 0 deletions pkg/slack/events/jira/create.go
Original file line number Diff line number Diff line change
@@ -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
}
62 changes: 62 additions & 0 deletions pkg/slack/events/jira/reaction.go
Original file line number Diff line number Diff line change
@@ -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
})
}
Loading