diff --git a/.travis.yml b/.travis.yml index c80c361..2e6ff1e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,7 @@ language: go go: - - "1.11" - - "1.12" - - "1.13" + - "1.17" env: - GO111MODULE=on \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 5daee83..fd8653c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,10 @@ -FROM golang:1.11-alpine as builder +FROM golang:1.17-alpine as builder ENV GO111MODULE=on WORKDIR /boggy/ COPY . ./ RUN apk add git +RUN go mod tidy RUN CGO_ENABLED=0 go build -o /app boggy.go FROM alpine:latest as alpine diff --git a/Makefile b/Makefile index f6cc731..1c8f8a6 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ all: clean dep bin/boggy bin/boggy: dep mkdir -p bin/ + go mod tidy GO111MODULE=on go build -ldflags="-s -w" -o bin/boggy *.go clean: diff --git a/bot/user.go b/bot/user.go index 22cfba8..1a7b0c7 100644 --- a/bot/user.go +++ b/bot/user.go @@ -2,6 +2,7 @@ package bot import ( "bufio" + "fmt" "github.com/nlopes/slack" "github.com/tigerteufel85/boggy/client" "github.com/tigerteufel85/boggy/config" @@ -78,7 +79,7 @@ func getAllowedUsers() []User { // load the public and private channels and list of all users from current space func (b *Bot) loadChannelsUsersAndProjects(config *config.Config) error { - // load channels + fmt.Println("...Loading Channels") var err error var cursor string var channels []slack.Channel @@ -100,7 +101,7 @@ func (b *Bot) loadChannelsUsersAndProjects(config *config.Config) error { } } - // load users + fmt.Println("...Loading Users") users, err := b.slackClient.GetUsers() if err != nil { return err @@ -111,7 +112,7 @@ func (b *Bot) loadChannelsUsersAndProjects(config *config.Config) error { client.Users[user.ID] = user.Name } - // load projects + fmt.Println("...Loading Projects") client.Projects = make(map[string]string) for _, project := range config.Jira.Projects { client.Projects[project] = project diff --git a/command/default.go b/command/default.go index d475b81..944d466 100644 --- a/command/default.go +++ b/command/default.go @@ -15,6 +15,7 @@ func GetDefaultCommands(slackClient client.SlackClient, jira *jira.Client, confi Commands: []bot.Command{ NewHelp(slackClient, &commands), NewJiraIssues(slackClient, jira, config.Jira, config.Replies, config.Regex), + NewJiraIssuesSingle(slackClient, jira, config.Jira, config.Replies, config.Regex), NewJiraIssue(slackClient, jira, config.Jira, config.Replies), NewJiraOverview(slackClient, jira, config.Jira, config.Replies, config.Regex), NewAdminAddUser(slackClient), diff --git a/command/jira_issues_single.go b/command/jira_issues_single.go new file mode 100644 index 0000000..8dd8002 --- /dev/null +++ b/command/jira_issues_single.go @@ -0,0 +1,114 @@ +package command + +import ( + "context" + "github.com/nlopes/slack" + "github.com/tigerteufel85/boggy/bot" + "github.com/tigerteufel85/boggy/client" + "github.com/tigerteufel85/boggy/config" + "github.com/tigerteufel85/boggy/utils" + "gopkg.in/andygrunwald/go-jira.v1" + "strings" +) + +type jiraIssuesSingle struct { + slackClient client.SlackClient + jira *jira.Client + jiraCfg config.JiraConfig + jiraReplies config.ReplyConfig + regex config.RegexConfig +} + +func NewJiraIssuesSingle(slackClient client.SlackClient, jira *jira.Client, jiraCfg config.JiraConfig, jiraReplies config.ReplyConfig, regex config.RegexConfig) *jiraIssuesSingle { + return &jiraIssuesSingle{ + slackClient, + jira, + jiraCfg, + jiraReplies, + regex, + } +} + +func (c *jiraIssuesSingle) GetName() string { + return "jira single" +} + +func (c *jiraIssuesSingle) IsValid(b *bot.Bot, command string) bool { + if !strings.HasPrefix(command, c.GetName()) { + return false + } + + _, err := utils.NewJQL(c.regex, command).BuildJqlQuery(c.jiraCfg) + if err != nil { + return false + } + + return true +} + +func (c *jiraIssuesSingle) Execute(ctx context.Context, b *bot.Bot, eventText string, event *slack.MessageEvent, user bot.User) bool { + if !strings.HasPrefix(eventText, c.GetName()) { + return false + } + + // Create JQL Query + jql := utils.NewJQL(c.regex, eventText) + query, err := jql.BuildJqlQuery(c.jiraCfg) + if err != nil { + c.slackClient.Respond(event, err.Error()) + return true + } + + // Search issues on JIRA + issues, searchResponse, err := c.jira.Issue.Search(query, &jira.SearchOptions{MaxResults: 50, Expand: "names"}) + if err != nil { + auth, _ := c.slackClient.AuthTest() + if user.Name != auth.User { + c.slackClient.Respond(event, err.Error()) + } + return true + } + + if searchResponse.Total == 0 { + return true + } + + // Get all fields + allFields, _, err := c.jira.Field.GetList() + if err != nil { + auth, _ := c.slackClient.AuthTest() + if user.Name != auth.User { + c.slackClient.Respond(event, err.Error()) + } + return true + } + + for _, issue := range issues { + customFields, _, err := c.jira.Issue.GetCustomFields(issue.ID) + if err != nil { + auth, _ := c.slackClient.AuthTest() + if user.Name != auth.User { + c.slackClient.Respond(event, err.Error()) + } + return true + } + + responseText := utils.NewLayout(c.regex, eventText).BuildSimpleTextResponse(c.jiraCfg, issue, customFields, allFields, jql) + + c.slackClient.Respond(event, responseText) + } + + return true +} + +func (c *jiraIssuesSingle) GetHelp() []bot.Help { + return []bot.Help{ + { + "jira issues single", + "creates a slack response for each crm sale which will start, the placeholders will be replaced respectively", + []string{ + "jira single “Campaign Category” = SaleA sale will start in %component% %date% at %time% : %summary%", + }, + }, + } +} diff --git a/command/schedule_add.go b/command/schedule_add.go index e2ddd68..1747db9 100644 --- a/command/schedule_add.go +++ b/command/schedule_add.go @@ -43,6 +43,7 @@ func (c *scheduleAdd) IsValid(b *bot.Bot, command string) bool { func (c *scheduleAdd) getAllowedCommands() []bot.Command { return []bot.Command{ NewJiraIssues(c.slackClient, c.jira, c.jiraCfg, c.jiraReplies, c.regex), + NewJiraIssuesSingle(c.slackClient, c.jira, c.jiraCfg, c.jiraReplies, c.regex), NewJiraOverview(c.slackClient, c.jira, c.jiraCfg, c.jiraReplies, c.regex), } } diff --git a/config/config.example.yaml b/config/config.example.yaml index f0945b8..a633442 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -10,6 +10,12 @@ jira: - DEF - GHI + location: "Europe/Berlin" + + components: + "Backend": ":be:" + "Frontend": ":fe:" + statuses: open: "Unresolved" unresolved: "Unresolved" @@ -51,6 +57,8 @@ jira: "Raccoon": ":raccoon:" "Double Trouble": ":busts_in_silhouette:" + timeformat: "2006-01-02 03:04" + bugoverview: listall: - ABC @@ -154,9 +162,11 @@ regex: jirasorting: "(?i:)" jirastatus: "(?i:)" jiratime: "(?i:)" + jiraoffsettime: "(?i:)" + jiraoffsetfield: "(?i:)" replycolor: "(?i:)" replylayout: "(?i:)" replylist: "(?i:)" - replytitle: "(?i:)([a-zA-Z0-9 .,;:=\"()><\\-+!?&|_]*)()" - croncommand: "(?i:)([a-zA-Z0-9 .,;~:=\"()><\\-+!&|\\/_]*)()" + replytitle: "(?i:)([a-zA-Z0-9 .,;:=\"()><\\-%+!?&|_]*)()" + croncommand: "(?i:)([a-zA-Z0-9 .,;~:=\"()><\\-%+!&|\\/_]*)()" crontime: "(?i:)" \ No newline at end of file diff --git a/config/config.go b/config/config.go index f8ae423..ceceb38 100644 --- a/config/config.go +++ b/config/config.go @@ -19,9 +19,12 @@ type JiraConfig struct { Username string Password string Projects []string `yaml:",flow"` + Location string + Components map[string]string `yaml:",flow"` Statuses map[string]string `yaml:",flow"` Priorities map[string]Priority `yaml:",flow"` FeatureTeams TeamConfig + TimeFormat string BugOverview struct { ListAll []string `yaml:",flow"` All string @@ -68,19 +71,21 @@ type TeamConfig struct { // RegexConfig contains the various regex expressions to parse Slack messages for commands type RegexConfig struct { - JiraAssignee string - JiraCustom string - JiraIssueType string - JiraOption string - JiraPriority string - JiraProject string - JiraSorting string - JiraStatus string - JiraTime string - ReplyColor string - ReplyLayout string - ReplyList string - ReplyTitle string - CronCommand string - CronTime string + JiraAssignee string + JiraCustom string + JiraIssueType string + JiraOption string + JiraPriority string + JiraProject string + JiraSorting string + JiraStatus string + JiraTime string + JiraOffsetTime string + JiraOffsetField string + ReplyColor string + ReplyLayout string + ReplyList string + ReplyTitle string + CronCommand string + CronTime string } diff --git a/go.mod b/go.mod index fd7e326..6a97f63 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,21 @@ module github.com/tigerteufel85/boggy +go 1.17 + +require ( + github.com/imdario/mergo v0.3.7 + github.com/nlopes/slack v0.5.0 + gopkg.in/andygrunwald/go-jira.v1 v1.6.0 + gopkg.in/robfig/cron.v2 v2.0.0-20150107220207-be2e0b0deed5 + gopkg.in/yaml.v2 v2.2.2 +) + require ( - github.com/ae6rt/retry v2.0.0+incompatible // indirect - github.com/blend/go-sdk v1.1.1 // indirect github.com/fatih/structs v1.1.0 // indirect - github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/google/go-querystring v1.0.0 // indirect github.com/gorilla/websocket v1.4.0 // indirect - github.com/imdario/mergo v0.3.7 - github.com/lusis/go-slackbot v0.0.0-20180109053408-401027ccfef5 // indirect github.com/lusis/slack-test v0.0.0-20180109053238-3c758769bfa6 // indirect - github.com/nlopes/slack v0.5.0 github.com/pkg/errors v0.8.1 // indirect github.com/stretchr/testify v1.3.0 // indirect github.com/trivago/tgo v1.0.5 // indirect - github.com/wcharczuk/go-chart v2.0.1+incompatible - github.com/xoom/stash v1.3.1 - golang.org/x/image v0.0.0-20190118043309-183bebdce1b2 // indirect - gopkg.in/andygrunwald/go-jira.v1 v1.6.0 - gopkg.in/robfig/cron.v2 v2.0.0-20150107220207-be2e0b0deed5 - gopkg.in/yaml.v2 v2.2.2 ) diff --git a/readme.md b/readme.md index 36c410c..c481d1b 100644 --- a/readme.md +++ b/readme.md @@ -1,5 +1,5 @@ # Boggy -[![Build Status](https://travis-ci.com/tigerteufel85/boggy.svg)](https://travis-ci.com/tigerteufel85/boggy) +[![Build Status](https://app.travis-ci.com/tigerteufel85/boggy.svg)](https://app.travis-ci.com/github/tigerteufel85/boggy) [![GoDoc](https://godoc.org/github.com/tigerteufel85/boggy?status.svg)](https://godoc.org/github.com/innogames/boggy) [![Go Report Card](https://goreportcard.com/badge/github.com/tigerteufel85/boggy)](https://goreportcard.com/report/github.com/tigerteufel85/boggy) [![Release](https://img.shields.io/github/release/tigerteufel85/boggy.svg)](https://github.com/tigerteufel85/boggy/releases) @@ -104,6 +104,17 @@ Query information from Jira for or a whole list of tickets. - filters by a JQL query, please do not add any ordering to it - please use with care as it makes the commands flexible but also more error prone +## JIRA Single +Query information from Jira and posts each issue one by one. + +### Parameters +Generally the same parameters as for "JIRA Issues" can be used but there are a few additional ones. + +#### `` + `` + `` +- queries tickets where the time from the Start Time field starts in 1h and lasts for 10m +- if current time is 2022-01-01 10:00 and with an offset of 1h and time of 10m +- it creates a query like `"Start Time" >= "2022-01-01 11:00" AND "Start Time" <= "2022-01-01 11:10"` + ## Bugs Overview Creates an overview of the current bug status of a project. diff --git a/utils/jira_query.go b/utils/jira_query.go index 07339d5..01da193 100644 --- a/utils/jira_query.go +++ b/utils/jira_query.go @@ -5,34 +5,41 @@ import ( "github.com/tigerteufel85/boggy/client" "github.com/tigerteufel85/boggy/config" "html" + "regexp" + "strconv" "strings" + "time" ) // JQL is a wrapper with all information for a JIRA JQL type JQL struct { - Project string - Type string - Priority string - Status string - Option string - Time string - Custom string - Assignee string - Sorting string + Project string + Type string + Priority string + Status string + Option string + Time string + OffsetTime string + OffsetField string + Custom string + Assignee string + Sorting string } // NewJQL provides all information needed for a JIRA JQL func NewJQL(config config.RegexConfig, input string) *JQL { return &JQL{ - Project: ParseRegex(input, config.JiraProject), - Type: ParseRegex(input, config.JiraIssueType), - Priority: ParseRegex(input, config.JiraPriority), - Status: ParseRegex(input, config.JiraStatus), - Option: ParseRegex(input, config.JiraOption), - Time: ParseRegex(input, config.JiraTime), - Custom: ParseRegex(input, config.JiraCustom), - Assignee: ParseRegex(input, config.JiraAssignee), - Sorting: ParseRegex(input, config.JiraSorting), + Project: ParseRegex(input, config.JiraProject), + Type: ParseRegex(input, config.JiraIssueType), + Priority: ParseRegex(input, config.JiraPriority), + Status: ParseRegex(input, config.JiraStatus), + Option: ParseRegex(input, config.JiraOption), + Time: ParseRegex(input, config.JiraTime), + OffsetTime: ParseRegex(input, config.JiraOffsetTime), + OffsetField: ParseRegex(input, config.JiraOffsetField), + Custom: ParseRegex(input, config.JiraCustom), + Assignee: ParseRegex(input, config.JiraAssignee), + Sorting: ParseRegex(input, config.JiraSorting), } } @@ -72,6 +79,22 @@ func (jql *JQL) BuildJqlQuery(config config.JiraConfig) (string, error) { } } + // prepare field with offset + if jql.OffsetField != "" && jql.OffsetTime != "" && jql.Time != "" { + loc, _ := time.LoadLocation(config.Location) + timein := time.Now().In(loc) + timein = addTime(timein, strings.ToLower(jql.OffsetTime)) + timeout := addTime(timein, strings.ToLower(jql.Time)) + + results = append(results, fmt.Sprintf( + "\"%s\" >= \"%s\" AND \"%s\" <= \"%s\"", + jql.OffsetField, + timein.Format(config.TimeFormat), + jql.OffsetField, + timeout.Format(config.TimeFormat), + )) + } + // prepare assignee with time if jql.Assignee != "" && jql.Time != "" { results = append(results, fmt.Sprintf("assignee changed TO %s DURING (-%s,now())", jql.Assignee, jql.Time)) @@ -121,3 +144,29 @@ func getStatus(statuses map[string]string, input string) string { return "" } + +func addTime(startTime time.Time, offset string) time.Time { + timeMinutes := regexp.MustCompile(`^[0-9]+m$`) + timeHours := regexp.MustCompile(`^[0-9]+h$`) + timeDays := regexp.MustCompile(`^[0-9]+d$`) + timeWeeks := regexp.MustCompile(`^[0-9]+w$`) + + returnTime := startTime + + switch { + case timeMinutes.MatchString(offset): + timeOffset, _ := strconv.Atoi(strings.ReplaceAll(offset, "m", "")) + returnTime = startTime.Add(time.Minute * time.Duration(timeOffset)) + case timeHours.MatchString(offset): + timeOffset, _ := strconv.Atoi(strings.ReplaceAll(offset, "h", "")) + returnTime = startTime.Add(time.Hour * time.Duration(timeOffset)) + case timeDays.MatchString(offset): + timeOffset, _ := strconv.Atoi(strings.ReplaceAll(offset, "d", "")) + returnTime = startTime.Add(time.Hour * 24 * time.Duration(timeOffset)) + case timeWeeks.MatchString(offset): + timeOffset, _ := strconv.Atoi(strings.ReplaceAll(offset, "w", "")) + returnTime = startTime.Add(time.Hour * 24 * 7 * time.Duration(timeOffset)) + } + + return returnTime +} diff --git a/utils/response_layout.go b/utils/response_layout.go index f04b965..55ef44f 100644 --- a/utils/response_layout.go +++ b/utils/response_layout.go @@ -6,6 +6,7 @@ import ( "github.com/tigerteufel85/boggy/config" "gopkg.in/andygrunwald/go-jira.v1" "strings" + "time" ) // Layout is a wrapper for layouting the Slack replies @@ -30,6 +31,39 @@ func NewLayout(config config.RegexConfig, input string) *Layout { } } +func (layout *Layout) BuildSimpleTextResponse(config config.JiraConfig, issue jira.Issue, customFields jira.CustomFields, allFields []jira.Field, jql *JQL) string { + responseText := layout.Title + + var components []string + for _, component := range issue.Fields.Components { + components = append(components, component.Name) + } + + var offsetFieldId string + for _, field := range allFields { + if field.Name == jql.OffsetField { + offsetFieldId = field.ID + break + } + } + + loc, _ := time.LoadLocation(config.Location) + timeNow := time.Now().In(loc).Format("2006-01-02") + jiraTime, _ := time.Parse("2006-01-02T15:04:05.000+0000", customFields[offsetFieldId]) + + jiraDate := jiraTime.In(loc).Format("2006-01-02") + if strings.HasPrefix(jiraDate, timeNow) { + jiraDate = "today" + } + + responseText = strings.Replace(responseText, "%component%", getComponentIcon(config, strings.Join(components, ", ")), -1) + responseText = strings.Replace(responseText, "%summary%", issue.Fields.Summary, -1) + responseText = strings.Replace(responseText, "%date%", jiraDate, -1) + responseText = strings.Replace(responseText, "%time%", jiraTime.In(loc).Format("15:04"), -1) + + return responseText +} + // BuildAttachment creates Slack Attachments for Slack replies func (layout *Layout) BuildAttachment(replies config.ReplyConfig, config config.JiraConfig, amount int, issues []jira.Issue) slack.Attachment { option := layout.Option @@ -207,6 +241,14 @@ func createJiraLink(config config.JiraConfig, issueKey string) string { return fmt.Sprintf("<%s/browse/%s|%s>", config.Host, issueKey, issueKey) } +func getComponentIcon(config config.JiraConfig, component string) string { + if val, ok := config.Components[component]; ok { + return val + } + + return component +} + func getPriorityIcon(priorities map[string]config.Priority, id string) string { if val, ok := priorities[strings.ToLower(id)]; ok { return val.Icon