Skip to content
Merged
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
1 change: 1 addition & 0 deletions cmd/slack-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Currently, the bot can do the following:
- When the bot is explicitly mentioned in a message (`@DPTP bot`), it lists all available actions it knows how to do, like file a bug, request a consultation, and more.
- When a specific job link is included in a message, the bot responds with helpful information related to that job.
- In the `CoreOS` slack space, when someone tags `@dptp-helpdesk` in the `forum-ocp-testplatform` channel, the bot sends an automatic reply containing helpful basic information in a new thread.
- Support-request mode (enabled by default in `#forum-ocp-testplatform`): if a thread exceeds `--support-request-threshold` messages (default `12`), the bot creates a Jira issue in `DPTP`, posts the link in the thread, and closes that Jira with `Done` when `:closed:` is added to the root thread message.

# Local testing
There is an alpha instance of Slack Bot running on the app.ci cluster that you can use for testing by running a mitmproxy and reverse tunneling requests to your local machine.
Expand Down
29 changes: 22 additions & 7 deletions cmd/slack-bot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
userv1 "github.com/openshift/api/user/v1"

"github.com/openshift/ci-tools/pkg/jira"
"github.com/openshift/ci-tools/pkg/pagerdutyutil"
eventhandler "github.com/openshift/ci-tools/pkg/slack/events"
"github.com/openshift/ci-tools/pkg/slack/events/helpdesk"
eventrouter "github.com/openshift/ci-tools/pkg/slack/events/router"
Expand All @@ -50,6 +51,7 @@ type options struct {
gracePeriod time.Duration
instrumentationOptions prowflagutil.InstrumentationOptions
jiraOptions prowflagutil.JiraOptions
pagerDutyOptions pagerdutyutil.Options

prowconfig configflagutil.ConfigOptions

Expand All @@ -62,7 +64,8 @@ type options struct {
reviewRequestWorkflowID string
namespace string
requireWorkflowsInForum bool
jiraActivityTypeField string
supportRequestChannelID string
supportRequestThreshold int
}

func (o *options) Validate() error {
Expand All @@ -78,8 +81,11 @@ func (o *options) Validate() error {
if o.slackSigningSecretPath == "" {
return fmt.Errorf("--slack-signing-secret-path is required")
}
if o.supportRequestChannelID != "" && o.supportRequestThreshold < 1 {
return fmt.Errorf("--support-request-threshold must be >= 1")
}

for _, group := range []flagutil.OptionGroup{&o.instrumentationOptions, &o.jiraOptions, &o.prowconfig} {
for _, group := range []flagutil.OptionGroup{&o.instrumentationOptions, &o.jiraOptions, &o.pagerDutyOptions, &o.prowconfig} {
if err := group.Validate(false); err != nil {
return err
}
Expand All @@ -97,7 +103,7 @@ func gatherOptions(fs *flag.FlagSet, args ...string) options {

o.prowconfig.ConfigPathFlagName = "prow-config-path"
o.prowconfig.JobConfigPathFlagName = "prow-job-config-path"
for _, group := range []flagutil.OptionGroup{&o.instrumentationOptions, &o.jiraOptions, &o.prowconfig} {
for _, group := range []flagutil.OptionGroup{&o.instrumentationOptions, &o.jiraOptions, &o.pagerDutyOptions, &o.prowconfig} {
group.AddFlags(fs)
}

Expand All @@ -109,7 +115,8 @@ func gatherOptions(fs *flag.FlagSet, args ...string) options {
fs.StringVar(&o.reviewRequestWorkflowID, "review-request-workflow-id", "B06T46F374N", "ID for the 'Review Request' slack workflow")
fs.StringVar(&o.namespace, "namespace", "ci", "Namespace to store helpdesk-faq items")
fs.BoolVar(&o.requireWorkflowsInForum, "require-workflows-in-forum", true, "Require the use of workflows in the designated forum channel")
fs.StringVar(&o.jiraActivityTypeField, "jira-activity-type-custom-field", "", "Activity Type field key (e.g. "+jira.ActivityTypeCustomFieldKey+"); empty omits it")
fs.StringVar(&o.supportRequestChannelID, "support-request-channel-id", "CBN38N3MW", "Channel ID where support request mode watches long threads (defaults to #forum-ocp-testplatform)")
fs.IntVar(&o.supportRequestThreshold, "support-request-threshold", 12, "Create a support-request Jira when a thread has more than this many messages (total count includes the root message)")

if err := fs.Parse(args); err != nil {
logrus.WithError(err).Fatal("Could not parse args.")
Expand Down Expand Up @@ -168,9 +175,13 @@ func main() {
if err != nil {
logrus.WithError(err).Fatal("Could not initialize Jira client.")
}
pagerDutyClient, err := o.pagerDutyOptions.Client()
if err != nil {
logrus.WithError(err).Fatal("Could not initialize PagerDuty client.")
}

slackClient := slack.New(string(secret.GetSecret(o.slackTokenPath)))
issueFiler, err := jira.NewIssueFiler(slackClient, jiraClient.JiraClient(), o.jiraActivityTypeField)
issueFiler, err := jira.NewIssueFiler(slackClient, jiraClient.JiraClient(), pagerDutyClient)
if err != nil {
logrus.WithError(err).Fatal("Could not initialize Jira issue filer.")
}
Expand Down Expand Up @@ -204,7 +215,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, issueFiler, kubeClient, configAgent.Config, gcsClient, keywordsConfig, o.helpdeskAlias, o.forumChannelId, o.reviewRequestWorkflowID, o.namespace, o.supportRequestChannelID, o.supportRequestThreshold, o.requireWorkflowsInForum))))
server := &http.Server{Addr: ":" + strconv.Itoa(o.port), Handler: mux}

health.ServeReady()
Expand All @@ -214,7 +225,7 @@ func main() {
interrupts.WaitForGracefulShutdown()
}

func loadKeywordsConfig(configPath string, config interface{}) error {
func loadConfig(configPath string, config interface{}) error {
configContent, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read config: %w", err)
Expand All @@ -225,6 +236,10 @@ func loadKeywordsConfig(configPath string, config interface{}) error {
return nil
}

func loadKeywordsConfig(configPath string, config interface{}) error {
return loadConfig(configPath, config)
}

func verifiedBody(logger *logrus.Entry, request *http.Request, signingSecret func() []byte) ([]byte, bool) {
verifier, err := slack.NewSecretsVerifier(request.Header, string(signingSecret()))
if err != nil {
Expand Down
8 changes: 4 additions & 4 deletions cmd/sprint-automation/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,8 @@ func userOnCallDuring(client *pagerduty.Client, query string, since, until time.
schedule := scheduleResponse.Schedules[0]

users, err := client.ListOnCallUsers(schedule.ID, pagerduty.ListOnCallUsersOptions{
Since: since.String(),
Until: until.String(),
Since: since.Format(time.RFC3339),
Until: until.Format(time.RFC3339),
})
if err != nil {
return nil, fmt.Errorf("could not query PagerDuty for the %s on-call: %w", query, err)
Expand All @@ -329,8 +329,8 @@ func userOnCallDuring(client *pagerduty.Client, query string, since, until time.

// more than 1 user means there must be an override, determine who the override is associated with
overrides, err := client.ListOverrides(schedule.ID, pagerduty.ListOverridesOptions{
Since: since.String(),
Until: until.String(),
Since: since.Format(time.RFC3339),
Until: until.Format(time.RFC3339),
})
if err != nil {
return nil, fmt.Errorf("could not query PagerDuty for the '%s' overrides: %w", query, err)
Expand Down
93 changes: 90 additions & 3 deletions pkg/jira/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,57 @@ type IssueRequest struct {
IssueType, Title, Description, Reporter, ActivityType string
}

// CloseIssueRequest describes a client call to close an issue.
type CloseIssueRequest struct {
IssueKey, Resolution string
}

// SetIssueStatusRequest describes a client call to set issue status.
type SetIssueStatusRequest struct {
IssueKey, Status string
}

// IssueResponse describes a client response for filing an issue
type IssueResponse struct {
Issue *jira.Issue
Error error
}

// CloseIssueResponse describes a client response for closing an issue.
type CloseIssueResponse struct {
Closed bool
Error error
}

// SetIssueStatusResponse describes a client response for setting status.
type SetIssueStatusResponse struct {
Error error
}

// Fake is an injectable IssueFiler
type Fake struct {
behavior map[IssueRequest]IssueResponse
unwanted []IssueRequest
behavior map[IssueRequest]IssueResponse
closeBehavior map[CloseIssueRequest]CloseIssueResponse
statusBehavior map[SetIssueStatusRequest]SetIssueStatusResponse
unwanted []IssueRequest
unwantedCloseCalls []CloseIssueRequest
unwantedStatusCalls []SetIssueStatusRequest
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// SetCloseBehavior configures expected CloseIssue calls and responses.
func (f *Fake) SetCloseBehavior(behavior map[CloseIssueRequest]CloseIssueResponse) {
f.closeBehavior = make(map[CloseIssueRequest]CloseIssueResponse, len(behavior))
for request, response := range behavior {
f.closeBehavior[request] = response
}
}

// SetStatusBehavior configures expected SetIssueStatus calls and responses.
func (f *Fake) SetStatusBehavior(behavior map[SetIssueStatusRequest]SetIssueStatusResponse) {
f.statusBehavior = make(map[SetIssueStatusRequest]SetIssueStatusResponse, len(behavior))
for request, response := range behavior {
f.statusBehavior[request] = response
}
}

// FileIssue files the issue using injected behavior
Expand All @@ -43,19 +84,65 @@ func (f *Fake) FileIssue(issueType, title, description, reporter, activityType s
return response.Issue, response.Error
}

// CloseIssue closes the issue using injected behavior.
func (f *Fake) CloseIssue(issueKey, resolution string, logger *logrus.Entry) (bool, error) {
request := CloseIssueRequest{
IssueKey: issueKey,
Resolution: resolution,
}
response, registered := f.closeBehavior[request]
if !registered {
f.unwantedCloseCalls = append(f.unwantedCloseCalls, request)
return false, errors.New("no such close issue behavior in fake")
}
delete(f.closeBehavior, request)
return response.Closed, response.Error
}

// SetIssueStatus sets issue status using injected behavior.
func (f *Fake) SetIssueStatus(issueKey, status string, logger *logrus.Entry) error {
request := SetIssueStatusRequest{
IssueKey: issueKey,
Status: status,
}
response, registered := f.statusBehavior[request]
if !registered {
f.unwantedStatusCalls = append(f.unwantedStatusCalls, request)
return errors.New("no such set issue status behavior in fake")
}
delete(f.statusBehavior, request)
return response.Error
}

// Validate ensures that all expected client calls happened
func (f *Fake) Validate(t *testing.T) {
for request := range f.behavior {
t.Errorf("fake issue filer did not get request: %v", request)
}
for request := range f.closeBehavior {
t.Errorf("fake issue filer did not get close request: %v", request)
}
for request := range f.statusBehavior {
t.Errorf("fake issue filer did not get set-status request: %v", request)
}
for _, request := range f.unwanted {
t.Errorf("fake issue filer got unwanted request: %v", request)
}
for _, request := range f.unwantedCloseCalls {
t.Errorf("fake issue filer got unwanted close request: %v", request)
}
for _, request := range f.unwantedStatusCalls {
t.Errorf("fake issue filer got unwanted set-status request: %v", request)
}
}

var _ IssueFiler = &Fake{}

// NewFake creates a new fake filer with the injected behavior
func NewFake(calls map[IssueRequest]IssueResponse) *Fake {
return &Fake{behavior: calls}
return &Fake{
behavior: calls,
closeBehavior: map[CloseIssueRequest]CloseIssueResponse{},
statusBehavior: map[SetIssueStatusRequest]SetIssueStatusResponse{},
}
}
Loading