// Copyright (C) The Arvados Authors. All rights reserved. // // SPDX-License-Identifier: Apache-2.0 package main import ( "bytes" _ "embed" "fmt" "net/smtp" "os" "regexp" "strconv" "strings" "text/template" "time" "git.arvados.org/arvados-dev.git/lib/redmine" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) //go:embed emailTemplate.txt var emailTemplate string var debug, send bool type ReviewTask struct { IssueID string IssueSubject string IssueURL string ID string Subject string URL string Status string } type Report struct { Developer string Email string Subject string Body bytes.Buffer SprintName string SprintURL string SprintStartDate string SprintDueDate string ReviewTasksInProgress []ReviewTask UnassignedReviewTasks []ReviewTask NewReviewTasks []ReviewTask } var ( conf config ) type config struct { Endpoint string `json:"endpoint"` // https://dev-dev.arvados.org Apikey string `json:"apikey"` // abcde... } func loadConfig() config { var c config Viper := viper.New() Viper.SetEnvPrefix("redmine") // will be uppercased automatically Viper.BindEnv("endpoint") Viper.BindEnv("apikey") c.Endpoint = Viper.GetString("endpoint") c.Apikey = Viper.GetString("apikey") return c } func init() { rootCmd.PersistentFlags().StringP("output", "o", "", "Output format. Empty for human-readable, 'json' or 'json-line'") rootCmd.PersistentFlags().BoolP("help", "h", false, "Print help") rootCmd.PersistentFlags().BoolP("debug", "d", false, "Print debug output") rootCmd.PersistentFlags().BoolP("send", "s", false, "Send reports via e-mail (if false, print them to stdout)") rootCmd.Flags().StringP("project", "p", "", "Redmine project name") err := rootCmd.MarkFlagRequired("project") if err != nil { log.Fatalf(err.Error()) } } var rootCmd = &cobra.Command{ Use: "review-task-reminder", Short: "review-task-reminder - Send e-mail reminders with the list of review tasks in progress", Long: ` review-task-reminder looks at the current sprint and e-mails a reminder to all people with assigned review tasks. https://git.arvados.org/arvados-dev.git/cmd/review-task-reminder` + "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." + "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.", PreRunE: func(cmd *cobra.Command, args []string) error { if conf.Endpoint == "" { cmd.Help() fmt.Println() fmt.Println("Error: the REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server") os.Exit(1) } if conf.Apikey == "" { cmd.Help() fmt.Println() fmt.Println("Error: the REDMINE_APIKEY environment variable must be set to your redmine API key") os.Exit(1) } var err error debug, err = cmd.Flags().GetBool("debug") if err != nil { log.Fatalf(err.Error()) } if debug { // parse string, this is built-in feature of logrus ll, err := log.ParseLevel("debug") if err != nil { ll = log.DebugLevel } // set global log level log.SetLevel(ll) log.Debug("Enabled debug log level") } send, err = cmd.Flags().GetBool("send") if err != nil { log.Fatalf(err.Error()) } return nil }, Run: func(cmd *cobra.Command, args []string) { log.Debug("Creating redmine object") rm := redmine.NewClient(conf.Endpoint, conf.Apikey) log.Debug("Getting project object") project, err := cmd.Flags().GetString("project") if err != nil { log.Fatalf(err.Error()) } p, err := rm.GetProjectByName(project) if err != nil { log.Fatalf(err.Error()) } log.Debugf("Project: %s ID: %d", project, p.ID) log.Debug("Getting versions") versions, err := rm.Versions(p.ID) if err != nil { log.Fatalf(err.Error()) } now := time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.UTC) // Find any current sprint(s) for _, v := range versions { // It must be "open" if v.Status != "open" { continue } // The due date must be in the future if v.DueDate == "" { continue } dueDate, err := time.Parse("2006-01-02", v.DueDate) if err != nil { log.Fatalf(err.Error()) } if dueDate.Before(now) { continue } // The start date must be in the past (have to look up the Sprint object!) log.Debugf("Getting sprint with id %d", v.ID) s, err := rm.Sprint(v.ID) if err != nil { log.Fatalf(err.Error()) } if s.StartDate == "" { continue } startDate, err := time.Parse("2006-01-02", s.StartDate) if err != nil { log.Fatalf(err.Error()) } if startDate.After(now) { continue } // Found a current sprint log.Debugf("Current sprint: %+#v", s) // Get the issues from this sprint var issueFilter redmine.IssueFilter issueFilter.VersionID = strconv.Itoa(v.ID) log.Debugf("Getting issues with version ID %d", v.ID) issues, err := rm.FilteredIssues(&issueFilter) if err != nil { log.Fatalf(err.Error()) } log.Debugf("Retrieved %d issues", len(issues)) reviewTasksByDeveloper := make(map[int][]ReviewTask) var UnassignedReviewTasks []ReviewTask for _, i := range issues { log.Debugf("Considering issue (%s): %+#v, \"%s\"", i.Tracker.Name, i.ID, i.Subject) // Filter for tasks if i.Tracker.Name != "Task" { continue } // Filter for review tasks (issue subject must start with 'review') reviewRE := regexp.MustCompile(`^(Review|review)`) if !reviewRE.MatchString(i.Subject) { continue } // Is the task assigned? if i.AssignedTo == nil { log.Debugf("Found unassigned review task: %+#v, \"%s\"", i.ID, i.Subject) log.Debugf("Getting parent issue with ID %d", i.Parent.ID) parent, err := rm.GetIssue(i.Parent.ID) if err != nil { log.Fatalf(err.Error()) } rt := ReviewTask{ IssueID: "#" + strconv.Itoa(parent.ID), IssueSubject: limit(parent.Subject, 58), IssueURL: conf.Endpoint + "/issues/" + strconv.Itoa(parent.ID), ID: "#" + strconv.Itoa(i.ID), Subject: limit(i.Subject, 58), URL: conf.Endpoint + "/issues/" + strconv.Itoa(i.ID), Status: i.Status.Name, } UnassignedReviewTasks = append(UnassignedReviewTasks, rt) continue } // Found an assigned review task log.Debugf("Found assigned review task: %+#v, \"%s\", assigned to %+#v", i.ID, i.Subject, i.AssignedTo.ID) if _, ok := reviewTasksByDeveloper[i.AssignedTo.ID]; !ok { reviewTasksByDeveloper[i.AssignedTo.ID] = []ReviewTask{} } log.Debugf("Getting parent issue with ID %d", i.Parent.ID) parent, err := rm.GetIssue(i.Parent.ID) if err != nil { log.Fatalf(err.Error()) } rt := ReviewTask{ IssueID: "#" + strconv.Itoa(parent.ID), IssueSubject: limit(parent.Subject, 58), IssueURL: conf.Endpoint + "/issues/" + strconv.Itoa(parent.ID), ID: "#" + strconv.Itoa(i.ID), Subject: limit(i.Subject, 58), URL: conf.Endpoint + "/issues/" + strconv.Itoa(i.ID), Status: i.Status.Name, } reviewTasksByDeveloper[i.AssignedTo.ID] = append(reviewTasksByDeveloper[i.AssignedTo.ID], rt) } // Create the report(s) log.Debug("Creating reports") for developerID, rt := range reviewTasksByDeveloper { log.Debugf("Getting user with ID %d", developerID) u, err := rm.User(developerID) if err != nil { log.Fatalf(err.Error()) } var report Report report.Developer = u.FirstName + " " + u.LastName + " <" + u.Mail + ">" report.Email = u.Mail report.SprintName = s.Name report.SprintURL = conf.Endpoint + "/rb/taskboards/" + strconv.Itoa(s.ID) report.SprintStartDate = s.StartDate report.SprintDueDate = s.DueDate report.UnassignedReviewTasks = UnassignedReviewTasks for _, r := range rt { log.Debugf("rt status %s", r.Status) if r.Status == "In Progress" { report.ReviewTasksInProgress = append(report.ReviewTasksInProgress, r) } else if r.Status == "New" { report.NewReviewTasks = append(report.NewReviewTasks, r) } } if len(report.ReviewTasksInProgress) == 1 { report.Subject = strings.Title(project) + ": you have 1 review to finish" } else if len(report.ReviewTasksInProgress) > 0 { report.Subject = strings.Title(project) + ": you have " + strconv.Itoa(len(report.ReviewTasksInProgress)) + " reviews to finish" } else if len(report.UnassignedReviewTasks) > 0 { report.Subject = strings.Title(project) + ": nobody is waiting on a review from you (but take an unassigned review, please?)" } else { report.Subject = strings.Title(project) + ": nobody is waiting on a review from you" } t, err := template.New("report").Parse(emailTemplate) if err != nil { log.Fatalf(err.Error()) } err = t.Execute(&report.Body, report) if err != nil { log.Fatalf(err.Error()) } if send && !debug { log.Info("Sending e-mail report to " + report.Email) _, err = report.SendEmail() if err != nil { log.Fatalf(err.Error()) } } else if !debug { log.Info("Not sending e-mail (--send option not enabled), report follows:") log.Info(report.Body.String()) } else { log.Debug("Not sending e-mail (debug mode), report follows:") log.Debug(report.Body.String()) } } } }, } func (r *Report) SendEmail() (bool, error) { if err := smtp.SendMail("localhost:25", nil, "sysadmin@curii.com", []string{r.Email}, r.Body.Bytes()); err != nil { return false, err } return true, nil } func limit(text string, limit int) string { if len(text) > limit { return text[:limit] + "..." } return text } func Execute() { conf = loadConfig() if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } }