Merge branch '19092-upload-crunchstat_summary-to-pypi'
[arvados-dev.git] / cmd / review-task-reminder / root.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: Apache-2.0
4
5 package main
6
7 import (
8         "bytes"
9         _ "embed"
10         "fmt"
11         "net/smtp"
12         "os"
13         "regexp"
14         "strconv"
15         "strings"
16         "text/template"
17         "time"
18
19         "git.arvados.org/arvados-dev.git/lib/redmine"
20         log "github.com/sirupsen/logrus"
21         "github.com/spf13/cobra"
22         "github.com/spf13/viper"
23 )
24
25 //go:embed emailTemplate.txt
26 var emailTemplate string
27 var debug, send bool
28
29 type ReviewTask struct {
30         IssueID      string
31         IssueSubject string
32         IssueURL     string
33         ID           string
34         Subject      string
35         URL          string
36         Status       string
37 }
38
39 type Report struct {
40         Developer             string
41         Email                 string
42         Subject               string
43         Body                  bytes.Buffer
44         SprintName            string
45         SprintURL             string
46         SprintStartDate       string
47         SprintDueDate         string
48         ReviewTasksInProgress []ReviewTask
49         UnassignedReviewTasks []ReviewTask
50         NewReviewTasks        []ReviewTask
51 }
52
53 var (
54         conf config
55 )
56
57 type config struct {
58         Endpoint string `json:"endpoint"` // https://dev-dev.arvados.org
59         Apikey   string `json:"apikey"`   // abcde...
60 }
61
62 func loadConfig() config {
63         var c config
64
65         Viper := viper.New()
66         Viper.SetEnvPrefix("redmine") // will be uppercased automatically
67         Viper.BindEnv("endpoint")
68         Viper.BindEnv("apikey")
69
70         c.Endpoint = Viper.GetString("endpoint")
71         c.Apikey = Viper.GetString("apikey")
72
73         return c
74 }
75
76 func init() {
77         rootCmd.PersistentFlags().StringP("output", "o", "", "Output format. Empty for human-readable, 'json' or 'json-line'")
78         rootCmd.PersistentFlags().BoolP("help", "h", false, "Print help")
79         rootCmd.PersistentFlags().BoolP("debug", "d", false, "Print debug output")
80         rootCmd.PersistentFlags().BoolP("send", "s", false, "Send reports via e-mail (if false, print them to stdout)")
81         rootCmd.Flags().StringP("project", "p", "", "Redmine project name")
82         err := rootCmd.MarkFlagRequired("project")
83         if err != nil {
84                 log.Fatalf(err.Error())
85         }
86
87 }
88
89 var rootCmd = &cobra.Command{
90         Use:   "review-task-reminder",
91         Short: "review-task-reminder - Send e-mail reminders with the list of review tasks in progress",
92         Long: `
93 review-task-reminder looks at the current sprint and e-mails a reminder to all
94 people with assigned review tasks.
95
96 https://git.arvados.org/arvados-dev.git/cmd/review-task-reminder` +
97                 "\nThe REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server." +
98                 "\nThe REDMINE_APIKEY environment variable must be set to your redmine API key.",
99         PreRunE: func(cmd *cobra.Command, args []string) error {
100                 if conf.Endpoint == "" {
101                         cmd.Help()
102                         fmt.Println()
103                         fmt.Println("Error: the REDMINE_ENDPOINT environment variable must be set to the base URL of your redmine server")
104                         os.Exit(1)
105                 }
106                 if conf.Apikey == "" {
107                         cmd.Help()
108                         fmt.Println()
109                         fmt.Println("Error: the REDMINE_APIKEY environment variable must be set to your redmine API key")
110                         os.Exit(1)
111                 }
112                 var err error
113                 debug, err = cmd.Flags().GetBool("debug")
114                 if err != nil {
115                         log.Fatalf(err.Error())
116                 }
117                 if debug {
118                         // parse string, this is built-in feature of logrus
119                         ll, err := log.ParseLevel("debug")
120                         if err != nil {
121                                 ll = log.DebugLevel
122                         }
123                         // set global log level
124                         log.SetLevel(ll)
125                         log.Debug("Enabled debug log level")
126                 }
127
128                 send, err = cmd.Flags().GetBool("send")
129                 if err != nil {
130                         log.Fatalf(err.Error())
131                 }
132
133                 return nil
134         },
135         Run: func(cmd *cobra.Command, args []string) {
136                 log.Debug("Creating redmine object")
137                 rm := redmine.NewClient(conf.Endpoint, conf.Apikey)
138
139                 log.Debug("Getting project object")
140                 project, err := cmd.Flags().GetString("project")
141                 if err != nil {
142                         log.Fatalf(err.Error())
143                 }
144                 p, err := rm.GetProjectByName(project)
145                 if err != nil {
146                         log.Fatalf(err.Error())
147                 }
148
149                 log.Debugf("Project: %s ID: %d", project, p.ID)
150
151                 log.Debug("Getting versions")
152                 versions, err := rm.Versions(p.ID)
153                 if err != nil {
154                         log.Fatalf(err.Error())
155                 }
156                 now := time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.UTC)
157                 // Find any current sprint(s)
158                 for _, v := range versions {
159                         // It must be "open"
160                         if v.Status != "open" {
161                                 continue
162                         }
163                         // The due date must be in the future
164                         if v.DueDate == "" {
165                                 continue
166                         }
167                         dueDate, err := time.Parse("2006-01-02", v.DueDate)
168                         if err != nil {
169                                 log.Fatalf(err.Error())
170                         }
171                         if dueDate.Before(now) {
172                                 continue
173                         }
174                         // The start date must be in the past (have to look up the Sprint object!)
175                         log.Debugf("Getting sprint with id %d", v.ID)
176                         s, err := rm.Sprint(v.ID)
177                         if err != nil {
178                                 log.Fatalf(err.Error())
179                         }
180                         if s.StartDate == "" {
181                                 continue
182                         }
183                         startDate, err := time.Parse("2006-01-02", s.StartDate)
184                         if err != nil {
185                                 log.Fatalf(err.Error())
186                         }
187                         if startDate.After(now) {
188                                 continue
189                         }
190                         // Found a current sprint
191                         log.Debugf("Current sprint: %+#v", s)
192
193                         // Get the issues from this sprint
194                         var issueFilter redmine.IssueFilter
195                         issueFilter.VersionID = strconv.Itoa(v.ID)
196                         log.Debugf("Getting issues with version ID %d", v.ID)
197                         issues, err := rm.FilteredIssues(&issueFilter)
198                         if err != nil {
199                                 log.Fatalf(err.Error())
200                         }
201
202                         log.Debugf("Retrieved %d issues", len(issues))
203                         reviewTasksByDeveloper := make(map[int][]ReviewTask)
204                         var UnassignedReviewTasks []ReviewTask
205                         for _, i := range issues {
206                                 log.Debugf("Considering issue (%s): %+#v, \"%s\"", i.Tracker.Name, i.ID, i.Subject)
207                                 // Filter for tasks
208                                 if i.Tracker.Name != "Task" {
209                                         continue
210                                 }
211                                 // Filter for review tasks (issue subject must start with 'review')
212                                 reviewRE := regexp.MustCompile(`^(Review|review)`)
213                                 if !reviewRE.MatchString(i.Subject) {
214                                         continue
215                                 }
216                                 // Is the task assigned?
217                                 if i.AssignedTo == nil {
218                                         log.Debugf("Found unassigned review task: %+#v, \"%s\"", i.ID, i.Subject)
219                                         log.Debugf("Getting parent issue with ID %d", i.Parent.ID)
220                                         parent, err := rm.GetIssue(i.Parent.ID)
221                                         if err != nil {
222                                                 log.Fatalf(err.Error())
223                                         }
224                                         rt := ReviewTask{
225                                                 IssueID:      "#" + strconv.Itoa(parent.ID),
226                                                 IssueSubject: limit(parent.Subject, 58),
227                                                 IssueURL:     conf.Endpoint + "/issues/" + strconv.Itoa(parent.ID),
228                                                 ID:           "#" + strconv.Itoa(i.ID),
229                                                 Subject:      limit(i.Subject, 58),
230                                                 URL:          conf.Endpoint + "/issues/" + strconv.Itoa(i.ID),
231                                                 Status:       i.Status.Name,
232                                         }
233                                         UnassignedReviewTasks = append(UnassignedReviewTasks, rt)
234                                         continue
235                                 }
236                                 // Found an assigned review task
237                                 log.Debugf("Found assigned review task: %+#v, \"%s\", assigned to %+#v", i.ID, i.Subject, i.AssignedTo.ID)
238                                 if _, ok := reviewTasksByDeveloper[i.AssignedTo.ID]; !ok {
239                                         reviewTasksByDeveloper[i.AssignedTo.ID] = []ReviewTask{}
240                                 }
241                                 log.Debugf("Getting parent issue with ID %d", i.Parent.ID)
242                                 parent, err := rm.GetIssue(i.Parent.ID)
243                                 if err != nil {
244                                         log.Fatalf(err.Error())
245                                 }
246                                 rt := ReviewTask{
247                                         IssueID:      "#" + strconv.Itoa(parent.ID),
248                                         IssueSubject: limit(parent.Subject, 58),
249                                         IssueURL:     conf.Endpoint + "/issues/" + strconv.Itoa(parent.ID),
250                                         ID:           "#" + strconv.Itoa(i.ID),
251                                         Subject:      limit(i.Subject, 58),
252                                         URL:          conf.Endpoint + "/issues/" + strconv.Itoa(i.ID),
253                                         Status:       i.Status.Name,
254                                 }
255                                 reviewTasksByDeveloper[i.AssignedTo.ID] = append(reviewTasksByDeveloper[i.AssignedTo.ID], rt)
256                         }
257
258                         // Create the report(s)
259                         log.Debug("Creating reports")
260                         for developerID, rt := range reviewTasksByDeveloper {
261                                 log.Debugf("Getting user with ID %d", developerID)
262                                 u, err := rm.User(developerID)
263                                 if err != nil {
264                                         log.Fatalf(err.Error())
265                                 }
266                                 var report Report
267                                 report.Developer = u.FirstName + " " + u.LastName + " <" + u.Mail + ">"
268                                 report.Email = u.Mail
269                                 report.SprintName = s.Name
270                                 report.SprintURL = conf.Endpoint + "/rb/taskboards/" + strconv.Itoa(s.ID)
271                                 report.SprintStartDate = s.StartDate
272                                 report.SprintDueDate = s.DueDate
273                                 report.UnassignedReviewTasks = UnassignedReviewTasks
274                                 for _, r := range rt {
275                                         log.Debugf("rt status %s", r.Status)
276                                         if r.Status == "In Progress" {
277                                                 report.ReviewTasksInProgress = append(report.ReviewTasksInProgress, r)
278                                         } else if r.Status == "New" {
279                                                 report.NewReviewTasks = append(report.NewReviewTasks, r)
280                                         }
281                                 }
282                                 if len(report.ReviewTasksInProgress) == 1 {
283                                         report.Subject = strings.Title(project) + ": you have 1 review to finish"
284                                 } else if len(report.ReviewTasksInProgress) > 0 {
285                                         report.Subject = strings.Title(project) + ": you have " + strconv.Itoa(len(report.ReviewTasksInProgress)) + " reviews to finish"
286                                 } else if len(report.UnassignedReviewTasks) > 0 {
287                                         report.Subject = strings.Title(project) + ": nobody is waiting on a review from you (but take an unassigned review, please?)"
288                                 } else {
289                                         report.Subject = strings.Title(project) + ": nobody is waiting on a review from you"
290                                 }
291                                 t, err := template.New("report").Parse(emailTemplate)
292                                 if err != nil {
293                                         log.Fatalf(err.Error())
294                                 }
295                                 err = t.Execute(&report.Body, report)
296                                 if err != nil {
297                                         log.Fatalf(err.Error())
298                                 }
299                                 if send && !debug {
300                                         log.Info("Sending e-mail report to " + report.Email)
301                                         _, err = report.SendEmail()
302                                         if err != nil {
303                                                 log.Fatalf(err.Error())
304                                         }
305                                 } else if !debug {
306                                         log.Info("Not sending e-mail (--send option not enabled), report follows:")
307                                         log.Info(report.Body.String())
308                                 } else {
309                                         log.Debug("Not sending e-mail (debug mode), report follows:")
310                                         log.Debug(report.Body.String())
311                                 }
312                         }
313                 }
314         },
315 }
316
317 func (r *Report) SendEmail() (bool, error) {
318         if err := smtp.SendMail("localhost:25", nil, "sysadmin@curii.com", []string{r.Email}, r.Body.Bytes()); err != nil {
319                 return false, err
320         }
321         return true, nil
322 }
323
324 func limit(text string, limit int) string {
325         if len(text) > limit {
326                 return text[:limit] + "..."
327         }
328         return text
329 }
330
331 func Execute() {
332         conf = loadConfig()
333         if err := rootCmd.Execute(); err != nil {
334                 fmt.Fprintln(os.Stderr, err)
335                 os.Exit(1)
336         }
337 }