d8ecd97c5fd233d088c425b04f6f0e99880e556c
[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                 // Find any current sprint(s)
157                 for _, v := range versions {
158                         // It must be "open"
159                         if v.Status != "open" {
160                                 continue
161                         }
162                         // The due date must be in the future
163                         if v.DueDate == "" {
164                                 continue
165                         }
166                         dueDate, err := time.Parse("2006-01-02", v.DueDate)
167                         if err != nil {
168                                 log.Fatalf(err.Error())
169                         }
170                         if time.Now().After(dueDate) {
171                                 continue
172                         }
173                         // The start date must be in the past (have to look up the Sprint object!)
174                         log.Debugf("Getting sprint with id %d", v.ID)
175                         s, err := rm.Sprint(v.ID)
176                         if err != nil {
177                                 log.Fatalf(err.Error())
178                         }
179                         if s.StartDate == "" {
180                                 continue
181                         }
182                         startDate, err := time.Parse("2006-01-02", s.StartDate)
183                         if err != nil {
184                                 log.Fatalf(err.Error())
185                         }
186                         if time.Now().Before(startDate) {
187                                 continue
188                         }
189                         // Found a current sprint
190                         log.Debugf("Current sprint: %+#v", s)
191
192                         // Get the issues from this sprint
193                         var issueFilter redmine.IssueFilter
194                         issueFilter.VersionID = strconv.Itoa(v.ID)
195                         log.Debugf("Getting issues with version ID %d", v.ID)
196                         issues, err := rm.FilteredIssues(&issueFilter)
197                         if err != nil {
198                                 log.Fatalf(err.Error())
199                         }
200
201                         log.Debugf("Retrieved %d issues", len(issues))
202                         reviewTasksByDeveloper := make(map[int][]ReviewTask)
203                         var UnassignedReviewTasks []ReviewTask
204                         for _, i := range issues {
205                                 log.Debugf("Considering issue (%s): %+#v, \"%s\"", i.Tracker.Name, i.ID, i.Subject)
206                                 // Filter for tasks
207                                 if i.Tracker.Name != "Task" {
208                                         continue
209                                 }
210                                 // Filter for review tasks (issue subject must start with 'review')
211                                 reviewRE := regexp.MustCompile(`^review`)
212                                 if !reviewRE.MatchString(i.Subject) {
213                                         continue
214                                 }
215                                 // Is the task assigned?
216                                 if i.AssignedTo == nil {
217                                         log.Debugf("Found unassigned review task: %+#v, \"%s\"", i.ID, i.Subject)
218                                         log.Debugf("Getting parent issue with ID %d", i.Parent.ID)
219                                         parent, err := rm.GetIssue(i.Parent.ID)
220                                         if err != nil {
221                                                 log.Fatalf(err.Error())
222                                         }
223                                         rt := ReviewTask{
224                                                 IssueID:      "#" + strconv.Itoa(parent.ID),
225                                                 IssueSubject: limit(parent.Subject, 58),
226                                                 IssueURL:     conf.Endpoint + "/issues/" + strconv.Itoa(parent.ID),
227                                                 ID:           "#" + strconv.Itoa(i.ID),
228                                                 Subject:      limit(i.Subject, 58),
229                                                 URL:          conf.Endpoint + "/issues/" + strconv.Itoa(i.ID),
230                                                 Status:       i.Status.Name,
231                                         }
232                                         UnassignedReviewTasks = append(UnassignedReviewTasks, rt)
233                                         continue
234                                 }
235                                 // Found an assigned review task
236                                 log.Debugf("Found assigned review task: %+#v, \"%s\", assigned to %+#v", i.ID, i.Subject, i.AssignedTo.ID)
237                                 if _, ok := reviewTasksByDeveloper[i.AssignedTo.ID]; !ok {
238                                         reviewTasksByDeveloper[i.AssignedTo.ID] = []ReviewTask{}
239                                 }
240                                 log.Debugf("Getting parent issue with ID %d", i.Parent.ID)
241                                 parent, err := rm.GetIssue(i.Parent.ID)
242                                 if err != nil {
243                                         log.Fatalf(err.Error())
244                                 }
245                                 rt := ReviewTask{
246                                         IssueID:      "#" + strconv.Itoa(parent.ID),
247                                         IssueSubject: limit(parent.Subject, 58),
248                                         IssueURL:     conf.Endpoint + "/issues/" + strconv.Itoa(parent.ID),
249                                         ID:           "#" + strconv.Itoa(i.ID),
250                                         Subject:      limit(i.Subject, 58),
251                                         URL:          conf.Endpoint + "/issues/" + strconv.Itoa(i.ID),
252                                         Status:       i.Status.Name,
253                                 }
254                                 reviewTasksByDeveloper[i.AssignedTo.ID] = append(reviewTasksByDeveloper[i.AssignedTo.ID], rt)
255                         }
256
257                         // Create the report(s)
258                         log.Debug("Creating reports")
259                         for developerID, rt := range reviewTasksByDeveloper {
260                                 log.Debugf("Getting user with ID %d", developerID)
261                                 u, err := rm.User(developerID)
262                                 if err != nil {
263                                         log.Fatalf(err.Error())
264                                 }
265                                 var report Report
266                                 report.Developer = u.FirstName + " " + u.LastName + " <" + u.Mail + ">"
267                                 report.Email = u.Mail
268                                 report.SprintName = s.Name
269                                 report.SprintURL = conf.Endpoint + "/rb/taskboards/" + strconv.Itoa(s.ID)
270                                 report.SprintStartDate = s.StartDate
271                                 report.SprintDueDate = s.DueDate
272                                 report.UnassignedReviewTasks = UnassignedReviewTasks
273                                 for _, r := range rt {
274                                         log.Debugf("rt status %s", r.Status)
275                                         if r.Status == "In Progress" {
276                                                 report.ReviewTasksInProgress = append(report.ReviewTasksInProgress, r)
277                                         } else if r.Status == "New" {
278                                                 report.NewReviewTasks = append(report.NewReviewTasks, r)
279                                         }
280                                 }
281                                 if len(report.ReviewTasksInProgress) == 1 {
282                                         report.Subject = strings.Title(project) + ": you have 1 review to finish"
283                                 } else if len(report.ReviewTasksInProgress) > 0 {
284                                         report.Subject = strings.Title(project) + ": you have " + strconv.Itoa(len(report.ReviewTasksInProgress)) + " reviews to finish"
285                                 } else if len(report.UnassignedReviewTasks) > 0 {
286                                         report.Subject = strings.Title(project) + ": nobody is waiting on a review from you (but take an unassigned review, please?)"
287                                 } else {
288                                         report.Subject = strings.Title(project) + ": nobody is waiting on a review from you"
289                                 }
290                                 t, err := template.New("report").Parse(emailTemplate)
291                                 if err != nil {
292                                         log.Fatalf(err.Error())
293                                 }
294                                 err = t.Execute(&report.Body, report)
295                                 if err != nil {
296                                         log.Fatalf(err.Error())
297                                 }
298                                 if send && !debug {
299                                         log.Info("Sending e-mail report to " + report.Email)
300                                         _, err = report.SendEmail()
301                                         if err != nil {
302                                                 log.Fatalf(err.Error())
303                                         }
304                                 } else if !debug {
305                                         log.Info("Not sending e-mail (--send option not enabled), report follows:")
306                                         log.Info(report.Body.String())
307                                 } else {
308                                         log.Debug("Not sending e-mail (debug mode), report follows:")
309                                         log.Debug(report.Body.String())
310                                 }
311                         }
312                 }
313         },
314 }
315
316 func (r *Report) SendEmail() (bool, error) {
317         if err := smtp.SendMail("localhost:25", nil, "sysadmin@curii.com", []string{r.Email}, r.Body.Bytes()); err != nil {
318                 return false, err
319         }
320         return true, nil
321 }
322
323 func limit(text string, limit int) string {
324         if len(text) > limit {
325                 return text[:limit] + "..."
326         }
327         return text
328 }
329
330 func Execute() {
331         conf = loadConfig()
332         if err := rootCmd.Execute(); err != nil {
333                 fmt.Fprintln(os.Stderr, err)
334                 os.Exit(1)
335         }
336 }